Skip to main content

xmtp_mls/groups/app_data/
mod.rs

1//! Group state stored in the OpenMLS AppData dictionary.
2//!
3//! This module is the bridge between the per-field intent handlers in
4//! `mls_sync` and the OpenMLS app data dictionary. The existing per-field
5//! helpers (`update_group_name`, `update_admin_list_action`, …) keep their
6//! signatures and route through the appropriate sub-module here. The
7//! component codec lives in `xmtp_mls_common::app_data::component_source`;
8//! its helpers transform bytes and do not change a group by themselves.
9//!
10//! `membership_upkeep` adds the registry reconciliation and removed-member
11//! cleanup that ride along with every commit this client builds.
12
13#[allow(
14    dead_code,
15    reason = "Retained for removal with migration code in Task 5"
16)]
17pub(crate) mod bootstrap_validator;
18pub(crate) mod membership_upkeep;
19pub mod migration;
20pub(crate) mod sender_intents;
21
22use std::collections::BTreeMap;
23
24use openmls::{
25    component::ComponentData,
26    extensions::AppDataDictionary,
27    framing::{MlsMessageOut, ProcessedMessage, ProtocolMessage},
28    group::{
29        AppDataUpdates, MlsGroup as OpenMlsGroup, ProcessMessageError, ProposalError,
30        ResolveAppDataCommitError,
31    },
32    messages::proposals::{AppDataUpdateOperation, Proposal},
33    // `CommitMessageBundle` lives in `prelude` because the natural path
34    // (`openmls::group::commit_builder`) is private to the openmls crate.
35    // Re-importing through prelude is the only public path.
36    prelude::{CommitMessageBundle, ProcessedMessageContent},
37    storage::OpenMlsProvider,
38};
39use xmtp_mls_common::app_data::{component_id::ComponentId, component_registry::ComponentRegistry};
40
41use xmtp_configuration::ApplicationComponentDefinition;
42use xmtp_mls_common::app_data::component_source::{
43    ComponentSourceError, apply_app_data_update_payload, read_from_app_data_dict,
44};
45use xmtp_mls_common::app_data::protocol_floor::committed_floor_exceeding;
46use xmtp_mls_common::libxmtp_version::LibXMTPVersion;
47
48#[cfg(any(test, feature = "test-utils"))]
49tokio::task_local! {
50    /// Test-only override returned by [`load_component_registry`].
51    /// Stored as a tokio task-local (rather than a thread-local) so the
52    /// scope survives task migration across worker threads under
53    /// `multi_thread` runtimes.
54    pub static TEST_REGISTRY_OVERRIDE: ComponentRegistry;
55}
56
57/// Error returned by [`process_message_with_app_data`].
58///
59/// Wraps both an OpenMLS [`ProcessMessageError`] (for the underlying
60/// `process_message` failure modes) and a [`ComponentSourceError`] (for
61/// failures that happen while we decode an incoming `AppDataUpdate`
62/// payload). Splitting them keeps "the message was bad in OpenMLS terms"
63/// distinct from "we couldn't decode an AppData payload" so callers can
64/// log / retry / surface them differently.
65#[derive(Debug, thiserror::Error)]
66pub enum ProcessMessageWithAppDataError<StorageError: std::error::Error> {
67    /// Standard OpenMLS processing failure (decryption, validation, …).
68    #[error(transparent)]
69    OpenMls(#[from] ProcessMessageError<StorageError>),
70    /// Failed to decode an incoming `AppDataUpdate` payload via
71    /// [`apply_app_data_update_payload`]. Almost always indicates a
72    /// malformed proposal from a peer (or a wire-format mismatch with a
73    /// future version we don't understand yet).
74    ///
75    /// **Not retryable.** Decode failures are deterministic over the
76    /// exact bytes on the wire, so retrying the same message will fail
77    /// the same way. `GroupMessageProcessingError::is_retryable` and
78    /// `commit_result` treat this as a terminal wire-format violation
79    /// (mapped to `CommitResult::Invalid`).
80    #[error("failed to decode incoming AppDataUpdate payload: {0}")]
81    AppDataDecode(#[from] ComponentSourceError),
82    /// The group's committed `MIN_SUPPORTED_PROTOCOL_VERSION` floor
83    /// exceeds this client's version. Surfaced *before* any
84    /// `AppDataUpdate` payload is dispatched, so a client below the
85    /// floor (most commonly after an app downgrade — pausing normally
86    /// happens at the floor-bump commit itself, but a downgraded
87    /// client never processed one) pauses the group instead of
88    /// rejecting a commit it cannot interpret. Rejecting here is what
89    /// forks a group: peers above the floor accept the commit and
90    /// advance without us.
91    ///
92    /// Converted to `CommitRuleError::ProtocolVersionTooLow` at
93    /// the `mls_sync` boundary so the existing pause machinery
94    /// (`set_group_paused`, held cursor, reprocess-on-upgrade) applies
95    /// unchanged.
96    #[error(
97        "group's minimum supported protocol version {min_version} exceeds this client's version {own_version}"
98    )]
99    ProtocolVersionTooLow {
100        min_version: String,
101        own_version: String,
102    },
103    /// Staging an app-data commit failed after we interpreted its
104    /// proposals (`OpenMlsGroup::resolve_app_data_commit`). Carries the
105    /// same staging failure modes a commit without AppDataUpdate
106    /// proposals would surface from `process_message` directly.
107    #[error("failed to stage app-data commit: {0}")]
108    ResolveAppDataCommit(#[from] ResolveAppDataCommitError),
109}
110
111/// Walk a stream of `(ComponentId, &AppDataUpdateOperation)` tuples and
112/// produce the resulting [`AppDataUpdates`] the commit builder / message
113/// processor wants.
114///
115/// Returns `Ok(None)` when the iterator yields no proposals.
116pub(crate) fn accumulate_app_data_updates<'a, I>(
117    mls_group: &OpenMlsGroup,
118    proposals: I,
119) -> Result<Option<AppDataUpdates>, ComponentSourceError>
120where
121    I: IntoIterator<Item = (openmls::component::ComponentId, &'a AppDataUpdateOperation)>,
122{
123    let mut batch = AppDataBatch::new(mls_group)?;
124    for (id, operation) in proposals {
125        batch.apply(id, operation)?;
126    }
127    Ok(batch.updates())
128}
129
130/// The dictionary a sequence of `AppDataUpdate` proposals produces, built
131/// one proposal at a time.
132///
133/// Per-component state (`Some(bytes)` for an Update, `None` for a Remove)
134/// lets two proposals for the same component chain: the second sees the
135/// first's effect as its old value. Every payload decodes against the
136/// pre-commit registry, as on the receive side, so a registry change in
137/// the same commit does not change how its other updates are read.
138pub(crate) struct AppDataBatch<'a> {
139    group: &'a OpenMlsGroup,
140    registry: ComponentRegistry,
141    states: BTreeMap<openmls::component::ComponentId, Option<Vec<u8>>>,
142}
143
144impl<'a> AppDataBatch<'a> {
145    pub(crate) fn new(group: &'a OpenMlsGroup) -> Result<Self, ComponentSourceError> {
146        Ok(Self {
147            group,
148            registry: load_component_registry(group)?,
149            states: BTreeMap::new(),
150        })
151    }
152
153    /// A batch holding the group's pending proposals, in the order a
154    /// commit that consumes the proposal store applies them.
155    pub(crate) fn pending(group: &'a OpenMlsGroup) -> Result<Self, ComponentSourceError> {
156        let mut batch = Self::new(group)?;
157        for queued in group.pending_proposals() {
158            if let Proposal::AppDataUpdate(proposal) = queued.proposal() {
159                batch.apply(proposal.component_id(), proposal.operation())?;
160            }
161        }
162        Ok(batch)
163    }
164
165    pub(crate) fn apply(
166        &mut self,
167        id: openmls::component::ComponentId,
168        operation: &AppDataUpdateOperation,
169    ) -> Result<(), ComponentSourceError> {
170        let value = match operation {
171            AppDataUpdateOperation::Update(payload) => {
172                let xmtp_id = ComponentId::from(id);
173                let old = self.state(xmtp_id);
174                Some(
175                    apply_app_data_update_payload(
176                        xmtp_id,
177                        payload.as_slice(),
178                        old.as_deref(),
179                        &self.registry,
180                    )
181                    .inspect_err(|e| {
182                        tracing::warn!(
183                            component_id = %xmtp_id,
184                            error = %e,
185                            "Failed to apply AppDataUpdate payload"
186                        );
187                    })?,
188                )
189            }
190            // A Remove never consults the component's payload rules, so
191            // component-level Remove rejections (e.g. the whole-registry
192            // Remove ban) are enforced by commit validation, which both
193            // commit-processing paths run before applying.
194            AppDataUpdateOperation::Remove => None,
195        };
196        self.states.insert(id, value);
197        Ok(())
198    }
199
200    /// The component's value after the proposals applied so far.
201    pub(crate) fn state(&self, id: ComponentId) -> Option<Vec<u8>> {
202        match self.states.get(&id.as_u16()) {
203            Some(value) => value.clone(),
204            None => read_from_app_data_dict(id, self.group),
205        }
206    }
207
208    /// The [`AppDataUpdates`] for the commit; `None` when no proposal was
209    /// applied.
210    pub(crate) fn updates(self) -> Option<AppDataUpdates> {
211        if self.states.is_empty() {
212            return None;
213        }
214        let mut updater = self.group.app_data_dictionary_updater();
215        for (id, value) in self.states {
216            match value {
217                Some(bytes) => updater.set(ComponentData::from_parts(id, bytes.into())),
218                None => updater.remove(&id),
219            }
220        }
221        updater.changes()
222    }
223}
224
225/// AppDataUpdate-aware wrapper around [`OpenMlsGroup::process_message`].
226///
227/// `OpenMlsGroup::process_message` returns a commit covering
228/// `AppDataUpdate` proposals as
229/// [`ProcessedMessageContent::UnresolvedAppDataCommit`] — the application
230/// is required to interpret the proposals, compute the resulting
231/// [`AppDataUpdates`], and resume staging. This wrapper does that dance:
232///
233/// 1. `process_message` as usual.
234/// 2. On an unresolved app-data commit, hand its (already
235///    reference-resolved) `AppDataUpdate` proposals to
236///    [`accumulate_app_data_updates`] to compute the resulting
237///    [`AppDataUpdates`].
238/// 3. Call `resolve_app_data_commit` with those updates, staging the
239///    commit and yielding a regular `StagedCommitMessage`.
240///
241/// Callers replace `mls_group.process_message(provider, message)` with
242/// `process_message_with_app_data(mls_group, provider, message)` and get
243/// back the same `ProcessedMessage` they used to; the
244/// `UnresolvedAppDataCommit` variant never escapes this function.
245/// `own` is the client's parsed pkg_version (threaded from the caller's
246/// context rather than read from a constant so cross-version tests can
247/// override it).
248pub(crate) fn process_message_with_app_data<Provider: OpenMlsProvider>(
249    mls_group: &mut OpenMlsGroup,
250    provider: &Provider,
251    message: impl Into<ProtocolMessage>,
252    own: &LibXMTPVersion,
253) -> Result<ProcessedMessage, ProcessMessageWithAppDataError<Provider::StorageError>> {
254    let processed = mls_group.process_message(provider, message)?;
255
256    // PAUSE BEFORE PARSE: every commit on a below-floor group must pause
257    // (held cursor, `set_group_paused`), never process — above-floor
258    // peers accept it and advance, so rejecting instead of pausing forks
259    // the group. Checked here, *after* `process_message` authenticated
260    // the message, so the pause decision is never driven by
261    // unauthenticated framing bits a sender could spoof to freeze
262    // application-message processing on a below-floor group. For an
263    // `UnresolvedAppDataCommit` this runs before the proposals are
264    // interpreted below — the commit's app-data payloads may use wire
265    // formats introduced after this version. It reads ONLY the
266    // pre-commit dict — committed, already-validated state — and must
267    // never consider the commit's own proposals: a same-commit floor
268    // bump has not passed the super-admin policy check yet, and pausing
269    // on unvalidated input would let any member freeze the group for
270    // everyone. Application messages are unaffected; standalone
271    // proposals get the same floor-first hold in `mls_sync`'s
272    // `ProposalMessage` arm, which also keeps a below-floor client from
273    // ever advancing past a stored-by-peers proposal that a later commit
274    // references. (Staging a plain commit inside `process_message`
275    // interprets no app-data payloads; nothing is merged until
276    // `merge_staged_commit`.)
277    let is_commit = matches!(
278        processed.content(),
279        ProcessedMessageContent::StagedCommitMessage(_)
280            | ProcessedMessageContent::UnresolvedAppDataCommit(_)
281    );
282    if is_commit && let Some(min_version) = committed_floor_exceeding(mls_group, own) {
283        return Err(ProcessMessageWithAppDataError::ProtocolVersionTooLow {
284            min_version,
285            own_version: own.to_string(),
286        });
287    }
288
289    let unresolved = match processed.content() {
290        ProcessedMessageContent::UnresolvedAppDataCommit(unresolved) => unresolved,
291        _ => return Ok(processed),
292    };
293
294    // Collect owned (id, operation) tuples so the iterator doesn't keep
295    // `processed` borrowed — `resolve_app_data_commit` consumes it below.
296    // Proposals committed by reference are already resolved from the
297    // proposal store by `process_message`.
298    let collected: Vec<(openmls::component::ComponentId, AppDataUpdateOperation)> = unresolved
299        .app_data_update_proposals()
300        .map(|p| (p.component_id(), p.operation().clone()))
301        .collect();
302    let iter = collected.iter().map(|(id, op)| (*id, op));
303    let app_data_updates = accumulate_app_data_updates(mls_group, iter)?;
304
305    Ok(mls_group.resolve_app_data_commit(provider, processed, app_data_updates)?)
306}
307
308/// Stage a standalone `AppDataUpdate(Update)` proposal AND a follow-up
309/// commit that references it from the OpenMLS proposal store.
310///
311/// This is the shape XIP §1.5.2 / §3.4 prescribes for post-migration
312/// metadata updates: separate proposal and commit MLS messages, so the
313/// commit message carries only a `ProposalRef` (hash) rather than the
314/// AppDataUpdate payload bytes. Smaller commits, smaller proposal-
315/// processing hot paths, identical end state.
316///
317/// Returns `(proposal_msg, commit_bundle)`. The caller MUST publish
318/// `proposal_msg` and `commit_bundle.commit()` together in one
319/// `payloads_to_publish` batch (proposal first) so receivers see the
320/// proposal in the same network round trip before processing the
321/// commit that references it.
322///
323/// Call this inside `generate_prepared_commit` and an outer state transaction.
324/// Store the exact attempt and staged commit in that transaction.
325pub(crate) fn stage_app_data_propose_and_commit<Provider: OpenMlsProvider>(
326    mls_group: &mut OpenMlsGroup,
327    provider: &Provider,
328    signer: &impl openmls_traits::signatures::Signer,
329    catalogue: &[ApplicationComponentDefinition],
330    component_id: ComponentId,
331    payload: Vec<u8>,
332) -> Result<(MlsMessageOut, CommitMessageBundle), GroupAppDataError<Provider::StorageError>> {
333    let (mut proposals, bundle) = stage_app_data_proposals_and_commit(
334        mls_group,
335        provider,
336        signer,
337        catalogue,
338        vec![(component_id, AppDataUpdateOperation::Update(payload.into()))],
339    )?;
340    Ok((proposals.remove(0), bundle))
341}
342
343/// Propose every `updates` operation and stage one commit of them, using
344/// the same pre-commit state. The commit also carries this client's
345/// membership upkeep for `catalogue` inline.
346pub(crate) fn stage_app_data_proposals_and_commit<Provider: OpenMlsProvider>(
347    mls_group: &mut OpenMlsGroup,
348    provider: &Provider,
349    signer: &impl openmls_traits::signatures::Signer,
350    catalogue: &[ApplicationComponentDefinition],
351    updates: Vec<(ComponentId, AppDataUpdateOperation)>,
352) -> Result<(Vec<MlsMessageOut>, CommitMessageBundle), GroupAppDataError<Provider::StorageError>> {
353    // Lazy-batching: we deliberately do NOT block on pre-existing
354    // pending proposals. This helper queues a new `AppDataUpdate` then
355    // commits via `consume_proposal_store(true)`, sweeping whatever
356    // else is in the store — concurrent `AppDataUpdate`s (accumulated
357    // into the dict by step 2), leaf-node `Update`s, membership
358    // `Add` / `Remove` / `SelfRemove`, PSK, etc. — all into one
359    // commit. That's the design: minimize commit count, let the
360    // producers of those proposals decide if they need to force their
361    // own commit (because they want to send a message right now or
362    // grant access immediately). MLS guarantees consistent state
363    // convergence on the wire regardless of which commit body carries
364    // which proposal; the sender's intent ledger may carry less
365    // information than the on-wire commit, but the producer of each
366    // folded-in proposal already accepted that outcome by leaving it
367    // pending instead of issuing its own commit.
368    let mut proposals = Vec::with_capacity(updates.len());
369    for (component_id, operation) in updates {
370        let (proposal, _) = mls_group
371            .propose_app_data_update(provider, signer, component_id.as_u16(), operation)
372            .map_err(GroupAppDataError::Propose)?;
373        proposals.push(proposal);
374    }
375
376    // Step 2: registry reconciliation and cleanup after any swept removal,
377    // with the dict updates of every `AppDataUpdate` in the store (earlier
378    // intents' queued proposals included) followed by the inline upkeep, so
379    // the final dict bytes match what `process_message_with_app_data`
380    // produces on the receive side. If OpenMLS ever changes
381    // `consume_proposal_store(true)`'s sweep or `pending_proposals()`
382    // ordering, receivers reject the commit with `WrongConfirmationTag`; the
383    // AppDataUpdate E2E tests in `groups/tests/test_proposals.rs` catch that.
384    let (upkeep, app_data_updates) = membership_upkeep::membership_upkeep(mls_group, catalogue)
385        .map_err(|e| GroupAppDataError::Upkeep(Box::new(e)))?;
386
387    // Step 3: build a commit that consumes the proposal store. The
388    // just-queued proposals are encoded as `ProposalRef`s; only the upkeep
389    // is inline.
390    let mut stage = mls_group
391        .commit_builder()
392        .consume_proposal_store(true)
393        .add_proposals(upkeep)
394        .load_psks(provider.storage())?;
395    stage.with_app_data_dictionary_updates(app_data_updates);
396
397    let bundle = stage
398        .build(provider.rand(), provider.crypto(), signer, |_| true)?
399        .stage_commit(provider)?;
400
401    Ok((proposals, bundle))
402}
403
404/// Errors surfaced by [`stage_app_data_propose_and_commit`].
405///
406/// Wrapped into `GroupError` via the `#[from]` impl on
407/// `GroupError::AppDataCommit` so the structured source is preserved at
408/// the call site (no string-flattening). The `pub(crate)` visibility
409/// matches the helper itself; the variant is only re-exported through
410/// the public `GroupError` enum.
411#[derive(Debug, thiserror::Error)]
412pub enum GroupAppDataError<StorageError: std::error::Error> {
413    /// `propose_app_data_update(…)` failed when staging the standalone
414    /// proposal that precedes the commit.
415    #[error("propose error: {0}")]
416    Propose(#[from] ProposalError<StorageError>),
417    /// `commit_builder().load_psks(…).build(…)` failed.
418    #[error("commit create error: {0}")]
419    CreateCommit(#[from] openmls::group::CreateCommitError),
420    /// `stage_commit(provider)` failed (storage / signature / staging error).
421    #[error("commit stage error: {0}")]
422    StageCommit(#[from] openmls::group::CommitBuilderStageError<StorageError>),
423    /// `apply_app_data_update_payload` failed while pre-computing the new
424    /// dict value the commit builder hands to OpenMLS. The most common
425    /// cause is a mismatch between the sender's idea of the current dict
426    /// state and the receiver's, which would surface as a confirmation
427    /// tag mismatch on the wire if it ever escaped.
428    #[error("apply payload error: {0}")]
429    ApplyPayload(#[from] xmtp_mls_common::app_data::component_source::ComponentSourceError),
430    /// Computing the removed-member cleanup for a swept removal failed.
431    #[error("membership upkeep error: {0}")]
432    Upkeep(#[source] Box<crate::groups::GroupError>),
433}
434
435// Specialize to the concrete SqlKeyStoreError because that's the only
436// storage instantiation used (see `GroupError::AppDataCommit` at
437// error.rs). It also lets us delegate to `RetryableError<Mls>` impls
438// already defined in `xmtp_db::errors` for the inner OpenMLS error
439// types — sibling pattern to `GroupError::Proposal(e) => e.is_retryable()`
440// — so SQLite-busy storage faults retry instead of permanently failing
441// the intent.
442impl xmtp_common::RetryableError for GroupAppDataError<xmtp_db::sql_key_store::SqlKeyStoreError> {
443    fn is_retryable(&self) -> bool {
444        match self {
445            // Delegate to the inner OpenMLS error's retryability so
446            // SQLite-busy storage faults during propose / stage retry
447            // rather than permanently fail the intent. The matching
448            // upstream impls live in `xmtp_db::errors`
449            // (`RetryableError<Mls>` for `ProposalError` /
450            // `CommitBuilderStageError`).
451            Self::Propose(e) => xmtp_common::retryable!(e),
452            Self::StageCommit(e) => xmtp_common::retryable!(e),
453            Self::Upkeep(e) => xmtp_common::retryable!(e),
454            // Deterministic shape / staging-precondition failures —
455            // CreateCommit is upstream-`false`, and ApplyPayload is a
456            // sender-side encode failure that won't get better on
457            // retry.
458            Self::CreateCommit(_) | Self::ApplyPayload(_) => false,
459        }
460    }
461}
462
463/// Compute the [`AppDataUpdates`] required to commit any pending
464/// AppDataUpdate proposals in the group's proposal store.
465///
466/// Returns `Ok(None)` when there are no AppDataUpdate proposals pending —
467/// this is the common case and lets the caller skip the `with_…` plumbing
468/// entirely without changing semantics.
469pub(crate) fn pending_app_data_updates(
470    mls_group: &OpenMlsGroup,
471) -> Result<Option<AppDataUpdates>, ComponentSourceError> {
472    Ok(AppDataBatch::pending(mls_group)?.updates())
473}
474
475/// The dictionary a commit built now starts from: the committed one with
476/// the pending proposals' updates applied.
477pub(crate) fn pending_dictionary(
478    mls_group: &OpenMlsGroup,
479) -> Result<AppDataDictionary, ComponentSourceError> {
480    let mut dictionary = mls_group
481        .extensions()
482        .app_data_dictionary()
483        .map(|extension| extension.dictionary().clone())
484        .unwrap_or_default();
485    for (id, value) in pending_app_data_updates(mls_group)?.into_iter().flatten() {
486        match value {
487            Some(value) => dictionary.insert(id, value),
488            None => dictionary.remove(&id),
489        };
490    }
491    Ok(dictionary)
492}
493
494/// Read the committed component registry from the dictionary.
495/// Missing registry entries reject component writes by default.
496pub(crate) fn load_component_registry(
497    mls_group: &OpenMlsGroup,
498) -> Result<ComponentRegistry, ComponentSourceError> {
499    load_component_registry_from_extensions(mls_group.extensions())
500}
501
502/// Read the component registry without loading an OpenMLS group.
503pub(crate) fn load_component_registry_from_extensions(
504    extensions: &openmls::extensions::Extensions<openmls::group::GroupContext>,
505) -> Result<ComponentRegistry, ComponentSourceError> {
506    // The committed dictionary is the source of truth for the registry.
507    if let Some(ext) = extensions.app_data_dictionary()
508        && let Some(bytes) = ext
509            .dictionary()
510            .get(&ComponentId::COMPONENT_REGISTRY.as_u16())
511    {
512        return ComponentRegistry::from_bytes(bytes)
513            .map_err(|e| ComponentSourceError::MalformedComponentValue {
514                component_id: ComponentId::COMPONENT_REGISTRY,
515                reason: format!("registry decode: {e}"),
516            })
517            .inspect(|reg| {
518                // Tolerated (preserved-but-invisible) entries mean the
519                // dict was written by a newer protocol version or
520                // carries a historical invalid entry. Writes to those
521                // components fall to deny-by-default; everything else
522                // validates normally. Loud so poisoned-registry
523                // incidents are diagnosable from logs.
524                let unrecognized: Vec<_> = reg.unrecognized_ids().collect();
525                if !unrecognized.is_empty() {
526                    tracing::warn!(
527                        ?unrecognized,
528                        "component registry contains unrecognized entries; \
529                         treating them as unregistered (deny-by-default)"
530                    );
531                }
532            });
533    }
534
535    // Pre-migration or test override.
536    #[cfg(any(test, feature = "test-utils"))]
537    if let Ok(reg) = TEST_REGISTRY_OVERRIDE.try_with(|r| r.clone()) {
538        return Ok(reg);
539    }
540    Ok(ComponentRegistry::new())
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546    use openmls::extensions::{
547        AppDataDictionary, AppDataDictionaryExtension, Extension, Extensions,
548    };
549
550    fn extensions_with_dict(
551        entries: &[(u16, Vec<u8>)],
552    ) -> Extensions<openmls::group::GroupContext> {
553        let mut dict = AppDataDictionary::new();
554        for (id, bytes) in entries {
555            let _ = dict.insert(*id, bytes.clone());
556        }
557        Extensions::from_vec(vec![Extension::AppDataDictionary(
558            AppDataDictionaryExtension::new(dict),
559        )])
560        .expect("AppDataDictionary is a valid GroupContext extension")
561    }
562
563    fn empty_extensions() -> Extensions<openmls::group::GroupContext> {
564        Extensions::from_vec(vec![]).expect("empty extensions are always valid")
565    }
566
567    // ========================================================================
568    // load_component_registry_from_extensions
569    // ========================================================================
570    //
571    // These pin the contract that the migration-marker
572    // (registry entry presence) and the registry loader
573    // (`load_component_registry_from_extensions`, parseability) agree on
574    // exactly one shape of disagreement: malformed bytes surface as a
575    // hard `MalformedComponentValue` error rather than silently
576    // collapsing to an empty registry. An empty registry on a "migrated"
577    // group would cause downstream readers (mutable_metadata, validators)
578    // to silently lose every dict-backed component, so this invariant is
579    // load-bearing.
580
581    #[xmtp_common::test(unwrap_try = true)]
582    fn load_registry_no_dict_returns_empty() {
583        let reg = load_component_registry_from_extensions(&empty_extensions()).unwrap();
584        assert!(reg.is_empty());
585    }
586
587    #[xmtp_common::test(unwrap_try = true)]
588    fn load_registry_dict_without_entry_returns_empty() {
589        // Dict present but no COMPONENT_REGISTRY entry => pre-bootstrap.
590        // An entry under some *other* component id must not be confused
591        // for the registry payload.
592        let exts =
593            extensions_with_dict(&[(ComponentId::GROUP_NAME.as_u16(), b"Group Name".to_vec())]);
594        let reg = load_component_registry_from_extensions(&exts).unwrap();
595        assert!(reg.is_empty());
596    }
597
598    #[xmtp_common::test(unwrap_try = true)]
599    fn load_registry_with_valid_bytes_round_trips() {
600        let original = ComponentRegistry::new();
601        let bytes = original.to_bytes().expect("empty registry serializes");
602        let exts = extensions_with_dict(&[(ComponentId::COMPONENT_REGISTRY.as_u16(), bytes)]);
603        let loaded = load_component_registry_from_extensions(&exts).unwrap();
604        assert_eq!(loaded, original);
605    }
606
607    #[xmtp_common::test(unwrap_try = true)]
608    fn load_registry_with_malformed_bytes_surfaces_error() {
609        // Pin the "fail loud, never return empty" invariant: a
610        // malformed `COMPONENT_REGISTRY` value must surface as
611        // `MalformedComponentValue` so downstream readers don't carry
612        // on with a phantom empty registry against an
613        // dictionary with a registry entry.
614        let exts = extensions_with_dict(&[(
615            ComponentId::COMPONENT_REGISTRY.as_u16(),
616            vec![0xff, 0xff, 0xff],
617        )]);
618        let err = load_component_registry_from_extensions(&exts).unwrap_err();
619        assert!(
620            matches!(
621                err,
622                ComponentSourceError::MalformedComponentValue { component_id, .. }
623                    if component_id == ComponentId::COMPONENT_REGISTRY
624            ),
625            "expected MalformedComponentValue for COMPONENT_REGISTRY, got: {err:?}"
626        );
627    }
628}