Skip to main content

xmtp_mls_common/app_data/
component_source.rs

1//! Single source-of-truth for the per-`ComponentId` read/encode/apply logic.
2//!
3//! Centralizes everything the rest of the MLS pipeline needs to know about
4//! a well-known `ComponentId`: its logical [`ComponentType`], where its
5//! current bytes live (OpenMLS AppData dictionary vs. legacy group context
6//! extensions), and how to encode/apply `AppDataUpdate` payloads.
7//!
8//! The helpers are `pub` so `xmtp_mls` can call them. They read and write
9//! raw component bytes; `xmtp_mls` owns the intent and commit paths that
10//! decide when a write is allowed.
11//!
12//! ## Field bounds
13//!
14//! [`apply_app_data_update_payload`] rejects a new value of an application
15//! component, `USER_DISPLAY_NAME`, or `GROUP_IMAGE` whose scalar,
16//! collection key, or collection value exceeds [`MAX_FIELD_ELEMENT_BYTES`],
17//! or whose serialized collection exceeds [`MAX_FIELD_SNAPSHOT_BYTES`].
18//! [`check_update_payload_bounds`] applies the element bound to every
19//! element an update payload names, without the state it applies to.
20//!
21//! ## Inbox-id encoding
22//!
23//! The legacy GMM extension stores inbox ids as 64-character hex strings.
24//! Anything serialized through the new `AppDataUpdate` path uses the
25//! versioned [`InboxId`] newtype.
26//!
27//! See [`crate::inbox_id`] for the full wire-format contract;
28//! the short version is `varint(version) || 32-byte payload`, with
29//! version 0 producing a 33-byte encoding.
30
31use crate::{
32    app_data::{
33        component_id::ComponentId,
34        component_registry::ComponentRegistry,
35        components::type_dispatch::{apply_update_payload_for_type, expand_to_changes_for_type},
36        registry_table::lookup_component,
37        typed::ComponentTypedError,
38    },
39    group_mutable_metadata::{GroupMutableMetadata, GroupMutableMetadataError, MetadataField},
40    inbox_id::{InboxId, InboxIdError},
41    tls_map::{TlsMap, TlsMapDelta, TlsMapError, TlsMapMutation},
42    tls_set::{TlsSet, TlsSetDelta, TlsSetError, TlsSetMutation},
43};
44use openmls::{
45    extensions::Extensions,
46    group::{GroupContext, MlsGroup as OpenMlsGroup, StagedCommit},
47    messages::proposals::AppDataUpdateOperation,
48};
49use tls_codec::{Deserialize, Serialize, VLBytes};
50use xmtp_proto::xmtp::mls::message_contents::ComponentType;
51
52/// Longest scalar, collection key, or collection value that an application
53/// component, `USER_DISPLAY_NAME`, `GROUP_IMAGE`, or `COMPONENT_REGISTRY` may
54/// hold.
55pub const MAX_FIELD_ELEMENT_BYTES: usize = 8192;
56
57/// Longest serialized collection snapshot that an application component,
58/// `USER_DISPLAY_NAME`, `GROUP_IMAGE`, or `COMPONENT_REGISTRY` may hold.
59pub const MAX_FIELD_SNAPSHOT_BYTES: usize = 65536;
60
61/// Errors surfaced by the component_source layer.
62///
63/// `xmtp_mls` embeds this in `GroupError` via `#[from]` for the
64/// AppDataUpdate path.
65#[derive(Debug, thiserror::Error)]
66pub enum ComponentSourceError {
67    /// The component id is outside the well-known XMTP range.
68    #[error("unknown component {0}")]
69    UnknownComponent(ComponentId),
70
71    /// The component is known but its wiring hasn't been built yet.
72    #[error("component {0} wiring is not yet implemented")]
73    NotImplemented(ComponentId),
74
75    /// An `AppDataUpdate::Update` write was attempted against an immutable
76    /// component. Insert-once writes should be expressed as `Insert`, not
77    /// caught here.
78    #[error("component {0} is immutable and cannot be updated via AppDataUpdate")]
79    ImmutableUpdate(ComponentId),
80
81    /// A mutation does not match the component type of the component it
82    /// targets (e.g. a scalar write to `ADMIN_LIST`).
83    #[error("mutation shape does not match component {0}")]
84    MismatchedMutation(ComponentId),
85
86    /// Failed to convert an inbox id string or byte slice into an
87    /// [`InboxId`]. Wraps [`InboxIdError`] — callers that need to
88    /// distinguish "not hex" from "wrong length" can match the inner
89    /// variant.
90    #[error("invalid inbox id: {0}")]
91    InvalidInboxId(#[from] InboxIdError),
92
93    /// A wire-format violation on a component value: the bytes stored in
94    /// the AppData dictionary for a known component don't decode under the
95    /// expected encoding (e.g. non-UTF-8 bytes for a `Bytes`-typed
96    /// metadata attribute, malformed `TlsSet` for a collection component).
97    #[error("malformed value for component {component_id}: {reason}")]
98    MalformedComponentValue {
99        /// The component whose stored bytes failed to decode.
100        component_id: ComponentId,
101        /// Human-readable reason — surface to logs, not user-facing.
102        reason: String,
103    },
104
105    /// A `MetadataUpdate` intent referenced a metadata field name that has
106    /// no corresponding `ComponentId`. Most commonly fires when a future
107    /// metadata field is added to one of the senders without also being
108    /// added to [`metadata_field_to_component_id`].
109    #[error("unknown metadata field name: {0}")]
110    UnknownMetadataField(String),
111
112    /// Failed to read, decode, or encode the legacy group mutable metadata
113    /// extension while servicing a component-source request.
114    #[error(transparent)]
115    GroupMutableMetadata(#[from] GroupMutableMetadataError),
116
117    /// A TLS-codec operation on a delta or stored collection value failed.
118    #[error("tls codec error: {0}")]
119    TlsCodec(#[from] tls_codec::Error),
120
121    /// A `TlsSet::apply_delta` call failed while synthesizing the new full
122    /// value of a collection component from an incoming delta.
123    #[error("tls set apply error: {0}")]
124    TlsSetApply(#[from] TlsSetError),
125
126    /// A `TlsMap::apply_delta` call failed while synthesizing the new full
127    /// value of a map component from an incoming delta.
128    #[error("tls map apply error: {0}")]
129    TlsMapApply(#[from] TlsMapError),
130
131    /// An update would leave a bounded component holding a scalar,
132    /// collection element, or collection snapshot longer than its limit
133    /// ([`MAX_FIELD_ELEMENT_BYTES`] or [`MAX_FIELD_SNAPSHOT_BYTES`]).
134    #[error("component {component_id} holds {len} bytes, over the {max}-byte bound")]
135    FieldBoundExceeded {
136        /// The component whose new value is too large.
137        component_id: ComponentId,
138        /// Length of the offending scalar, element, or snapshot.
139        len: usize,
140        /// The bound it exceeds.
141        max: usize,
142    },
143}
144
145impl ComponentSourceError {
146    /// Best-effort `ComponentId` extraction for the variants that carry
147    /// one — so error-mapping shims can preserve structured context
148    /// across the crate boundary into
149    /// [`GroupMutableMetadataError::MalformedComponent`] without
150    /// stringifying.
151    pub fn component_id(&self) -> Option<ComponentId> {
152        match self {
153            Self::UnknownComponent(id)
154            | Self::NotImplemented(id)
155            | Self::ImmutableUpdate(id)
156            | Self::MismatchedMutation(id)
157            | Self::MalformedComponentValue {
158                component_id: id, ..
159            }
160            | Self::FieldBoundExceeded {
161                component_id: id, ..
162            } => Some(*id),
163            _ => None,
164        }
165    }
166}
167
168impl From<ComponentTypedError> for ComponentSourceError {
169    /// Surface trait-layer errors at the dispatch boundary. The
170    /// dispatch layer adds `UnknownComponent` / `NotImplemented` /
171    /// `UnknownMetadataField` / `GroupMutableMetadata` for things the
172    /// trait can't see; the variants below are the trait's domain
173    /// and round-trip 1:1.
174    fn from(err: ComponentTypedError) -> Self {
175        match err {
176            ComponentTypedError::ImmutableUpdate(id) => Self::ImmutableUpdate(id),
177            ComponentTypedError::MismatchedMutation(id) => Self::MismatchedMutation(id),
178            ComponentTypedError::MalformedValue {
179                component_id,
180                reason,
181            } => Self::MalformedComponentValue {
182                component_id,
183                reason,
184            },
185            ComponentTypedError::InvalidInboxId(e) => Self::InvalidInboxId(e),
186            ComponentTypedError::TlsCodec(e) => Self::TlsCodec(e),
187            ComponentTypedError::TlsSetApply(e) => Self::TlsSetApply(e),
188            ComponentTypedError::TlsMapApply(e) => Self::TlsMapApply(e),
189            ComponentTypedError::UnspecifiedType(id) => Self::MalformedComponentValue {
190                component_id: id,
191                reason: "registered ComponentType is Unspecified".to_string(),
192            },
193            // A COMPONENT_REGISTRY delta that violates the registry's
194            // write invariants (reserved/hardcoded/out-of-space id,
195            // immutable overwrite, undecodable metadata). Structurally
196            // a malformed value for the registry component; the reason
197            // string carries the specific violation.
198            ComponentTypedError::RegistryMutation(e) => Self::MalformedComponentValue {
199                component_id: ComponentId::COMPONENT_REGISTRY,
200                reason: e.to_string(),
201            },
202        }
203    }
204}
205
206impl From<ComponentSourceError> for GroupMutableMetadataError {
207    /// Preserve structure where possible. If the source already wraps a
208    /// `GroupMutableMetadataError`, unwrap and return that inner variant
209    /// verbatim so callers can
210    /// match on `MissingExtension` / `MissingMetadataField` / etc.
211    ///
212    /// For every other variant, surface as `MalformedComponent` and
213    /// preserve the offending `component_id` when it's available so
214    /// downstream consumers (bindings, error-mapping) can match
215    /// structurally on it. Variants without one surface as
216    /// `component_id: None`; the display string stays the
217    /// authoritative diagnostic.
218    fn from(err: ComponentSourceError) -> Self {
219        if let ComponentSourceError::GroupMutableMetadata(inner) = err {
220            return inner;
221        }
222        let component_id = err.component_id();
223        GroupMutableMetadataError::MalformedComponent {
224            component_id,
225            reason: err.to_string(),
226        }
227    }
228}
229
230/// Hardcoded logical type of a well-known component. Returns `None` for
231/// app-range components (`0xC000-0xFEFF`) and for any well-known id that
232/// is not yet wired into this match.
233pub fn component_type(id: ComponentId) -> Option<ComponentType> {
234    match id {
235        // Hardcoded registry / list components. ComponentRegistry itself is a
236        // TlsMap, but permissions are enforced in code — it never flows
237        // through this module.
238        ComponentId::COMPONENT_REGISTRY => Some(ComponentType::TlsMapBytesBytes),
239        ComponentId::SUPER_ADMIN_LIST => Some(ComponentType::TlsSetInboxId),
240        ComponentId::ADMIN_LIST => Some(ComponentType::TlsSetInboxId),
241
242        // GroupMembership — TlsMap<InboxId, bytes>
243        ComponentId::GROUP_MEMBERSHIP => Some(ComponentType::TlsMapInboxIdBytes),
244        ComponentId::USER_DISPLAY_NAME => Some(ComponentType::TlsMapInboxIdString),
245
246        // GroupMutableMetadata-backed string components.
247        ComponentId::GROUP_NAME
248        | ComponentId::GROUP_DESCRIPTION
249        | ComponentId::GROUP_IMAGE_URL
250        | ComponentId::APP_DATA
251        | ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION => Some(ComponentType::String),
252
253        // GroupMutableMetadata-backed bytes components.
254        ComponentId::MESSAGE_DISAPPEAR_FROM_NS
255        | ComponentId::MESSAGE_DISAPPEAR_IN_NS
256        | ComponentId::COMMIT_LOG_SIGNER
257        | ComponentId::GROUP_IMAGE => Some(ComponentType::Bytes),
258
259        // Immutable metadata (not flowable through AppDataUpdate writes,
260        // but we still advertise the type for completeness).
261        ComponentId::CONVERSATION_TYPE
262        | ComponentId::CREATOR_INBOX_ID
263        | ComponentId::ONESHOT_MESSAGE => Some(ComponentType::Bytes),
264        ComponentId::DM_MEMBERS => Some(ComponentType::TlsSetInboxId),
265
266        _ => None,
267    }
268}
269
270use crate::group_mutable_metadata::METADATA_FIELD_COMPONENT_MAP;
271
272/// Map a [`MetadataField`] string to its corresponding `ComponentId`.
273///
274/// Returns `None` for unknown field names so this can also be called with a
275/// raw string coming from a legacy intent payload.
276pub fn metadata_field_to_component_id(field_name: &str) -> Option<ComponentId> {
277    METADATA_FIELD_COMPONENT_MAP
278        .iter()
279        .find(|(field, _)| field.as_str() == field_name)
280        .map(|(_, id)| *id)
281}
282
283/// Map a `ComponentId` back to the `MetadataField` attribute name that stores
284/// it in the legacy `GroupMutableMetadata` extension.
285///
286/// Returns `None` for component ids that are not backed by a
287/// `GroupMutableMetadata` attribute (e.g. `ADMIN_LIST`, `GROUP_MEMBERSHIP`,
288/// or anything outside the mutable metadata family).
289pub fn component_id_to_metadata_field(id: ComponentId) -> Option<MetadataField> {
290    METADATA_FIELD_COMPONENT_MAP
291        .iter()
292        .find(|(_, component_id)| *component_id == id)
293        .map(|(field, _)| *field)
294}
295
296/// Read the component's current bytes from the OpenMLS AppData dictionary.
297pub fn read_component_bytes(
298    id: ComponentId,
299    extensions: &Extensions<GroupContext>,
300) -> Result<Option<Vec<u8>>, ComponentSourceError> {
301    Ok(read_from_app_data_dict_from_extensions(id, extensions))
302}
303
304/// Compute the post-commit value of a single component
305/// by overlaying the staged commit's `AppDataUpdate` proposals on top of
306/// the pre-commit dict. Last-write-wins matches the lazy-batching apply
307/// order in `xmtp_mls`'s `accumulate_app_data_updates`: every `Update(payload)`
308/// is decoded against the running value (so collection deltas compose),
309/// and `Remove` collapses to `None`.
310///
311/// Returns `Ok(None)` when the component is absent both before and after
312/// the commit, or when it was explicitly removed. Returns `Err` only when
313/// an `Update` payload fails to decode against the running value — the
314/// same condition `validate_app_data_update_proposals_in_commit` would
315/// also reject upstream, so callers can treat decode failure here as
316/// "validator will surface the real error" and short-circuit.
317///
318/// Used by the commit validator to evaluate per-component invariants
319/// (notably `MIN_SUPPORTED_PROTOCOL_VERSION`) that need the post-commit
320/// view from the AppData dictionary.
321///
322/// # Registry semantics
323///
324/// `registry` is the **pre-commit** `COMPONENT_REGISTRY` (i.e. the state
325/// of the dictionary entry before the staged commit applies). Callers
326/// should load it once via `xmtp_mls`'s `load_component_registry` on the
327/// live `mls_group` and reuse it across all validator helpers — same
328/// registry feeds `validate_app_data_update_proposals_in_commit`
329/// and any other per-component checks.
330///
331/// **Implication for commits that modify `COMPONENT_REGISTRY` in the
332/// same commit as a write to a newly-registered component**: such a
333/// write fails with `UnknownComponent` here because the new entry is
334/// not yet visible in the pre-commit registry. This matches what the
335/// receiver-side validator
336/// (`validate_app_data_update_proposals_in_commit`) enforces
337/// today and is the documented convention across the commit
338/// path: registry mutations and writes that depend on those mutations
339/// MUST land in separate commits.
340///
341pub fn read_post_commit_component_bytes(
342    id: ComponentId,
343    mls_group: &OpenMlsGroup,
344    staged_commit: &StagedCommit,
345    registry: &ComponentRegistry,
346) -> Result<Option<Vec<u8>>, ComponentSourceError> {
347    let openmls_id: openmls::component::ComponentId = id.as_u16();
348
349    // Owned snapshot of operations targeting this specific component.
350    // Iterating `app_data_update_proposals()` yields short-lived
351    // `QueuedAppDataUpdateProposal` views that borrow into the staged
352    // commit — we can't hold their byte slices across iterations, so
353    // we materialize an owned form up front. `Update` payloads are
354    // typically tiny (version strings, single-key deltas), so the
355    // clone cost is negligible.
356    enum Op {
357        Update(Vec<u8>),
358        Remove,
359    }
360    let ops: Vec<Op> = staged_commit
361        .app_data_update_proposals()
362        .filter_map(|queued| {
363            let proposal = queued.app_data_update_proposal();
364            if proposal.component_id() != openmls_id {
365                return None;
366            }
367            Some(match proposal.operation() {
368                AppDataUpdateOperation::Update(payload) => Op::Update(payload.as_slice().to_vec()),
369                AppDataUpdateOperation::Remove => Op::Remove,
370            })
371        })
372        .collect();
373    if ops.is_empty() {
374        return Ok(read_from_app_data_dict(id, mls_group));
375    }
376
377    let mut current = read_from_app_data_dict(id, mls_group);
378    for op in &ops {
379        match op {
380            Op::Update(payload) => {
381                current = Some(apply_app_data_update_payload(
382                    id,
383                    payload,
384                    current.as_deref(),
385                    registry,
386                )?);
387            }
388            Op::Remove => current = None,
389        }
390    }
391    Ok(current)
392}
393
394/// Look up the component's bytes in the OpenMLS AppData dictionary.
395///
396/// The commit validator in `xmtp_mls` pulls the pre-commit stored bytes
397/// for a component and threads them into
398/// [`expand_app_data_update_to_changes`] as `old_value` — the validator
399/// uses that to resolve `RemoveByHash` mutations back to their underlying
400/// inbox id. `xmtp_mls`'s `app_data` module also uses it from
401/// `process_message_with_app_data`, `stage_app_data_propose_and_commit`,
402/// and `pending_app_data_updates`.
403pub fn read_from_app_data_dict(id: ComponentId, mls_group: &OpenMlsGroup) -> Option<Vec<u8>> {
404    read_from_app_data_dict_from_extensions(id, mls_group.extensions())
405}
406
407/// Extensions-only counterpart of [`read_from_app_data_dict`] — reads the
408/// component's bytes straight from a group's `GroupContext` extensions, with no
409/// full `OpenMlsGroup`. openmls keys the dictionary by its own `ComponentId`,
410/// which is just a `u16` alias, so `id.as_u16()` unwraps our newtype to the key.
411pub fn read_from_app_data_dict_from_extensions(
412    id: ComponentId,
413    extensions: &Extensions<GroupContext>,
414) -> Option<Vec<u8>> {
415    extensions
416        .app_data_dictionary()
417        .and_then(|ext| ext.dictionary().get(&id.as_u16()))
418        .map(|bytes| bytes.to_vec())
419}
420
421use crate::app_data::typed::ExpandedComponentChange;
422
423/// Expand an `AppDataUpdate` proposal payload into the per-element changes
424/// that should be checked against the component registry.
425///
426/// - `Bytes` components: returns a single `Update` change with the new
427///   payload bytes.
428/// - Collection components (`ADMIN_LIST` / `SUPER_ADMIN_LIST`): parses the
429///   payload as a `TlsSetDelta<InboxId>` and emits one entry per
430///   mutation, with `op = Insert` for `Insert`, `op = Delete` for
431///   `Remove` / `RemoveByHash`.
432/// - `AppDataUpdateOperation::Remove` (any component): a single
433///   `Delete` entry with no value.
434///
435/// `old_value` is the component's pre-commit stored bytes (from the
436/// AppData dictionary). It's only consulted for `RemoveByHash`
437/// resolution on collection components: given the prior `TlsSet<InboxId>`,
438/// we build a `hash → InboxId` index and resolve each `RemoveByHash` back
439/// to the concrete key being removed so the validator sees the inbox id
440/// the peer is targeting. If the hash doesn't match any prior key (or
441/// `old_value` is `None`), the expansion surfaces `value: None` and the
442/// subsequent CRDT apply step surfaces the real error.
443///
444/// Used on the receiver side to feed `validate_component_write` for each
445/// distinct change inside a single `AppDataUpdate` proposal.
446///
447/// The steady-state validator dispatches through `lookup_component`
448/// directly so it can also call `Component::validate_invariant`
449/// without a second binary search. This wrapper is retained for
450/// callers that don't need the invariant hook.
451pub fn expand_app_data_update_to_changes(
452    component_id: ComponentId,
453    operation: &AppDataUpdateOperation,
454    old_value: Option<&[u8]>,
455    registry: &ComponentRegistry,
456) -> Result<Vec<ExpandedComponentChange>, ComponentSourceError> {
457    if let Some(component) = lookup_component(component_id) {
458        return component
459            .expand_to_changes(operation, old_value)
460            .map_err(Into::into);
461    }
462
463    // No per-id [`Component`] impl on this client. The closed type
464    // universe (7 variants) means every shape — including `TlsSet` /
465    // `TlsMap` deltas — surfaces a proper per-element change list to the
466    // validator. Old and new clients converge on the same dict state for
467    // the same wire bytes.
468    let ty = resolved_type(component_id, registry)?;
469    expand_to_changes_for_type(component_id, ty, operation, old_value).map_err(Into::into)
470}
471
472/// Decode an incoming `AppDataUpdateOperation::Update(bytes)` payload
473/// and produce the new full bytes of the component, given the prior
474/// stored bytes (if any). `Update`-only — `Remove` carries no payload
475/// and is handled directly by the caller.
476///
477/// Immutable components are rejected with
478/// [`ComponentSourceError::ImmutableUpdate`] **only when a prior
479/// value already exists** — the bootstrap commit is the canonical
480/// first-insert path for immutable seeds, so this layer must allow
481/// an `Update` whose `old_value` is `None`. The bootstrap validator
482/// catches malicious initial values upstream via byte-compare.
483///
484/// A bounded component's payload is held to the element bound before
485/// anything else, so the result does not depend on the state it applies
486/// to; the new value is then held to the snapshot bound.
487pub fn apply_app_data_update_payload(
488    id: ComponentId,
489    payload: &[u8],
490    old_value: Option<&[u8]>,
491    registry: &ComponentRegistry,
492) -> Result<Vec<u8>, ComponentSourceError> {
493    check_update_payload_bounds(id, payload, registry)?;
494    // Immutability gate. Reject only on overwrite — a fresh insert
495    // (no prior value) is the bootstrap commit's first write of an
496    // immutable seed and must succeed for honest receivers to reach
497    // the migrated state. Steady-state immutables always have a prior
498    // (inserted at bootstrap), so a Byzantine peer trying to mutate
499    // them post-bootstrap still hits this branch and gets rejected.
500    if id.is_immutable() && old_value.is_some() {
501        return Err(ComponentSourceError::ImmutableUpdate(id));
502    }
503
504    // A per-id `Component` impl handles the well-known mutable components
505    // with a typed decoder; every other id dispatches on its resolved type.
506    let new_value = match lookup_component(id) {
507        Some(component) => component.apply_update_payload(payload, old_value)?,
508        None => {
509            apply_update_payload_for_type(id, resolved_type(id, registry)?, payload, old_value)?
510        }
511    };
512    if is_field_bounded(id) {
513        check_field_bounds(id, resolved_type(id, registry)?, &new_value)?;
514    }
515    Ok(new_value)
516}
517
518/// The type of component `id`, from two sources tried in order:
519///
520/// 1. In-code [`component_type`] mapping — covers well-known XMTP ids,
521///    including those with no typed decoder (immutable seeds like
522///    CREATOR_INBOX_ID — bootstrap-only first-write path).
523/// 2. On-dict [`ComponentRegistry`] entry — covers components a *newer*
524///    release ships that this client has never heard of; the registry's
525///    `component_type` tag is the type oracle.
526fn resolved_type(
527    id: ComponentId,
528    registry: &ComponentRegistry,
529) -> Result<ComponentType, ComponentSourceError> {
530    component_type(id).map_or_else(|| registered_component_type(id, registry), Ok)
531}
532
533/// Whether the field bounds cover component `id`: application components,
534/// `USER_DISPLAY_NAME`, `GROUP_IMAGE`, and `COMPONENT_REGISTRY`, whose
535/// application-range entries a DM participant may write.
536fn is_field_bounded(id: ComponentId) -> bool {
537    id.is_app_range()
538        || [
539            ComponentId::USER_DISPLAY_NAME,
540            ComponentId::GROUP_IMAGE,
541            ComponentId::COMPONENT_REGISTRY,
542        ]
543        .contains(&id)
544}
545
546/// Reject an `AppDataUpdate::Update` payload for component `id` that names
547/// a scalar, collection key, or collection value longer than
548/// [`MAX_FIELD_ELEMENT_BYTES`], including one that a later mutation of the
549/// same payload removes and the key of a removal. It reads only the payload,
550/// so it holds whatever state the update is applied to.
551// implements: META-068
552fn check_update_payload_bounds(
553    id: ComponentId,
554    payload: &[u8],
555    registry: &ComponentRegistry,
556) -> Result<(), ComponentSourceError> {
557    if !is_field_bounded(id) {
558        return Ok(());
559    }
560    // A removal by hash names no key bytes.
561    let longest = match resolved_type(id, registry)? {
562        ComponentType::Bytes | ComponentType::String => Some(payload.len()),
563        ComponentType::TlsSetBytes => TlsSetDelta::<VLBytes>::tls_deserialize_exact(payload)?
564            .mutations
565            .iter()
566            .map(|m| match m {
567                TlsSetMutation::Insert(key) | TlsSetMutation::Remove(key) => key.as_slice().len(),
568                TlsSetMutation::RemoveByHash(_) => 0,
569            })
570            .max(),
571        ComponentType::TlsMapInboxIdBytes | ComponentType::TlsMapInboxIdString => {
572            longest_delta_value::<InboxId>(payload)?
573        }
574        ComponentType::TlsMapBytesBytes if id == ComponentId::COMPONENT_REGISTRY => {
575            longest_delta_value::<ComponentId>(payload)?
576        }
577        ComponentType::TlsMapBytesBytes => {
578            TlsMapDelta::<VLBytes, VLBytes>::tls_deserialize_exact(payload)?
579                .mutations
580                .iter()
581                .map(|m| match m {
582                    TlsMapMutation::Insert { key, value }
583                    | TlsMapMutation::Update { key, value } => {
584                        key.as_slice().len().max(value.as_slice().len())
585                    }
586                    TlsMapMutation::Delete { key } => key.as_slice().len(),
587                })
588                .max()
589        }
590        ComponentType::TlsSetInboxId | ComponentType::Unspecified => None,
591    };
592    check_element_bound(id, longest.unwrap_or(0))
593}
594
595/// The longest value that a map delta with fixed-size `K` keys inserts or
596/// updates.
597fn longest_delta_value<K>(payload: &[u8]) -> Result<Option<usize>, tls_codec::Error>
598where
599    TlsMapDelta<K, VLBytes>: Deserialize,
600{
601    Ok(TlsMapDelta::<K, VLBytes>::tls_deserialize_exact(payload)?
602        .mutations
603        .iter()
604        .map(|m| match m {
605            TlsMapMutation::Insert { value, .. } | TlsMapMutation::Update { value, .. } => {
606                value.as_slice().len()
607            }
608            TlsMapMutation::Delete { .. } => 0,
609        })
610        .max())
611}
612
613/// Reject `value`, the new bytes of component `id` of type `ty`, when a
614/// scalar, collection key, or collection value is longer than
615/// [`MAX_FIELD_ELEMENT_BYTES`] or a collection snapshot is longer than
616/// [`MAX_FIELD_SNAPSHOT_BYTES`]. `InboxId` and `ComponentId` keys have a
617/// fixed size, so only byte keys and values are measured element by element.
618// implements: META-068
619fn check_field_bounds(
620    id: ComponentId,
621    ty: ComponentType,
622    value: &[u8],
623) -> Result<(), ComponentSourceError> {
624    let longest_element = match ty {
625        ComponentType::Bytes | ComponentType::String => Some(value.len()),
626        _ if value.len() > MAX_FIELD_SNAPSHOT_BYTES => {
627            return Err(ComponentSourceError::FieldBoundExceeded {
628                component_id: id,
629                len: value.len(),
630                max: MAX_FIELD_SNAPSHOT_BYTES,
631            });
632        }
633        ComponentType::TlsSetBytes => TlsSet::<VLBytes>::tls_deserialize_exact(value)?
634            .iter()
635            .map(|v| v.as_slice().len())
636            .max(),
637        ComponentType::TlsMapInboxIdBytes | ComponentType::TlsMapInboxIdString => {
638            longest_map_value::<InboxId>(value)?
639        }
640        ComponentType::TlsMapBytesBytes if id == ComponentId::COMPONENT_REGISTRY => {
641            longest_map_value::<ComponentId>(value)?
642        }
643        ComponentType::TlsMapBytesBytes => {
644            TlsMap::<VLBytes, VLBytes>::tls_deserialize_exact(value)?
645                .iter()
646                .flat_map(|(k, v)| [k.as_slice().len(), v.as_slice().len()])
647                .max()
648        }
649        ComponentType::TlsSetInboxId | ComponentType::Unspecified => None,
650    }
651    .unwrap_or(0);
652    check_element_bound(id, longest_element)
653}
654
655/// The longest value of a map with fixed-size `K` keys.
656fn longest_map_value<K: Ord>(value: &[u8]) -> Result<Option<usize>, tls_codec::Error>
657where
658    TlsMap<K, VLBytes>: Deserialize,
659{
660    Ok(TlsMap::<K, VLBytes>::tls_deserialize_exact(value)?
661        .values()
662        .map(|v| v.as_slice().len())
663        .max())
664}
665
666/// Reject `len`, the longest scalar, key, or value written to component
667/// `id`, when it is over [`MAX_FIELD_ELEMENT_BYTES`].
668fn check_element_bound(id: ComponentId, len: usize) -> Result<(), ComponentSourceError> {
669    if len > MAX_FIELD_ELEMENT_BYTES {
670        return Err(ComponentSourceError::FieldBoundExceeded {
671            component_id: id,
672            len,
673            max: MAX_FIELD_ELEMENT_BYTES,
674        });
675    }
676    Ok(())
677}
678
679/// Look up the [`ComponentType`] registered for a component id in the
680/// on-dict [`ComponentRegistry`]. Returns
681/// [`ComponentSourceError::UnknownComponent`] when no registry entry
682/// exists — the deny-by-default rule that keeps unrecognized payloads
683/// from being applied opaquely.
684fn registered_component_type(
685    id: ComponentId,
686    registry: &ComponentRegistry,
687) -> Result<ComponentType, ComponentSourceError> {
688    let meta = registry
689        .get(&id)
690        .map_err(|e| ComponentSourceError::MalformedComponentValue {
691            component_id: id,
692            reason: format!("registry lookup: {e}"),
693        })?
694        .ok_or(ComponentSourceError::UnknownComponent(id))?;
695    ComponentType::try_from(meta.component_type).map_err(|_| {
696        ComponentSourceError::MalformedComponentValue {
697            component_id: id,
698            reason: format!(
699                "registry entry has unknown component_type tag {}",
700                meta.component_type
701            ),
702        }
703    })
704}
705
706/// Overlay AppData-dict component values onto a base [`GroupMutableMetadata`].
707///
708/// Wire formats (must match what the sender emits via
709/// [`apply_app_data_update_payload`]):
710/// - Bytes components: raw UTF-8 string bytes.
711/// - `ADMIN_LIST` / `SUPER_ADMIN_LIST`: TLS-serialized `TlsSet<InboxId>`,
712///   each id hex-encoded back to string form.
713///
714/// ## Independence from `COMPONENT_REGISTRY` parseability
715///
716/// This function reads metadata field entries directly from the dict and
717/// **never** loads or validates the `COMPONENT_REGISTRY` payload — it
718/// does not use the `COMPONENT_REGISTRY` payload as a gate. So a malformed
719/// `COMPONENT_REGISTRY` blob does NOT cause
720/// metadata reads to drop authoritative data: as long as the individual
721/// metadata field bytes (`GROUP_NAME`, `ADMIN_LIST`, …) decode
722/// correctly, they round-trip into the returned GMM. Registry corruption
723/// is surfaced loudly on the *write* paths instead — the sender gate in
724/// `mls_sync.rs` and the commit validator in `validated_commit.rs` both
725/// call `xmtp_mls`'s `load_component_registry` and propagate decode errors
726/// — so a corrupt registry blocks state changes without making readable
727/// data unreachable. See
728/// `merge_with_malformed_registry_returns_valid_field` for the test
729/// that pins this invariant.
730pub fn merge_app_data_into_mutable_metadata(
731    base: &mut GroupMutableMetadata,
732    mls_group: &OpenMlsGroup,
733) -> Result<(), ComponentSourceError> {
734    merge_app_data_into_mutable_metadata_from_extensions(base, mls_group.extensions())
735}
736
737/// Extract [`GroupMutableMetadata`] from the AppData dictionary.
738pub fn extract_group_mutable_metadata_capability_aware(
739    mls_group: &OpenMlsGroup,
740) -> Result<GroupMutableMetadata, ComponentSourceError> {
741    let mut base =
742        GroupMutableMetadata::new(std::collections::HashMap::new(), Vec::new(), Vec::new());
743    merge_app_data_into_mutable_metadata(&mut base, mls_group)?;
744    Ok(base)
745}
746
747/// Same as [`extract_group_mutable_metadata_capability_aware`], but driven from
748/// the group's `GroupContext` extensions alone — no full `OpenMlsGroup::load`.
749/// The mutable metadata lives entirely in the context extensions, so a single
750/// `StorageProvider::group_context` read (one KV round-trip, no ratchet tree)
751/// is all this needs.
752pub fn extract_group_mutable_metadata_capability_aware_from_extensions(
753    extensions: &Extensions<GroupContext>,
754) -> Result<GroupMutableMetadata, ComponentSourceError> {
755    let mut base =
756        GroupMutableMetadata::new(std::collections::HashMap::new(), Vec::new(), Vec::new());
757    merge_app_data_into_mutable_metadata_from_extensions(&mut base, extensions)?;
758    Ok(base)
759}
760
761/// Extensions-only variant of [`merge_app_data_into_mutable_metadata`].
762pub fn merge_app_data_into_mutable_metadata_from_extensions(
763    base: &mut GroupMutableMetadata,
764    extensions: &openmls::extensions::Extensions<openmls::group::GroupContext>,
765) -> Result<(), ComponentSourceError> {
766    // The merge body lives in `xmtp_mls_common` so crates below
767    // `xmtp_mls` in the dependency graph (the archive exporter) can
768    // reuse it. Map the per-component error back to
769    // `MalformedComponentValue` so this function's error shape (which
770    // callers and tests match on, and `component_id()` extracts from)
771    // is unchanged by the move.
772    crate::group_mutable_metadata::merge_dict_into_mutable_metadata(base, extensions).map_err(|e| {
773        match e {
774            GroupMutableMetadataError::MalformedComponent {
775                component_id: Some(component_id),
776                reason,
777            } => ComponentSourceError::MalformedComponentValue {
778                component_id,
779                reason,
780            },
781            other => ComponentSourceError::GroupMutableMetadata(other),
782        }
783    })
784}
785
786// ============================================================================
787// Inbox-id encoding helpers
788// ============================================================================
789//
790// Inbox ids are SHA-256 hashes (see `xmtp_id::associations::member::inbox_id`).
791// Their canonical string form is a 64-character hex string. Anything we put
792// on the wire through the new `AppDataUpdate` path uses the
793// versioned `InboxId` newtype instead — see the module-level docs for
794// the rationale and `crate::inbox_id` for the full contract.
795
796/// Decode a hex-string inbox id into an [`InboxId`].
797///
798/// Returns [`ComponentSourceError::InvalidInboxId`] wrapping either
799/// [`InboxIdError::InvalidHex`] (input wasn't hex) or
800/// [`InboxIdError::InvalidLength`] (wrong byte length after decoding).
801/// Callers that need to distinguish the failure modes can match the
802/// inner variant.
803pub fn inbox_id_str_to_bytes(inbox_id: &str) -> Result<InboxId, ComponentSourceError> {
804    InboxId::from_hex(inbox_id).map_err(Into::into)
805}
806
807/// Read the super-admin list from the AppData dictionary.
808pub fn read_super_admin_list_from_dict(
809    mls_group: &OpenMlsGroup,
810) -> Result<Option<Vec<String>>, ComponentSourceError> {
811    read_super_admin_list_from_extensions(mls_group.extensions())
812}
813
814/// Extensions-only variant of [`read_super_admin_list_from_dict`]. Use
815/// the shim above when an `OpenMlsGroup` is at hand; this form is
816/// available primarily for unit testing and for commit-validation
817/// paths that only carry an `Extensions` reference.
818pub fn read_super_admin_list_from_extensions(
819    extensions: &Extensions<GroupContext>,
820) -> Result<Option<Vec<String>>, ComponentSourceError> {
821    let Some(ext) = extensions.app_data_dictionary() else {
822        return Ok(None);
823    };
824    let Some(bytes) = ext
825        .dictionary()
826        .get(&ComponentId::SUPER_ADMIN_LIST.as_u16())
827    else {
828        return Ok(None);
829    };
830    let set = TlsSet::<InboxId>::tls_deserialize_exact(bytes).map_err(|e| {
831        ComponentSourceError::MalformedComponentValue {
832            component_id: ComponentId::SUPER_ADMIN_LIST,
833            reason: format!("invalid TlsSet<InboxId>: {e}"),
834        }
835    })?;
836    Ok(Some(set.iter().map(|id| id.to_hex()).collect()))
837}
838
839/// Synthesize a [`GroupMetadata`] from the AppData dictionary.
840/// Returns `Ok(None)` if the critical immutable seeds are absent.
841///
842/// Encoding mirrors the sender-side synthesis in
843/// [`crate::app_data::migration::synthesize_canonical_subset_for_validation`]:
844/// - `CONVERSATION_TYPE`: 4 big-endian bytes of `ConversationType as i32`
845///   (see `encode_conversation_type` there).
846/// - `CREATOR_INBOX_ID`: the versioned `InboxId` TLS wire form
847///   (`varint(version) || 32-byte payload`) — the same shape every
848///   other inbox-id-bearing component on the new path uses. Reader
849///   hex-encodes the decoded id back into the legacy
850///   `GroupMetadata::creator_inbox_id: String` slot.
851/// - `DM_MEMBERS`: `TlsSet<InboxId>` with exactly two elements —
852///   matches the declared `ComponentType::TlsSetInboxId` and the
853///   sender's `encode_dm_members`. The writer rejects self-DMs
854///   (identical slots) up front; readers that see a 1-element set
855///   surface `MalformedComponentValue`.
856/// - `ONESHOT_MESSAGE`: prost-encoded `OneshotMessage`.
857pub fn read_group_metadata_from_dict(
858    mls_group: &OpenMlsGroup,
859) -> Result<Option<GroupMetadataReturn>, ComponentSourceError> {
860    read_group_metadata_from_extensions(mls_group.extensions())
861}
862
863/// Extensions-only variant of [`read_group_metadata_from_dict`]. Same
864/// rationale for the split as [`read_super_admin_list_from_extensions`].
865pub fn read_group_metadata_from_extensions(
866    extensions: &Extensions<GroupContext>,
867) -> Result<Option<GroupMetadataReturn>, ComponentSourceError> {
868    use prost::Message;
869    use xmtp_proto::xmtp::mls::message_contents::{
870        ConversationType, DmMembers as DmMembersProto, Inbox as InboxProto, OneshotMessage,
871    };
872
873    let Some(ext) = extensions.app_data_dictionary() else {
874        return Ok(None);
875    };
876    let dict = ext.dictionary();
877
878    let Some(ct_bytes) = dict.get(&ComponentId::CONVERSATION_TYPE.as_u16()) else {
879        return Ok(None);
880    };
881    let Some(creator_bytes) = dict.get(&ComponentId::CREATOR_INBOX_ID.as_u16()) else {
882        return Ok(None);
883    };
884
885    let ct_arr: [u8; 4] =
886        ct_bytes
887            .try_into()
888            .map_err(|_| ComponentSourceError::MalformedComponentValue {
889                component_id: ComponentId::CONVERSATION_TYPE,
890                reason: format!("expected 4 bytes, got {}", ct_bytes.len()),
891            })?;
892    let conversation_type = i32::from_be_bytes(ct_arr);
893
894    let creator_inbox_id = InboxId::tls_deserialize_exact(creator_bytes)
895        .map_err(|e| ComponentSourceError::MalformedComponentValue {
896            component_id: ComponentId::CREATOR_INBOX_ID,
897            reason: format!("invalid InboxId TLS encoding: {e}"),
898        })?
899        .to_hex();
900
901    // `DM_MEMBERS` on the wire is `TlsSet<InboxId>`; re-shape to
902    // `DmMembersProto` so downstream `GroupMetadata::try_from` is unchanged.
903    // The pair grants DM-only authority, so it is malformed outside a DM.
904    // implements: META-030
905    let dm_members = match dict.get(&ComponentId::DM_MEMBERS.as_u16()) {
906        Some(_) if conversation_type != ConversationType::Dm as i32 => {
907            return Err(ComponentSourceError::MalformedComponentValue {
908                component_id: ComponentId::DM_MEMBERS,
909                reason: format!("present in conversation type {conversation_type}"),
910            });
911        }
912        Some(b) => {
913            let set = TlsSet::<InboxId>::tls_deserialize_exact(b).map_err(|e| {
914                ComponentSourceError::MalformedComponentValue {
915                    component_id: ComponentId::DM_MEMBERS,
916                    reason: format!("invalid TlsSet<InboxId>: {e}"),
917                }
918            })?;
919            let ids: Vec<InboxId> = set.iter().copied().collect();
920            if ids.len() != 2 {
921                return Err(ComponentSourceError::MalformedComponentValue {
922                    component_id: ComponentId::DM_MEMBERS,
923                    reason: format!("expected 2 inbox ids, got {}", ids.len()),
924                });
925            }
926            Some(DmMembersProto {
927                dm_member_one: Some(InboxProto {
928                    inbox_id: ids[0].to_hex(),
929                }),
930                dm_member_two: Some(InboxProto {
931                    inbox_id: ids[1].to_hex(),
932                }),
933            })
934        }
935        None => None,
936    };
937
938    let oneshot = match dict.get(&ComponentId::ONESHOT_MESSAGE.as_u16()) {
939        Some(b) => Some(OneshotMessage::decode(b).map_err(|e| {
940            ComponentSourceError::MalformedComponentValue {
941                component_id: ComponentId::ONESHOT_MESSAGE,
942                reason: format!("OneshotMessage prost decode: {e}"),
943            }
944        })?),
945        None => None,
946    };
947
948    Ok(Some(GroupMetadataReturn {
949        conversation_type,
950        creator_inbox_id,
951        dm_members,
952        oneshot,
953    }))
954}
955
956/// Intermediate proto-shaped result of [`read_group_metadata_from_extensions`].
957/// Caller converts to the final [`crate::group_metadata::GroupMetadata`].
958#[derive(Debug)]
959pub struct GroupMetadataReturn {
960    pub conversation_type: i32,
961    pub creator_inbox_id: String,
962    pub dm_members: Option<xmtp_proto::xmtp::mls::message_contents::DmMembers>,
963    pub oneshot: Option<xmtp_proto::xmtp::mls::message_contents::OneshotMessage>,
964}
965
966/// Read the `GROUP_MEMBERSHIP` dict entry and decode it into the
967/// legacy `GroupMembership` proto shape. Used by `extract_group_membership` on the
968/// receive-side validator to bridge the dict-stored membership back
969/// into the existing `GroupMembership` Rust type without rewriting
970/// every caller.
971pub fn read_group_membership_from_dict(
972    extensions: &Extensions<GroupContext>,
973) -> Result<Option<xmtp_proto::xmtp::mls::message_contents::GroupMembership>, ComponentSourceError>
974{
975    use crate::app_data::migration::decode_group_membership_dict;
976    use xmtp_proto::xmtp::mls::message_contents::GroupMembership as GroupMembershipProto;
977
978    let Some(ext) = extensions.app_data_dictionary() else {
979        return Ok(None);
980    };
981    let Some(bytes) = ext
982        .dictionary()
983        .get(&ComponentId::GROUP_MEMBERSHIP.as_u16())
984    else {
985        return Ok(None);
986    };
987
988    let entries = decode_group_membership_dict(bytes).map_err(|e| {
989        ComponentSourceError::MalformedComponentValue {
990            component_id: ComponentId::GROUP_MEMBERSHIP,
991            reason: format!("TlsMap decode: {e}"),
992        }
993    })?;
994
995    // Flatten per-inbox GroupMembershipEntryV1 back into the legacy
996    // proto shape: members (inbox_id → sequence_id), failed_installations
997    // (flat Vec). The proto still has a flat failed_installations field
998    // for backward compat — we concatenate per-inbox failed lists for
999    // callers that still read the flat list.
1000    //
1001    // `decode_group_membership_dict` already rejects entries with
1002    // `version: None` (`MigrationError::GroupMembershipEntryUnknownVersion`),
1003    // so the only legal post-decode shape today is `Some(Version::V1(_))`.
1004    // Anything else (a future Version variant we can't interpret) is a
1005    // forward-compat hazard and surfaces as `MalformedComponentValue`.
1006    use xmtp_proto::xmtp::mls::message_contents::group_membership_entry::Version as GroupMembershipEntryVersion;
1007    let mut members: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
1008    let mut failed: Vec<Vec<u8>> = Vec::new();
1009    for (inbox_id, entry) in entries {
1010        let v1 = match entry.version {
1011            Some(GroupMembershipEntryVersion::V1(v1)) => v1,
1012            None => {
1013                return Err(ComponentSourceError::MalformedComponentValue {
1014                    component_id: ComponentId::GROUP_MEMBERSHIP,
1015                    reason: format!(
1016                        "GroupMembershipEntry for {} has no version",
1017                        inbox_id.to_hex()
1018                    ),
1019                });
1020            }
1021        };
1022        members.insert(inbox_id.to_hex(), v1.sequence_id);
1023        failed.extend(v1.failed_installations);
1024    }
1025
1026    Ok(Some(GroupMembershipProto {
1027        members,
1028        failed_installations: failed,
1029    }))
1030}
1031
1032/// Encode a list of hex inbox ids as a TLS-serialized `TlsSet<InboxId>`.
1033// Only tests call it. `expect` (not `allow`) so the compiler flags this
1034// once a non-test caller in this module uses it.
1035#[cfg_attr(not(test), expect(dead_code))]
1036fn encode_inbox_id_set(inbox_ids: &[String]) -> Result<Vec<u8>, ComponentSourceError> {
1037    let ids: Vec<InboxId> = inbox_ids
1038        .iter()
1039        .map(|s| inbox_id_str_to_bytes(s))
1040        .collect::<Result<Vec<_>, _>>()?;
1041    let set: TlsSet<InboxId> = ids.into_iter().collect();
1042    Ok(set.tls_serialize_detached()?)
1043}
1044
1045#[cfg(test)]
1046mod tests {
1047    use super::*;
1048    use crate::{
1049        app_data::{
1050            component_permissions::component_permissions,
1051            component_registry::{ComponentOp, new_component_metadata},
1052        },
1053        inbox_id::INBOX_ID_BYTE_LEN,
1054        tls_set::TlsKeyHash,
1055    };
1056    use prost::Message;
1057    use xmtp_proto::xmtp::mls::message_contents::{
1058        MetadataPolicy as MetadataPolicyProto,
1059        metadata_policy::{Kind as MetadataPolicyKind, MetadataBasePolicy},
1060    };
1061
1062    /// Build a deterministic 64-character hex inbox id from a tag byte. The
1063    /// tag is repeated 32 times, giving a unique inbox id per call without
1064    /// needing real cryptographic generation.
1065    fn fake_inbox_id(tag: u8) -> String {
1066        hex::encode([tag; INBOX_ID_BYTE_LEN])
1067    }
1068
1069    /// Build the [`InboxId`] form of [`fake_inbox_id`] directly.
1070    fn fake_inbox(tag: u8) -> InboxId {
1071        InboxId::from_bytes([tag; INBOX_ID_BYTE_LEN])
1072    }
1073
1074    /// Empty registry constant for tests that exercise known-id paths —
1075    /// `lookup_component` resolves first, so the registry is never
1076    /// consulted and an empty one is sufficient.
1077    fn empty_registry() -> ComponentRegistry {
1078        ComponentRegistry::new()
1079    }
1080
1081    /// A one-mutation `TlsSetDelta<InboxId>` payload.
1082    fn inbox_set_delta(mutation: TlsSetMutation<InboxId>) -> Vec<u8> {
1083        TlsSetDelta {
1084            mutations: vec![mutation],
1085        }
1086        .tls_serialize_detached()
1087        .unwrap()
1088    }
1089
1090    /// Build a single-entry registry for tests that exercise the
1091    /// type-aware fallback on unknown ids. Permissions are `Allow` for
1092    /// every op so the policy layer does not interfere with the
1093    /// dispatch test under question.
1094    fn registry_with(id: ComponentId, ty: ComponentType) -> ComponentRegistry {
1095        fn allow() -> MetadataPolicyProto {
1096            MetadataPolicyProto {
1097                kind: Some(MetadataPolicyKind::Base(MetadataBasePolicy::Allow as i32)),
1098            }
1099        }
1100        let perms = component_permissions()
1101            .insert(allow())
1102            .update(allow())
1103            .delete(allow())
1104            .call();
1105        let meta = new_component_metadata(perms, ty);
1106        let mut reg = ComponentRegistry::new();
1107        reg.set(id, meta).unwrap();
1108        reg
1109    }
1110
1111    // --- inbox-id helpers --------------------------------------------------
1112
1113    #[xmtp_common::test]
1114    fn test_inbox_id_round_trip() {
1115        let original = fake_inbox_id(0xAB);
1116        let id = inbox_id_str_to_bytes(&original).unwrap();
1117        assert_eq!(id.as_bytes(), &[0xAB; 32]);
1118        assert_eq!(id.to_hex(), original);
1119    }
1120
1121    #[xmtp_common::test]
1122    fn test_inbox_id_invalid_hex() {
1123        let err = inbox_id_str_to_bytes("not_hex").unwrap_err();
1124        assert!(
1125            matches!(
1126                err,
1127                ComponentSourceError::InvalidInboxId(InboxIdError::InvalidHex(_))
1128            ),
1129            "got {err:?}"
1130        );
1131    }
1132
1133    #[xmtp_common::test]
1134    fn test_inbox_id_wrong_length() {
1135        // Valid hex but too short — only 16 bytes.
1136        let err = inbox_id_str_to_bytes(&"ab".repeat(16)).unwrap_err();
1137        assert!(
1138            matches!(
1139                err,
1140                ComponentSourceError::InvalidInboxId(InboxIdError::InvalidLength {
1141                    expected: INBOX_ID_BYTE_LEN,
1142                    actual: 16,
1143                })
1144            ),
1145            "got {err:?}"
1146        );
1147    }
1148
1149    // --- component_type lookups --------------------------------------------
1150
1151    #[xmtp_common::test]
1152    fn test_component_type_string_family() {
1153        // GMM-backed components whose wire format is UTF-8 text — names,
1154        // descriptions, URLs, and the app-data string blob.
1155        for id in [
1156            ComponentId::GROUP_NAME,
1157            ComponentId::GROUP_DESCRIPTION,
1158            ComponentId::GROUP_IMAGE_URL,
1159            ComponentId::APP_DATA,
1160            ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION,
1161        ] {
1162            assert_eq!(component_type(id), Some(ComponentType::String));
1163        }
1164    }
1165
1166    #[xmtp_common::test]
1167    fn test_component_type_bytes_family() {
1168        // Components whose payload is opaque bytes, not UTF-8 text:
1169        // timestamped disappearance windows and the commit-log signer key.
1170        for id in [
1171            ComponentId::MESSAGE_DISAPPEAR_FROM_NS,
1172            ComponentId::MESSAGE_DISAPPEAR_IN_NS,
1173            ComponentId::COMMIT_LOG_SIGNER,
1174        ] {
1175            assert_eq!(component_type(id), Some(ComponentType::Bytes));
1176        }
1177    }
1178
1179    #[xmtp_common::test]
1180    fn test_component_type_set_inbox_id_family() {
1181        for id in [
1182            ComponentId::ADMIN_LIST,
1183            ComponentId::SUPER_ADMIN_LIST,
1184            ComponentId::DM_MEMBERS,
1185        ] {
1186            assert_eq!(component_type(id), Some(ComponentType::TlsSetInboxId));
1187        }
1188    }
1189
1190    #[xmtp_common::test]
1191    fn test_component_type_group_membership() {
1192        assert_eq!(
1193            component_type(ComponentId::GROUP_MEMBERSHIP),
1194            Some(ComponentType::TlsMapInboxIdBytes)
1195        );
1196    }
1197
1198    /// The two newest well-known ids carry the fixed types every client
1199    /// must agree on: a UTF-8 inbox map for display names, opaque bytes for
1200    /// the group image.
1201    // verifies: META-010
1202    #[xmtp_common::test]
1203    fn test_component_type_profile_and_image() {
1204        assert_eq!(
1205            component_type(ComponentId::USER_DISPLAY_NAME),
1206            Some(ComponentType::TlsMapInboxIdString)
1207        );
1208        assert_eq!(
1209            component_type(ComponentId::GROUP_IMAGE),
1210            Some(ComponentType::Bytes)
1211        );
1212    }
1213
1214    #[xmtp_common::test]
1215    fn test_component_type_app_range_is_none() {
1216        assert_eq!(component_type(ComponentId::new(0xC000)), None);
1217        assert_eq!(component_type(ComponentId::new(0xFDAB)), None);
1218    }
1219
1220    // --- MetadataField <-> ComponentId mapping -----------------------------
1221
1222    #[xmtp_common::test]
1223    fn test_metadata_field_round_trip() {
1224        for field in [
1225            MetadataField::GroupName,
1226            MetadataField::Description,
1227            MetadataField::GroupImageUrlSquare,
1228            MetadataField::MessageDisappearFromNS,
1229            MetadataField::MessageDisappearInNS,
1230            MetadataField::MinimumSupportedProtocolVersion,
1231            MetadataField::CommitLogSigner,
1232            MetadataField::AppData,
1233        ] {
1234            let id = metadata_field_to_component_id(field.as_str())
1235                .expect("every MetadataField has a ComponentId");
1236            let back = component_id_to_metadata_field(id)
1237                .expect("every mapped ComponentId has a MetadataField");
1238            assert_eq!(back, field, "round-trip mismatch for {field:?}");
1239        }
1240    }
1241
1242    #[xmtp_common::test]
1243    fn test_metadata_field_unknown_name_returns_none() {
1244        assert!(metadata_field_to_component_id("nonexistent_field").is_none());
1245    }
1246
1247    #[xmtp_common::test]
1248    fn test_component_id_to_metadata_field_non_gmm_returns_none() {
1249        assert!(component_id_to_metadata_field(ComponentId::ADMIN_LIST).is_none());
1250        assert!(component_id_to_metadata_field(ComponentId::GROUP_MEMBERSHIP).is_none());
1251        assert!(component_id_to_metadata_field(ComponentId::new(0xC000)).is_none());
1252    }
1253
1254    // --- apply_app_data_update_payload -------------------------------------
1255
1256    #[xmtp_common::test]
1257    fn test_apply_bytes_payload_returns_payload_verbatim() {
1258        let payload = b"new_name";
1259        let new_value = apply_app_data_update_payload(
1260            ComponentId::GROUP_NAME,
1261            payload,
1262            None,
1263            &empty_registry(),
1264        )
1265        .unwrap();
1266        assert_eq!(new_value, payload);
1267    }
1268
1269    #[xmtp_common::test]
1270    fn test_apply_bytes_payload_ignores_old_value() {
1271        // Full replacement — the old value is irrelevant.
1272        let new_value = apply_app_data_update_payload(
1273            ComponentId::GROUP_DESCRIPTION,
1274            b"replacement",
1275            Some(b"old_description"),
1276            &empty_registry(),
1277        )
1278        .unwrap();
1279        assert_eq!(new_value, b"replacement");
1280    }
1281
1282    #[xmtp_common::test]
1283    fn test_apply_admin_list_insert_against_none() {
1284        // Apply an insert delta against a group that has no prior admin list.
1285        // The synthesized full value should be a TlsSet with the one inbox id.
1286        let inbox = fake_inbox_id(0x44);
1287        let insert_payload =
1288            inbox_set_delta(TlsSetMutation::Insert(InboxId::from_hex(&inbox).unwrap()));
1289
1290        let new_bytes = apply_app_data_update_payload(
1291            ComponentId::ADMIN_LIST,
1292            &insert_payload,
1293            None,
1294            &empty_registry(),
1295        )
1296        .unwrap();
1297
1298        let set = TlsSet::<InboxId>::tls_deserialize_exact(&new_bytes).unwrap();
1299        assert_eq!(set.len(), 1);
1300        assert!(set.contains(&fake_inbox(0x44)));
1301    }
1302
1303    #[xmtp_common::test]
1304    fn test_apply_admin_list_insert_against_existing_set() {
1305        // Build a prior set with one entry, then apply a delta that inserts a
1306        // second entry. Confirm both entries are present in the new value.
1307        let alice = fake_inbox_id(0x01);
1308        let bob = fake_inbox_id(0x02);
1309
1310        let prior = encode_inbox_id_set(std::slice::from_ref(&alice)).unwrap();
1311        let insert_payload =
1312            inbox_set_delta(TlsSetMutation::Insert(InboxId::from_hex(&bob).unwrap()));
1313
1314        let new_bytes = apply_app_data_update_payload(
1315            ComponentId::ADMIN_LIST,
1316            &insert_payload,
1317            Some(&prior),
1318            &empty_registry(),
1319        )
1320        .unwrap();
1321
1322        let set = TlsSet::<InboxId>::tls_deserialize_exact(&new_bytes).unwrap();
1323        assert_eq!(set.len(), 2);
1324        assert!(set.contains(&fake_inbox(0x01)));
1325        assert!(set.contains(&fake_inbox(0x02)));
1326    }
1327
1328    #[xmtp_common::test]
1329    fn test_apply_admin_list_remove_against_existing_set() {
1330        let alice = fake_inbox_id(0x01);
1331        let bob = fake_inbox_id(0x02);
1332
1333        let prior = encode_inbox_id_set(&[alice.clone(), bob.clone()]).unwrap();
1334        let remove_payload =
1335            inbox_set_delta(TlsSetMutation::Remove(InboxId::from_hex(&alice).unwrap()));
1336
1337        let new_bytes = apply_app_data_update_payload(
1338            ComponentId::ADMIN_LIST,
1339            &remove_payload,
1340            Some(&prior),
1341            &empty_registry(),
1342        )
1343        .unwrap();
1344
1345        let set = TlsSet::<InboxId>::tls_deserialize_exact(&new_bytes).unwrap();
1346        assert_eq!(set.len(), 1);
1347        assert!(set.contains(&fake_inbox(0x02)));
1348        assert!(!set.contains(&fake_inbox(0x01)));
1349    }
1350
1351    #[xmtp_common::test]
1352    fn test_apply_super_admin_list_delta() {
1353        let owner = fake_inbox_id(0xAA);
1354        let new_sa = fake_inbox_id(0xBB);
1355
1356        let prior = encode_inbox_id_set(std::slice::from_ref(&owner)).unwrap();
1357        let add_payload =
1358            inbox_set_delta(TlsSetMutation::Insert(InboxId::from_hex(&new_sa).unwrap()));
1359
1360        let new_bytes = apply_app_data_update_payload(
1361            ComponentId::SUPER_ADMIN_LIST,
1362            &add_payload,
1363            Some(&prior),
1364            &empty_registry(),
1365        )
1366        .unwrap();
1367
1368        let set = TlsSet::<InboxId>::tls_deserialize_exact(&new_bytes).unwrap();
1369        assert_eq!(set.len(), 2);
1370        assert!(set.contains(&fake_inbox(0xAA)));
1371        assert!(set.contains(&fake_inbox(0xBB)));
1372    }
1373
1374    #[xmtp_common::test]
1375    fn test_apply_admin_list_malformed_payload_returns_tls_codec_error() {
1376        // Garbage bytes that aren't a valid TlsSetDelta — exercises the
1377        // wire-format decode failure path that ProcessMessageWithAppDataError
1378        // ::AppDataDecode wraps in production. The receiver-side
1379        // `process_message_with_app_data` propagates this through the new
1380        // GroupMessageProcessingError::OpenMlsProcessMessageWithAppData
1381        // variant rather than masking it as `FoundAppDataUpdateProposal`.
1382        let err = apply_app_data_update_payload(
1383            ComponentId::ADMIN_LIST,
1384            &[0xff, 0xff, 0xff, 0xff],
1385            None,
1386            &empty_registry(),
1387        )
1388        .unwrap_err();
1389        assert!(
1390            matches!(err, ComponentSourceError::TlsCodec(_)),
1391            "got {err:?}"
1392        );
1393    }
1394
1395    #[xmtp_common::test]
1396    fn test_apply_admin_list_malformed_old_value_returns_tls_codec_error() {
1397        // The delta payload is well-formed, but the old_value isn't a
1398        // valid TlsSet — also a TlsCodec error, just from the other side.
1399        let inbox = fake_inbox_id(0x55);
1400        let payload = inbox_set_delta(TlsSetMutation::Insert(InboxId::from_hex(&inbox).unwrap()));
1401        let err = apply_app_data_update_payload(
1402            ComponentId::ADMIN_LIST,
1403            &payload,
1404            Some(&[0xde, 0xad, 0xbe, 0xef]),
1405            &empty_registry(),
1406        )
1407        .unwrap_err();
1408        assert!(
1409            matches!(err, ComponentSourceError::TlsCodec(_)),
1410            "got {err:?}"
1411        );
1412    }
1413
1414    #[xmtp_common::test]
1415    fn test_apply_immutable_first_insert_allowed() {
1416        // Bootstrap-shape: an `Update(payload)` against an immutable
1417        // component with no prior value is the bootstrap commit's
1418        // first-write path. Apply must store the payload bytes
1419        // verbatim — the bootstrap validator's byte-compare catches a
1420        // peer that crafts a malicious initial value, so the apply
1421        // layer doesn't need its own decode/check step.
1422        let bytes = apply_app_data_update_payload(
1423            ComponentId::CONVERSATION_TYPE,
1424            b"seed",
1425            None,
1426            &empty_registry(),
1427        )
1428        .unwrap();
1429        assert_eq!(bytes, b"seed");
1430    }
1431
1432    #[xmtp_common::test]
1433    fn test_apply_immutable_overwrite_rejected() {
1434        // Steady-state: an `Update(payload)` against an immutable
1435        // component that already has a prior value must fail. This is
1436        // the only path Byzantine peers have to mutate immutables
1437        // post-bootstrap, and the apply layer is the gatekeeper.
1438        let err = apply_app_data_update_payload(
1439            ComponentId::CONVERSATION_TYPE,
1440            b"junk",
1441            Some(b"prior"),
1442            &empty_registry(),
1443        )
1444        .unwrap_err();
1445        assert!(matches!(err, ComponentSourceError::ImmutableUpdate(_)));
1446    }
1447
1448    #[xmtp_common::test]
1449    fn test_apply_component_registry_delta_against_empty() {
1450        // Bootstrap shape: a `TlsMapDelta<ComponentId, VLBytes>` of
1451        // all-`Insert` mutations applied against an empty map produces
1452        // a materialized snapshot containing those entries. Values must
1453        // be structurally valid `ComponentMetadata` — registry deltas
1454        // are entry-validated at apply.
1455        let id_a = ComponentId::GROUP_NAME;
1456        let id_b = ComponentId::GROUP_DESCRIPTION;
1457        let meta_a = registry_with(id_a, ComponentType::String)
1458            .get(&id_a)
1459            .unwrap()
1460            .unwrap()
1461            .encode_to_vec();
1462        let meta_b = registry_with(id_b, ComponentType::Bytes)
1463            .get(&id_b)
1464            .unwrap()
1465            .unwrap()
1466            .encode_to_vec();
1467        let delta = TlsMapDelta::<ComponentId, VLBytes>::new()
1468            .insert(id_a, VLBytes::new(meta_a.clone()))
1469            .insert(id_b, VLBytes::new(meta_b.clone()));
1470        let payload = delta.tls_serialize_detached().unwrap();
1471
1472        let new_bytes = apply_app_data_update_payload(
1473            ComponentId::COMPONENT_REGISTRY,
1474            &payload,
1475            None,
1476            &empty_registry(),
1477        )
1478        .unwrap();
1479        let map = TlsMap::<ComponentId, VLBytes>::tls_deserialize_exact(&new_bytes).unwrap();
1480        assert_eq!(map.len(), 2);
1481        assert_eq!(
1482            map.get(&id_a).map(|v| v.as_slice()),
1483            Some(meta_a.as_slice())
1484        );
1485        assert_eq!(
1486            map.get(&id_b).map(|v| v.as_slice()),
1487            Some(meta_b.as_slice())
1488        );
1489    }
1490
1491    #[xmtp_common::test]
1492    fn test_apply_group_membership_delta_against_existing_map() {
1493        // Post-bootstrap shape: an `Update` mutation applied on top of
1494        // a prior `TlsMap<InboxId, VLBytes>` snapshot produces a new
1495        // snapshot with the updated value.
1496        let alice = fake_inbox(0xAA);
1497        let bob = fake_inbox(0xBB);
1498        let mut prior: TlsMap<InboxId, VLBytes> = TlsMap::new();
1499        prior.set(alice, VLBytes::new(vec![0x01]));
1500        prior.set(bob, VLBytes::new(vec![0x02]));
1501        let prior_bytes = prior.tls_serialize_detached().unwrap();
1502
1503        let delta = TlsMapDelta::<InboxId, VLBytes>::new()
1504            .update(alice, VLBytes::new(vec![0x99]))
1505            .delete(bob);
1506        let payload = delta.tls_serialize_detached().unwrap();
1507
1508        let new_bytes = apply_app_data_update_payload(
1509            ComponentId::GROUP_MEMBERSHIP,
1510            &payload,
1511            Some(&prior_bytes),
1512            &empty_registry(),
1513        )
1514        .unwrap();
1515        let map = TlsMap::<InboxId, VLBytes>::tls_deserialize_exact(&new_bytes).unwrap();
1516        assert_eq!(map.len(), 1);
1517        assert_eq!(
1518            map.get(&alice).map(|v| v.as_slice()),
1519            Some([0x99].as_slice())
1520        );
1521        assert!(!map.contains_key(&bob));
1522    }
1523
1524    #[xmtp_common::test]
1525    fn test_apply_map_component_malformed_delta_returns_codec_error() {
1526        // Garbage bytes that aren't a valid TlsMapDelta surface as a
1527        // TLS-codec error, same shape as the set-component path.
1528        let err = apply_app_data_update_payload(
1529            ComponentId::COMPONENT_REGISTRY,
1530            &[0xff, 0xff, 0xff, 0xff],
1531            None,
1532            &empty_registry(),
1533        )
1534        .unwrap_err();
1535        assert!(
1536            matches!(err, ComponentSourceError::TlsCodec(_)),
1537            "got {err:?}"
1538        );
1539    }
1540
1541    #[xmtp_common::test]
1542    fn test_apply_map_component_apply_failure_surfaces_apply_error() {
1543        // A delta that updates a key not present in the prior snapshot
1544        // fails at apply time — surfaced as `TlsMapApply(KeyNotFound)`.
1545        let alice = fake_inbox(0x01);
1546        let delta = TlsMapDelta::<InboxId, VLBytes>::new().update(alice, VLBytes::new(vec![0x42]));
1547        let payload = delta.tls_serialize_detached().unwrap();
1548        let err = apply_app_data_update_payload(
1549            ComponentId::GROUP_MEMBERSHIP,
1550            &payload,
1551            None,
1552            &empty_registry(),
1553        )
1554        .unwrap_err();
1555        assert!(
1556            matches!(
1557                err,
1558                ComponentSourceError::TlsMapApply(TlsMapError::KeyNotFound)
1559            ),
1560            "got {err:?}"
1561        );
1562    }
1563
1564    // --- Unknown-component tolerance via type-aware dispatch ---------------
1565    //
1566    // No per-id `Component` impl exists for these ids on this client; the
1567    // sender shipped a newer release. Old clients look up the
1568    // `ComponentType` registered for the id in the on-dict
1569    // [`ComponentRegistry`] and route the payload through the
1570    // type-level decoder. The seven `ComponentType` variants cover the
1571    // wire-format universe, so any future well-known or
1572    // application-range component lands convergently — including
1573    // `TlsSet` / `TlsMap` deltas, which previously needed a per-id
1574    // impl to apply correctly.
1575
1576    #[xmtp_common::test]
1577    fn test_apply_unknown_id_with_bytes_registry_entry_stores_payload() {
1578        // Bytes shape: opaque passthrough, registry entry supplies the
1579        // type tag so the dispatch knows *not* to try a TLS-delta
1580        // decode (which would corrupt the bytes).
1581        let id = ComponentId::new(0xC123);
1582        let registry = registry_with(id, ComponentType::Bytes);
1583        let new_value = apply_app_data_update_payload(id, b"opaque", None, &registry).unwrap();
1584        assert_eq!(new_value, b"opaque");
1585    }
1586
1587    #[xmtp_common::test]
1588    fn test_apply_unknown_id_with_string_registry_entry_validates_utf8() {
1589        // String shape: payload must be valid UTF-8. Bad bytes surface
1590        // as `MalformedComponentValue` rather than silently corrupting
1591        // the dict.
1592        let id = ComponentId::new(0xC222);
1593        let registry = registry_with(id, ComponentType::String);
1594        let ok = apply_app_data_update_payload(id, b"hello", None, &registry).unwrap();
1595        assert_eq!(ok, b"hello");
1596        let err = apply_app_data_update_payload(id, &[0xC3, 0x28], None, &registry).unwrap_err();
1597        assert!(matches!(
1598            err,
1599            ComponentSourceError::MalformedComponentValue { .. }
1600        ));
1601    }
1602
1603    #[xmtp_common::test]
1604    fn test_apply_unknown_id_with_tls_set_inbox_id_applies_delta() {
1605        // The whole point of registry-typed dispatch: a new
1606        // `TlsSet<InboxId>` component lands as a typed delta apply, not
1607        // as an opaque blob replacement — old and new clients converge
1608        // on the same `TlsSet` snapshot byte-for-byte.
1609        let id = ComponentId::new(0xC333);
1610        let registry = registry_with(id, ComponentType::TlsSetInboxId);
1611        let bob = fake_inbox(0x02);
1612        let delta = TlsSetDelta::<InboxId>::new().insert(bob);
1613        let payload = delta.tls_serialize_detached().unwrap();
1614        let new_bytes = apply_app_data_update_payload(id, &payload, None, &registry).unwrap();
1615        let set = TlsSet::<InboxId>::tls_deserialize_exact(&new_bytes).unwrap();
1616        assert_eq!(set.len(), 1);
1617        assert!(set.contains(&bob));
1618    }
1619
1620    #[xmtp_common::test]
1621    fn test_apply_unknown_id_with_no_registry_entry_rejected() {
1622        // Deny-by-default: no per-id impl AND no registry entry means
1623        // we have no type to decode against. Reject rather than store
1624        // bytes whose shape we can't reason about — the alternative
1625        // would fork the dict the moment a typed client did know the
1626        // shape.
1627        let err =
1628            apply_app_data_update_payload(ComponentId::new(0xC456), b"x", None, &empty_registry())
1629                .unwrap_err();
1630        assert!(matches!(err, ComponentSourceError::UnknownComponent(_)));
1631    }
1632
1633    /// Immutability is enforced range-by-id, not per-`Component`-impl,
1634    /// so an unknown id sitting in the XMTP immutable range
1635    /// (`0xBE00-0xBFFF`) still gets the bootstrap-style "first insert
1636    /// allowed, subsequent overwrite rejected" contract — even when
1637    /// the type-aware dispatcher (not a per-id `Component` impl) is
1638    /// the path being exercised. Pins the reviewer's concern that the
1639    /// tolerance branch might bypass immutability for newly-defined
1640    /// immutable components that a NEWER release ships.
1641    #[xmtp_common::test]
1642    fn test_apply_unknown_id_in_xmtp_immutable_range_first_write_allowed() {
1643        let id = ComponentId::new(0xBE05); // XMTP immutable range
1644        assert!(id.is_immutable());
1645        let registry = registry_with(id, ComponentType::Bytes);
1646        let new_value = apply_app_data_update_payload(id, b"seed", None, &registry).unwrap();
1647        assert_eq!(new_value, b"seed");
1648    }
1649
1650    #[xmtp_common::test]
1651    fn test_apply_unknown_id_in_xmtp_immutable_range_overwrite_rejected() {
1652        let id = ComponentId::new(0xBE05);
1653        assert!(id.is_immutable());
1654        let registry = registry_with(id, ComponentType::Bytes);
1655        let err = apply_app_data_update_payload(id, b"new", Some(b"old"), &registry).unwrap_err();
1656        assert!(matches!(err, ComponentSourceError::ImmutableUpdate(_)));
1657    }
1658
1659    /// Same contract for the application immutable range
1660    /// (`0xFD00-0xFEFF`) — overwrites of an unknown immutable
1661    /// application component are rejected even though no per-id impl
1662    /// exists on this client.
1663    #[xmtp_common::test]
1664    fn test_apply_unknown_id_in_app_immutable_range_overwrite_rejected() {
1665        let id = ComponentId::new(0xFD42); // app immutable range
1666        assert!(id.is_immutable());
1667        let registry = registry_with(id, ComponentType::Bytes);
1668        let err = apply_app_data_update_payload(id, b"new", Some(b"old"), &registry).unwrap_err();
1669        assert!(matches!(err, ComponentSourceError::ImmutableUpdate(_)));
1670    }
1671
1672    #[xmtp_common::test]
1673    fn test_apply_reserved_range_component_rejected_with_or_without_registry() {
1674        // 0xFF00+ is the reserved range. `ComponentRegistry::set`
1675        // refuses to insert reserved ids, so a registry entry can't
1676        // even be constructed — the apply path falls through to
1677        // `UnknownComponent`.
1678        let err =
1679            apply_app_data_update_payload(ComponentId::new(0xFF01), b"x", None, &empty_registry())
1680                .unwrap_err();
1681        assert!(matches!(err, ComponentSourceError::UnknownComponent(_)));
1682    }
1683
1684    #[xmtp_common::test]
1685    fn test_apply_out_of_range_component_rejected() {
1686        // Ids below 0x8000 are outside the AppData address space.
1687        let err =
1688            apply_app_data_update_payload(ComponentId::new(0x0042), b"x", None, &empty_registry())
1689                .unwrap_err();
1690        assert!(matches!(err, ComponentSourceError::UnknownComponent(_)));
1691    }
1692
1693    #[xmtp_common::test]
1694    fn test_apply_unknown_xmtp_range_id_with_registry_dispatches() {
1695        // Same path applies to the XMTP-defined range, not just the
1696        // app range: any 0x8000-0xBFFF id that lacks a per-id impl
1697        // routes through the registry.
1698        let id = ComponentId::new(0x8FFF);
1699        assert!(id.is_xmtp_range());
1700        let registry = registry_with(id, ComponentType::Bytes);
1701        let new_value = apply_app_data_update_payload(id, b"opaque", None, &registry).unwrap();
1702        assert_eq!(new_value, b"opaque");
1703    }
1704
1705    // --- expand_app_data_update_to_changes ---------------------------------
1706
1707    /// Helper: wrap a `TlsSetDelta<InboxId>` payload in an
1708    /// `AppDataUpdateOperation::Update(...)`.
1709    fn update_op(delta: TlsSetDelta<InboxId>) -> AppDataUpdateOperation {
1710        AppDataUpdateOperation::Update(delta.tls_serialize_detached().unwrap().into())
1711    }
1712
1713    #[xmtp_common::test]
1714    fn test_expand_insert_surfaces_inbox_id_bytes() {
1715        let alice = fake_inbox(0x11);
1716        let delta: TlsSetDelta<InboxId> = TlsSetDelta {
1717            mutations: vec![TlsSetMutation::Insert(alice)],
1718        };
1719        let changes = expand_app_data_update_to_changes(
1720            ComponentId::ADMIN_LIST,
1721            &update_op(delta),
1722            None,
1723            &empty_registry(),
1724        )
1725        .unwrap();
1726        assert_eq!(changes.len(), 1);
1727        assert_eq!(changes[0].op, ComponentOp::Insert);
1728        assert_eq!(
1729            changes[0].value.as_deref(),
1730            Some(alice.as_bytes().as_slice())
1731        );
1732    }
1733
1734    #[xmtp_common::test]
1735    fn test_expand_remove_surfaces_inbox_id_bytes() {
1736        let alice = fake_inbox(0x22);
1737        let delta: TlsSetDelta<InboxId> = TlsSetDelta {
1738            mutations: vec![TlsSetMutation::Remove(alice)],
1739        };
1740        let changes = expand_app_data_update_to_changes(
1741            ComponentId::ADMIN_LIST,
1742            &update_op(delta),
1743            None,
1744            &empty_registry(),
1745        )
1746        .unwrap();
1747        assert_eq!(changes.len(), 1);
1748        assert_eq!(changes[0].op, ComponentOp::Delete);
1749        assert_eq!(
1750            changes[0].value.as_deref(),
1751            Some(alice.as_bytes().as_slice())
1752        );
1753    }
1754
1755    #[xmtp_common::test]
1756    fn test_expand_remove_by_hash_resolves_to_inbox_id_from_old_value() {
1757        // Prior set has alice + bob; RemoveByHash(hash(alice)) should
1758        // resolve back to alice's raw 32 bytes so the validator sees
1759        // *which* identity is being removed.
1760        let alice = fake_inbox(0xAA);
1761        let bob = fake_inbox(0xBB);
1762        let prior: TlsSet<InboxId> = [alice, bob].into_iter().collect();
1763        let old_bytes = prior.tls_serialize_detached().unwrap();
1764
1765        let hash = TlsKeyHash::of(&alice).unwrap();
1766        let delta: TlsSetDelta<InboxId> = TlsSetDelta {
1767            mutations: vec![TlsSetMutation::RemoveByHash(hash)],
1768        };
1769        let changes = expand_app_data_update_to_changes(
1770            ComponentId::ADMIN_LIST,
1771            &update_op(delta),
1772            Some(&old_bytes),
1773            &empty_registry(),
1774        )
1775        .unwrap();
1776        assert_eq!(changes.len(), 1);
1777        assert_eq!(changes[0].op, ComponentOp::Delete);
1778        assert_eq!(
1779            changes[0].value.as_deref(),
1780            Some(alice.as_bytes().as_slice())
1781        );
1782    }
1783
1784    #[xmtp_common::test]
1785    fn test_expand_remove_by_hash_miss_surfaces_none_value() {
1786        // Prior set has alice; RemoveByHash targets bob's hash (not in set).
1787        // Expansion surfaces value: None — the CRDT apply step will later
1788        // reject with KeyNotFound. Expansion's job is reshape, not auth.
1789        let alice = fake_inbox(0x01);
1790        let bob = fake_inbox(0x02);
1791        let prior: TlsSet<InboxId> = [alice].into_iter().collect();
1792        let old_bytes = prior.tls_serialize_detached().unwrap();
1793
1794        let hash = TlsKeyHash::of(&bob).unwrap();
1795        let delta: TlsSetDelta<InboxId> = TlsSetDelta {
1796            mutations: vec![TlsSetMutation::RemoveByHash(hash)],
1797        };
1798        let changes = expand_app_data_update_to_changes(
1799            ComponentId::ADMIN_LIST,
1800            &update_op(delta),
1801            Some(&old_bytes),
1802            &empty_registry(),
1803        )
1804        .unwrap();
1805        assert_eq!(changes.len(), 1);
1806        assert_eq!(changes[0].op, ComponentOp::Delete);
1807        assert!(changes[0].value.is_none());
1808    }
1809
1810    #[xmtp_common::test]
1811    fn test_expand_remove_by_hash_with_no_old_value_surfaces_none() {
1812        // No prior bytes → no set to search → every RemoveByHash misses.
1813        let hash = TlsKeyHash::of(&fake_inbox(0x33)).unwrap();
1814        let delta: TlsSetDelta<InboxId> = TlsSetDelta {
1815            mutations: vec![TlsSetMutation::RemoveByHash(hash)],
1816        };
1817        let changes = expand_app_data_update_to_changes(
1818            ComponentId::SUPER_ADMIN_LIST,
1819            &update_op(delta),
1820            None,
1821            &empty_registry(),
1822        )
1823        .unwrap();
1824        assert_eq!(changes.len(), 1);
1825        assert_eq!(changes[0].op, ComponentOp::Delete);
1826        assert!(changes[0].value.is_none());
1827    }
1828
1829    #[xmtp_common::test]
1830    fn test_expand_remove_by_hash_malformed_old_value_surfaces_codec_error() {
1831        // Our own dict bytes are corrupt — surface the decode error
1832        // loudly rather than silently degrading to value: None, since a
1833        // corrupt prior set signals a local-state bug, not a peer bug.
1834        let hash = TlsKeyHash::of(&fake_inbox(0x44)).unwrap();
1835        let delta: TlsSetDelta<InboxId> = TlsSetDelta {
1836            mutations: vec![TlsSetMutation::RemoveByHash(hash)],
1837        };
1838        let err = expand_app_data_update_to_changes(
1839            ComponentId::ADMIN_LIST,
1840            &update_op(delta),
1841            Some(&[0xde, 0xad, 0xbe, 0xef]),
1842            &empty_registry(),
1843        )
1844        .unwrap_err();
1845        assert!(
1846            matches!(err, ComponentSourceError::TlsCodec(_)),
1847            "got {err:?}"
1848        );
1849    }
1850
1851    /// XIP §2.2: an unknown component id with `Update(payload)` and a
1852    /// registered [`ComponentType::Bytes`] expands to a single Update
1853    /// change carrying the payload, so registry-policy validation runs
1854    /// against bytes a typed client would also accept opaquely.
1855    #[xmtp_common::test]
1856    fn test_expand_unknown_component_bytes_typed_emits_single_update() {
1857        let id = ComponentId::new(0x80FF);
1858        let registry = registry_with(id, ComponentType::Bytes);
1859        let changes = expand_app_data_update_to_changes(
1860            id,
1861            &AppDataUpdateOperation::Update(b"opaque".to_vec().into()),
1862            None,
1863            &registry,
1864        )
1865        .unwrap();
1866        assert_eq!(changes.len(), 1);
1867        assert_eq!(changes[0].op, ComponentOp::Update);
1868        assert_eq!(changes[0].value.as_deref(), Some(b"opaque".as_slice()));
1869    }
1870
1871    /// Unknown id with `Remove` and any registered type expands to a
1872    /// single Delete change — the wipe semantics are type-agnostic.
1873    #[xmtp_common::test]
1874    fn test_expand_unknown_component_remove_emits_single_delete() {
1875        let id = ComponentId::new(0x80FF);
1876        let registry = registry_with(id, ComponentType::Bytes);
1877        let changes =
1878            expand_app_data_update_to_changes(id, &AppDataUpdateOperation::Remove, None, &registry)
1879                .unwrap();
1880        assert_eq!(changes.len(), 1);
1881        assert_eq!(changes[0].op, ComponentOp::Delete);
1882        assert!(changes[0].value.is_none());
1883    }
1884
1885    /// Type-aware dispatch is the whole point: an unknown
1886    /// `TlsSet<InboxId>` component expands to a per-element change
1887    /// list, not a single opaque blob — so the validator policy loop
1888    /// inspects each `Insert` individually, the same as it would for a
1889    /// known set-shaped component.
1890    #[xmtp_common::test]
1891    fn test_expand_unknown_component_tls_set_inbox_id_emits_per_element_changes() {
1892        let id = ComponentId::new(0x80FE);
1893        let registry = registry_with(id, ComponentType::TlsSetInboxId);
1894        let alice = fake_inbox(0x11);
1895        let bob = fake_inbox(0x22);
1896        let delta = TlsSetDelta::<InboxId>::new().insert(alice).insert(bob);
1897        let payload = delta.tls_serialize_detached().unwrap();
1898        let changes = expand_app_data_update_to_changes(
1899            id,
1900            &AppDataUpdateOperation::Update(payload.into()),
1901            None,
1902            &registry,
1903        )
1904        .unwrap();
1905        assert_eq!(changes.len(), 2);
1906        assert!(changes.iter().all(|c| c.op == ComponentOp::Insert));
1907    }
1908
1909    /// Reserved-range ids are not tolerated. `ComponentRegistry::set`
1910    /// rejects reserved ids at construction time, so a registry entry
1911    /// can't even be built — the dispatcher falls through to the
1912    /// `UnknownComponent` rejection.
1913    #[xmtp_common::test]
1914    fn test_expand_reserved_range_id_still_rejected() {
1915        let err = expand_app_data_update_to_changes(
1916            ComponentId::new(0xFF02),
1917            &AppDataUpdateOperation::Update(b"x".to_vec().into()),
1918            None,
1919            &empty_registry(),
1920        )
1921        .unwrap_err();
1922        assert!(matches!(err, ComponentSourceError::UnknownComponent(_)));
1923    }
1924
1925    /// Deny-by-default: an unknown id with no registry entry is
1926    /// rejected. Without a type tag we have no idea how to decode the
1927    /// payload, and storing it opaquely would diverge from any future
1928    /// typed client that DID know the shape.
1929    #[xmtp_common::test]
1930    fn test_expand_unknown_component_no_registry_entry_rejected() {
1931        let err = expand_app_data_update_to_changes(
1932            ComponentId::new(0x80FF),
1933            &AppDataUpdateOperation::Update(b"opaque".to_vec().into()),
1934            None,
1935            &empty_registry(),
1936        )
1937        .unwrap_err();
1938        assert!(matches!(err, ComponentSourceError::UnknownComponent(_)));
1939    }
1940
1941    #[xmtp_common::test]
1942    fn test_expand_skips_old_value_decode_when_no_remove_by_hash() {
1943        // Delta has only Insert/Remove — we should never touch old_value,
1944        // so even a garbage old_value must not fail the expansion.
1945        let alice = fake_inbox(0x55);
1946        let delta: TlsSetDelta<InboxId> = TlsSetDelta {
1947            mutations: vec![TlsSetMutation::Insert(alice)],
1948        };
1949        let changes = expand_app_data_update_to_changes(
1950            ComponentId::ADMIN_LIST,
1951            &update_op(delta),
1952            Some(&[0xff, 0xff, 0xff]), // intentionally malformed
1953            &empty_registry(),
1954        )
1955        .unwrap();
1956        assert_eq!(changes.len(), 1);
1957        assert_eq!(changes[0].op, ComponentOp::Insert);
1958    }
1959
1960    // ========================================================================
1961    // Dict-reader helpers — happy path and malformed coverage
1962    // ========================================================================
1963    //
1964    // The three `read_*_from_dict` helpers execute before bootstrap is
1965    // wired end-to-end; the unit tests below pin the wire-format
1966    // contract so a decoder drift can't silently ship a broken read path.
1967
1968    use openmls::extensions::{
1969        AppDataDictionary, AppDataDictionaryExtension, Extension as OpenMlsExtension, Extensions,
1970    };
1971
1972    /// Build a synthetic `Extensions<GroupContext>` with an AppData dictionary.
1973    fn extensions_with_entries(
1974        entries: &[(u16, Vec<u8>)],
1975    ) -> Extensions<openmls::group::GroupContext> {
1976        let mut dict = AppDataDictionary::new();
1977        for (id, bytes) in entries {
1978            let _ = dict.insert(*id, bytes.clone());
1979        }
1980        Extensions::from_vec(vec![OpenMlsExtension::AppDataDictionary(
1981            AppDataDictionaryExtension::new(dict),
1982        )])
1983        .expect("valid group-context extension set")
1984    }
1985
1986    // --- read_super_admin_list_from_extensions ------------------------------
1987
1988    #[xmtp_common::test]
1989    fn read_super_admin_list_absent_returns_none() {
1990        // The dict has no SUPER_ADMIN_LIST entry —
1991        // `Ok(None)` rather than surfacing a malformed-value error.
1992        let exts = extensions_with_entries(&[]);
1993        assert!(
1994            read_super_admin_list_from_extensions(&exts)
1995                .unwrap()
1996                .is_none()
1997        );
1998    }
1999
2000    #[xmtp_common::test]
2001    fn read_super_admin_list_happy_path() {
2002        let ids = vec![fake_inbox_id(0xAA), fake_inbox_id(0xBB)];
2003        let bytes = encode_inbox_id_set(&ids).unwrap();
2004        let exts = extensions_with_entries(&[(ComponentId::SUPER_ADMIN_LIST.as_u16(), bytes)]);
2005        let got = read_super_admin_list_from_extensions(&exts)
2006            .unwrap()
2007            .unwrap();
2008        assert_eq!(got.len(), 2);
2009        // TlsSet sorts by value so 0xAA sorts before 0xBB.
2010        assert_eq!(got[0], fake_inbox_id(0xAA));
2011        assert_eq!(got[1], fake_inbox_id(0xBB));
2012    }
2013
2014    #[xmtp_common::test]
2015    fn read_super_admin_list_malformed_bytes_surface_error() {
2016        let exts = extensions_with_entries(&[(
2017            ComponentId::SUPER_ADMIN_LIST.as_u16(),
2018            vec![0x00, 0xDE, 0xAD],
2019        )]);
2020        let err = read_super_admin_list_from_extensions(&exts).unwrap_err();
2021        assert!(matches!(
2022            err,
2023            ComponentSourceError::MalformedComponentValue {
2024                component_id,
2025                ..
2026            } if component_id == ComponentId::SUPER_ADMIN_LIST
2027        ));
2028    }
2029
2030    // --- read_group_metadata_from_extensions --------------------------------
2031
2032    fn encode_conv_type_bytes(value: i32) -> Vec<u8> {
2033        value.to_be_bytes().to_vec()
2034    }
2035
2036    fn encode_dm_pair(tag_a: u8, tag_b: u8) -> Vec<u8> {
2037        encode_inbox_id_set(&[fake_inbox_id(tag_a), fake_inbox_id(tag_b)]).unwrap()
2038    }
2039
2040    /// Encode a single tagged inbox id in the `CREATOR_INBOX_ID` wire
2041    /// form (versioned `InboxId` TLS encoding).
2042    fn encode_creator_bytes(tag: u8) -> Vec<u8> {
2043        fake_inbox(tag).tls_serialize_detached().unwrap()
2044    }
2045
2046    #[xmtp_common::test]
2047    fn read_group_metadata_missing_required_seeds_returns_none() {
2048        // A missing CONVERSATION_TYPE seed is not malformed.
2049        let exts = extensions_with_entries(&[]);
2050        assert!(
2051            read_group_metadata_from_extensions(&exts)
2052                .unwrap()
2053                .is_none()
2054        );
2055    }
2056
2057    #[xmtp_common::test]
2058    fn read_group_metadata_happy_path_non_dm() {
2059        let exts = extensions_with_entries(&[
2060            (
2061                ComponentId::CONVERSATION_TYPE.as_u16(),
2062                encode_conv_type_bytes(1),
2063            ),
2064            (
2065                ComponentId::CREATOR_INBOX_ID.as_u16(),
2066                encode_creator_bytes(0x11),
2067            ),
2068        ]);
2069        let got = read_group_metadata_from_extensions(&exts).unwrap().unwrap();
2070        assert_eq!(got.conversation_type, 1);
2071        assert_eq!(got.creator_inbox_id, fake_inbox_id(0x11));
2072        assert!(got.dm_members.is_none());
2073        assert!(got.oneshot.is_none());
2074    }
2075
2076    #[xmtp_common::test]
2077    fn read_group_metadata_dm_happy_path() {
2078        // DM group — DM_MEMBERS decodes as TlsSet<InboxId>, re-shaped
2079        // to the proto's two-slot form.
2080        let exts = extensions_with_entries(&[
2081            (
2082                ComponentId::CONVERSATION_TYPE.as_u16(),
2083                encode_conv_type_bytes(2),
2084            ),
2085            (
2086                ComponentId::CREATOR_INBOX_ID.as_u16(),
2087                encode_creator_bytes(0x22),
2088            ),
2089            (ComponentId::DM_MEMBERS.as_u16(), encode_dm_pair(0x22, 0x33)),
2090        ]);
2091        let got = read_group_metadata_from_extensions(&exts).unwrap().unwrap();
2092        let dm = got.dm_members.unwrap();
2093        assert_eq!(dm.dm_member_one.unwrap().inbox_id, fake_inbox_id(0x22));
2094        assert_eq!(dm.dm_member_two.unwrap().inbox_id, fake_inbox_id(0x33));
2095    }
2096
2097    #[xmtp_common::test]
2098    fn read_group_metadata_dm_wrong_cardinality_errors() {
2099        // A 1-element TlsSet<InboxId> is invalid for DM_MEMBERS —
2100        // surfaces `MalformedComponentValue`.
2101        let one_element = encode_inbox_id_set(&[fake_inbox_id(0x44)]).unwrap();
2102        let exts = extensions_with_entries(&[
2103            (
2104                ComponentId::CONVERSATION_TYPE.as_u16(),
2105                encode_conv_type_bytes(2),
2106            ),
2107            (
2108                ComponentId::CREATOR_INBOX_ID.as_u16(),
2109                encode_creator_bytes(0x44),
2110            ),
2111            (ComponentId::DM_MEMBERS.as_u16(), one_element),
2112        ]);
2113        let err = read_group_metadata_from_extensions(&exts).unwrap_err();
2114        assert!(matches!(
2115            err,
2116            ComponentSourceError::MalformedComponentValue {
2117                component_id,
2118                ..
2119            } if component_id == ComponentId::DM_MEMBERS
2120        ));
2121    }
2122
2123    /// A `DM_MEMBERS` pair outside a DM is malformed. Commit authorization
2124    /// reads metadata through this parser, and the pair grants DM-only
2125    /// authority, so a group persisted by an older client with the pair
2126    /// must not reach the policy checks.
2127    // verifies: META-030
2128    #[xmtp_common::test(unwrap_try = true)]
2129    fn read_group_metadata_rejects_dm_members_outside_a_dm() {
2130        let read = |conversation_type: i32| {
2131            read_group_metadata_from_extensions(&extensions_with_entries(&[
2132                (
2133                    ComponentId::CONVERSATION_TYPE.as_u16(),
2134                    encode_conv_type_bytes(conversation_type),
2135                ),
2136                (
2137                    ComponentId::CREATOR_INBOX_ID.as_u16(),
2138                    encode_creator_bytes(0x22),
2139                ),
2140                (ComponentId::DM_MEMBERS.as_u16(), encode_dm_pair(0x22, 0x33)),
2141            ]))
2142        };
2143        read(2)?;
2144        for non_dm in [0, 1, 3, 4] {
2145            assert!(matches!(
2146                read(non_dm),
2147                Err(ComponentSourceError::MalformedComponentValue { component_id, .. })
2148                    if component_id == ComponentId::DM_MEMBERS
2149            ));
2150        }
2151    }
2152
2153    #[xmtp_common::test]
2154    fn read_group_metadata_malformed_creator_errors() {
2155        // CREATOR_INBOX_ID is the versioned `InboxId` TLS encoding —
2156        // a few stray bytes won't satisfy the varint length prefix
2157        // plus 32-byte payload, so deserialization must fail loud as
2158        // `MalformedComponentValue` rather than silently producing
2159        // a phantom inbox id.
2160        let exts = extensions_with_entries(&[
2161            (
2162                ComponentId::CONVERSATION_TYPE.as_u16(),
2163                encode_conv_type_bytes(1),
2164            ),
2165            (
2166                ComponentId::CREATOR_INBOX_ID.as_u16(),
2167                vec![0xFF, 0xFE, 0xFD],
2168            ),
2169        ]);
2170        let err = read_group_metadata_from_extensions(&exts).unwrap_err();
2171        assert!(matches!(
2172            err,
2173            ComponentSourceError::MalformedComponentValue {
2174                component_id,
2175                ..
2176            } if component_id == ComponentId::CREATOR_INBOX_ID
2177        ));
2178    }
2179
2180    // --- read_group_membership_from_dict ------------------------------------
2181
2182    #[xmtp_common::test]
2183    fn read_group_membership_happy_path_flattens_per_inbox() {
2184        use crate::app_data::migration::encode_group_membership_dict;
2185        use std::collections::BTreeMap;
2186        use xmtp_proto::xmtp::mls::message_contents::{
2187            GroupMembershipEntry,
2188            group_membership_entry::{V1 as GroupMembershipEntryV1, Version},
2189        };
2190        let mut entries: BTreeMap<InboxId, GroupMembershipEntry> = BTreeMap::new();
2191        entries.insert(
2192            InboxId::from_bytes([0x11; INBOX_ID_BYTE_LEN]),
2193            GroupMembershipEntry {
2194                version: Some(Version::V1(GroupMembershipEntryV1 {
2195                    sequence_id: 7,
2196                    failed_installations: vec![vec![0xA1; 16]],
2197                })),
2198            },
2199        );
2200        entries.insert(
2201            InboxId::from_bytes([0x22; INBOX_ID_BYTE_LEN]),
2202            GroupMembershipEntry {
2203                version: Some(Version::V1(GroupMembershipEntryV1 {
2204                    sequence_id: 42,
2205                    failed_installations: vec![vec![0xB1; 16]],
2206                })),
2207            },
2208        );
2209        let bytes = encode_group_membership_dict(&entries).unwrap();
2210        let exts = extensions_with_entries(&[(ComponentId::GROUP_MEMBERSHIP.as_u16(), bytes)]);
2211
2212        let proto = read_group_membership_from_dict(&exts).unwrap().unwrap();
2213        // `members` is flat <hex_inbox_id, seq>
2214        assert_eq!(proto.members.len(), 2);
2215        assert_eq!(proto.members.get(&fake_inbox_id(0x11)), Some(&7));
2216        assert_eq!(proto.members.get(&fake_inbox_id(0x22)), Some(&42));
2217        // `failed_installations` concatenates the per-inbox lists.
2218        assert_eq!(proto.failed_installations.len(), 2);
2219    }
2220
2221    #[xmtp_common::test]
2222    fn read_group_membership_malformed_bytes_surface_error() {
2223        let exts = extensions_with_entries(&[(
2224            ComponentId::GROUP_MEMBERSHIP.as_u16(),
2225            vec![0xDE, 0xAD, 0xBE, 0xEF],
2226        )]);
2227        let err = read_group_membership_from_dict(&exts).unwrap_err();
2228        assert!(matches!(
2229            err,
2230            ComponentSourceError::MalformedComponentValue {
2231                component_id,
2232                ..
2233            } if component_id == ComponentId::GROUP_MEMBERSHIP
2234        ));
2235    }
2236
2237    // ========================================================================
2238    // merge_app_data_into_mutable_metadata_from_extensions —
2239    //   independence from COMPONENT_REGISTRY parseability
2240    // ========================================================================
2241    //
2242    // These pin the call-graph invariant that a malformed
2243    // `COMPONENT_REGISTRY` does **not** cause `mutable_metadata()` to
2244    // drop authoritative dict-backed fields. The merge function reads
2245    // each metadata field directly from the dict; nothing on this
2246    // read path calls `load_component_registry`. Registry corruption is
2247    // surfaced loudly on the *write* paths (sender gate in `mls_sync.rs`
2248    // and the commit validator in `validated_commit.rs`), where it
2249    // belongs.
2250    //
2251    use crate::group_mutable_metadata::{GroupMutableMetadata, MetadataField};
2252
2253    fn empty_base_gmm() -> GroupMutableMetadata {
2254        GroupMutableMetadata::new(std::collections::HashMap::new(), Vec::new(), Vec::new())
2255    }
2256
2257    #[xmtp_common::test]
2258    fn merge_with_malformed_registry_returns_valid_field() {
2259        // Reviewer's alleged "data loss" scenario: COMPONENT_REGISTRY
2260        // contains malformed bytes, but a metadata field (GROUP_NAME)
2261        // is present and valid. The merge function does NOT validate
2262        // the registry — it reads GROUP_NAME directly — so the result
2263        // must contain "My Group" with no error.
2264        let exts = extensions_with_entries(&[
2265            (ComponentId::COMPONENT_REGISTRY.as_u16(), vec![0xCA; 4]),
2266            (ComponentId::GROUP_NAME.as_u16(), b"My Group".to_vec()),
2267        ]);
2268        let mut base = empty_base_gmm();
2269        merge_app_data_into_mutable_metadata_from_extensions(&mut base, &exts)
2270            .expect("merge ignores registry parseability and reads field directly");
2271        assert_eq!(
2272            base.attributes
2273                .get(MetadataField::GroupName.as_str())
2274                .map(String::as_str),
2275            Some("My Group"),
2276        );
2277    }
2278
2279    #[xmtp_common::test]
2280    fn merge_with_malformed_field_surfaces_error_not_silent_loss() {
2281        // The other half of the invariant: when a metadata field's bytes
2282        // ARE malformed, the merge fails loudly with
2283        // `MalformedComponentValue` carrying the offending component id —
2284        // it never silently swallows the value. Pairs with the test
2285        // above to disprove "all metadata may be lost" framings.
2286        let exts =
2287            extensions_with_entries(&[(ComponentId::ADMIN_LIST.as_u16(), vec![0xff, 0xff, 0xff])]);
2288        let mut base = empty_base_gmm();
2289        let err =
2290            merge_app_data_into_mutable_metadata_from_extensions(&mut base, &exts).unwrap_err();
2291        assert!(
2292            matches!(
2293                err,
2294                ComponentSourceError::MalformedComponentValue { component_id, .. }
2295                    if component_id == ComponentId::ADMIN_LIST
2296            ),
2297            "expected MalformedComponentValue for ADMIN_LIST, got: {err:?}"
2298        );
2299    }
2300
2301    // --- field bounds ------------------------------------------------------
2302
2303    fn bytes(len: usize) -> VLBytes {
2304        VLBytes::new(vec![b'a'; len])
2305    }
2306
2307    /// A scalar of an application component or `GROUP_IMAGE` may hold 8192
2308    /// bytes and no more, so one writer cannot fill the commit envelope.
2309    // verifies: META-068
2310    #[xmtp_common::test(unwrap_try = true)]
2311    fn bounded_scalar_holds_at_most_the_element_limit() {
2312        let app = ComponentId::new(0xC068);
2313        let registry = registry_with(app, ComponentType::Bytes);
2314        for id in [app, ComponentId::GROUP_IMAGE] {
2315            let at_limit = vec![0; MAX_FIELD_ELEMENT_BYTES];
2316            assert_eq!(
2317                apply_app_data_update_payload(id, &at_limit, None, &registry)?,
2318                at_limit
2319            );
2320            let over = [0; MAX_FIELD_ELEMENT_BYTES + 1];
2321            assert!(matches!(
2322                apply_app_data_update_payload(id, &over, None, &registry),
2323                Err(ComponentSourceError::FieldBoundExceeded {
2324                    component_id,
2325                    len: 8193,
2326                    max: MAX_FIELD_ELEMENT_BYTES,
2327                }) if component_id == id
2328            ));
2329        }
2330    }
2331
2332    /// Each key and value of an application byte map, and each
2333    /// `USER_DISPLAY_NAME` value, may hold 8192 bytes and no more.
2334    // verifies: META-068
2335    #[xmtp_common::test(unwrap_try = true)]
2336    fn bounded_map_elements_hold_at_most_the_element_limit() {
2337        let app = ComponentId::new(0xC069);
2338        let registry = registry_with(app, ComponentType::TlsMapBytesBytes);
2339        let app_entry = |key: usize, value: usize| {
2340            let delta = TlsMapDelta::<VLBytes, VLBytes>::new().insert(bytes(key), bytes(value));
2341            apply_app_data_update_payload(
2342                app,
2343                &delta.tls_serialize_detached().unwrap(),
2344                None,
2345                &registry,
2346            )
2347        };
2348        let name = |len: usize| {
2349            let delta = TlsMapDelta::<InboxId, VLBytes>::new().insert(fake_inbox(1), bytes(len));
2350            apply_app_data_update_payload(
2351                ComponentId::USER_DISPLAY_NAME,
2352                &delta.tls_serialize_detached().unwrap(),
2353                None,
2354                &registry,
2355            )
2356        };
2357        let over = |result| {
2358            matches!(
2359                result,
2360                Err(ComponentSourceError::FieldBoundExceeded {
2361                    len: 8193,
2362                    max: MAX_FIELD_ELEMENT_BYTES,
2363                    ..
2364                })
2365            )
2366        };
2367
2368        app_entry(MAX_FIELD_ELEMENT_BYTES, MAX_FIELD_ELEMENT_BYTES)?;
2369        assert!(over(app_entry(MAX_FIELD_ELEMENT_BYTES + 1, 1)));
2370        assert!(over(app_entry(1, MAX_FIELD_ELEMENT_BYTES + 1)));
2371        name(MAX_FIELD_ELEMENT_BYTES)?;
2372        assert!(over(name(MAX_FIELD_ELEMENT_BYTES + 1)));
2373    }
2374
2375    /// A serialized collection may hold 65536 bytes and no more, even when
2376    /// every element is within its own limit.
2377    // verifies: META-068
2378    #[xmtp_common::test(unwrap_try = true)]
2379    fn bounded_collection_holds_at_most_the_snapshot_limit() {
2380        let app = ComponentId::new(0xC06A);
2381        let registry = registry_with(app, ComponentType::TlsMapBytesBytes);
2382        // Eight 8000-byte entries plus one of `last` bytes.
2383        let write = |last: usize| {
2384            let delta = (0..8u8)
2385                .fold(TlsMapDelta::<VLBytes, VLBytes>::new(), |d, k| {
2386                    d.insert(VLBytes::new(vec![k]), bytes(8000))
2387                })
2388                .insert(VLBytes::new(vec![8]), bytes(last));
2389            apply_app_data_update_payload(
2390                app,
2391                &delta.tls_serialize_detached().unwrap(),
2392                None,
2393                &registry,
2394            )
2395        };
2396        // The value length prefix is two bytes from 64 up, so the snapshot
2397        // grows one byte per value byte from there.
2398        let last = 64 + MAX_FIELD_SNAPSHOT_BYTES - write(64)?.len();
2399
2400        assert_eq!(write(last)?.len(), MAX_FIELD_SNAPSHOT_BYTES);
2401        assert!(matches!(
2402            write(last + 1),
2403            Err(ComponentSourceError::FieldBoundExceeded {
2404                len: 65537,
2405                max: MAX_FIELD_SNAPSHOT_BYTES,
2406                ..
2407            })
2408        ));
2409    }
2410
2411    /// An element over the bound is refused even when a later mutation of
2412    /// the same payload removes it, so the final value cannot hide it.
2413    // verifies: META-068
2414    #[xmtp_common::test(unwrap_try = true)]
2415    fn apply_bounds_elements_that_the_payload_later_removes() {
2416        let set = ComponentId::new(0xC06D);
2417        let map = ComponentId::new(0xC06E);
2418        let over = || bytes(MAX_FIELD_ELEMENT_BYTES + 1);
2419        let apply = |id, payload: Vec<u8>, ty| {
2420            apply_app_data_update_payload(id, &payload, None, &registry_with(id, ty))
2421        };
2422        let exceeded = |result| {
2423            matches!(
2424                result,
2425                Err(ComponentSourceError::FieldBoundExceeded { len: 8193, .. })
2426            )
2427        };
2428
2429        let set_payload = TlsSetDelta::<VLBytes>::new()
2430            .insert(over())
2431            .remove(over())
2432            .tls_serialize_detached()?;
2433        assert!(exceeded(apply(
2434            set,
2435            set_payload,
2436            ComponentType::TlsSetBytes
2437        )));
2438        let value_payload = TlsMapDelta::<VLBytes, VLBytes>::new()
2439            .insert(bytes(1), over())
2440            .delete(bytes(1))
2441            .tls_serialize_detached()?;
2442        assert!(exceeded(apply(
2443            map,
2444            value_payload,
2445            ComponentType::TlsMapBytesBytes
2446        )));
2447        let key_payload = TlsMapDelta::<VLBytes, VLBytes>::new()
2448            .insert(over(), bytes(1))
2449            .delete(over())
2450            .tls_serialize_detached()?;
2451        assert!(exceeded(apply(
2452            map,
2453            key_payload,
2454            ComponentType::TlsMapBytesBytes
2455        )));
2456    }
2457
2458    /// A registry entry padded to `len` bytes by an unknown protobuf field,
2459    /// which the registry decodes past and stores whole.
2460    fn padded_registry_entry(len: usize) -> VLBytes {
2461        let id = ComponentId::new(0xC100);
2462        let mut entry = registry_with(id, ComponentType::Bytes)
2463            .get(&id)
2464            .unwrap()
2465            .unwrap()
2466            .encode_to_vec();
2467        // Field 15, length-delimited, with a two-byte length varint.
2468        let pad = len - entry.len() - 3;
2469        assert!((128..16384).contains(&pad));
2470        entry.extend([0x7A, pad as u8 | 0x80, (pad >> 7) as u8]);
2471        entry.resize(len, 0);
2472        VLBytes::new(entry)
2473    }
2474
2475    /// A registry entry may hold 8192 bytes and no more, unknown protobuf
2476    /// fields included: a DM participant may write application-range
2477    /// entries, and the registry keeps their bytes whole.
2478    // verifies: META-068
2479    #[xmtp_common::test(unwrap_try = true)]
2480    fn registry_entries_hold_at_most_the_element_limit() {
2481        let registry = ComponentRegistry::new();
2482        let insert = |len| {
2483            TlsMapDelta::<ComponentId, VLBytes>::new()
2484                .insert(ComponentId::new(0xC100), padded_registry_entry(len))
2485                .tls_serialize_detached()
2486                .unwrap()
2487        };
2488        let apply = |payload: &[u8]| {
2489            apply_app_data_update_payload(ComponentId::COMPONENT_REGISTRY, payload, None, &registry)
2490                .map(drop)
2491        };
2492        let check = |payload: &[u8]| {
2493            check_update_payload_bounds(ComponentId::COMPONENT_REGISTRY, payload, &registry)
2494        };
2495        let exceeded = |result| {
2496            matches!(
2497                result,
2498                Err(ComponentSourceError::FieldBoundExceeded {
2499                    len: 8193,
2500                    max: MAX_FIELD_ELEMENT_BYTES,
2501                    ..
2502                })
2503            )
2504        };
2505
2506        let at_limit = insert(MAX_FIELD_ELEMENT_BYTES);
2507        check(&at_limit)?;
2508        apply(&at_limit)?;
2509        let over = insert(MAX_FIELD_ELEMENT_BYTES + 1);
2510        assert!(exceeded(check(&over)));
2511        assert!(exceeded(apply(&over)));
2512    }
2513
2514    /// A serialized registry may hold 65536 bytes and no more, so repeated
2515    /// in-bound inserts cannot grow every member's group context without
2516    /// limit.
2517    // verifies: META-068
2518    #[xmtp_common::test(unwrap_try = true)]
2519    fn registry_holds_at_most_the_snapshot_limit() {
2520        // Eight 8000-byte entries plus one of `last` bytes.
2521        let write = |last: usize| {
2522            let delta = (0..8)
2523                .fold(TlsMapDelta::<ComponentId, VLBytes>::new(), |d, k| {
2524                    d.insert(ComponentId::new(0xC100 + k), padded_registry_entry(8000))
2525                })
2526                .insert(ComponentId::new(0xC108), padded_registry_entry(last));
2527            apply_app_data_update_payload(
2528                ComponentId::COMPONENT_REGISTRY,
2529                &delta.tls_serialize_detached().unwrap(),
2530                None,
2531                &ComponentRegistry::new(),
2532            )
2533        };
2534        // The entry's length prefixes are two bytes from 256 up, so the
2535        // snapshot grows one byte per entry byte from there.
2536        let last = 256 + MAX_FIELD_SNAPSHOT_BYTES - write(256)?.len();
2537
2538        assert_eq!(write(last)?.len(), MAX_FIELD_SNAPSHOT_BYTES);
2539        assert!(matches!(
2540            write(last + 1),
2541            Err(ComponentSourceError::FieldBoundExceeded {
2542                component_id: ComponentId::COMPONENT_REGISTRY,
2543                len: 65537,
2544                max: MAX_FIELD_SNAPSHOT_BYTES,
2545            })
2546        ));
2547    }
2548
2549    /// The bounds cover application components, `USER_DISPLAY_NAME`,
2550    /// `GROUP_IMAGE`, and `COMPONENT_REGISTRY` only. Other XMTP components
2551    /// keep their own limits.
2552    // verifies: META-068
2553    #[xmtp_common::test(unwrap_try = true)]
2554    fn field_bounds_skip_other_xmtp_components() {
2555        let registered = ComponentId::new(0x80FF);
2556        let over = [b'a'; MAX_FIELD_ELEMENT_BYTES + 1];
2557        let registry = registry_with(registered, ComponentType::Bytes);
2558        apply_app_data_update_payload(registered, &over, None, &registry)?;
2559        apply_app_data_update_payload(ComponentId::GROUP_DESCRIPTION, &over, None, &registry)?;
2560    }
2561
2562    /// A payload's element bound covers every byte key and value it names,
2563    /// removal keys included: an oversized key can never match a stored one,
2564    /// so admitting it only spends proposal storage.
2565    // verifies: META-068
2566    #[xmtp_common::test(unwrap_try = true)]
2567    fn payload_bounds_measure_every_named_element() {
2568        let set = ComponentId::new(0xC06B);
2569        let map = ComponentId::new(0xC06C);
2570        let registries = [
2571            registry_with(set, ComponentType::TlsSetBytes),
2572            registry_with(map, ComponentType::TlsMapBytesBytes),
2573        ];
2574        let over = MAX_FIELD_ELEMENT_BYTES + 1;
2575        let check = |id, payload: Vec<u8>| {
2576            check_update_payload_bounds(id, &payload, &registries[usize::from(id == map)])
2577        };
2578        let exceeded = |result| matches!(result, Err(ComponentSourceError::FieldBoundExceeded { len, .. }) if len == over);
2579
2580        let set_delta = |delta: TlsSetDelta<VLBytes>| delta.tls_serialize_detached().unwrap();
2581        check(
2582            set,
2583            set_delta(TlsSetDelta::new().insert(bytes(MAX_FIELD_ELEMENT_BYTES))),
2584        )?;
2585        assert!(exceeded(check(
2586            set,
2587            set_delta(TlsSetDelta::new().insert(bytes(over)))
2588        )));
2589        assert!(exceeded(check(
2590            set,
2591            set_delta(TlsSetDelta::new().remove(bytes(over)))
2592        )));
2593        check(
2594            set,
2595            set_delta(TlsSetDelta::new().remove(bytes(MAX_FIELD_ELEMENT_BYTES))),
2596        )?;
2597
2598        let map_delta =
2599            |delta: TlsMapDelta<VLBytes, VLBytes>| delta.tls_serialize_detached().unwrap();
2600        assert!(exceeded(check(
2601            map,
2602            map_delta(TlsMapDelta::new().update(bytes(over), bytes(1)))
2603        )));
2604        assert!(exceeded(check(
2605            map,
2606            map_delta(TlsMapDelta::new().update(bytes(1), bytes(over)))
2607        )));
2608        assert!(exceeded(check(
2609            map,
2610            map_delta(TlsMapDelta::new().delete(bytes(over)))
2611        )));
2612        check(
2613            map,
2614            map_delta(TlsMapDelta::new().delete(bytes(MAX_FIELD_ELEMENT_BYTES))),
2615        )?;
2616
2617        check(ComponentId::GROUP_DESCRIPTION, vec![b'a'; over])?;
2618    }
2619}