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}