Skip to main content

xmtp_mls/groups/app_data/
mod.rs

1//! App-data plumbing for moving group state from group context extensions
2//! onto OpenMLS `AppDataUpdate` proposals.
3//!
4//! This module is the bridge between the per-field intent handlers in
5//! `mls_sync` and the OpenMLS app data dictionary. It is intentionally
6//! `pub(crate)` — there is no public API for reading or writing arbitrary
7//! components. The existing per-field helpers (`update_group_name`,
8//! `update_admin_list_action`, …) keep their signatures and route through
9//! the appropriate sub-module here when the group has flipped
10//! `proposals_enabled`.
11
12// `pub` (rather than `pub(crate)`) so the public `GroupError::ComponentSource`
13// variant in `crate::groups::error` doesn't trip the `private_interfaces`
14// lint. The functions inside the module remain `pub(crate)`, so the wider
15// crate ecosystem still can't read or write arbitrary components — only
16// `GroupError` consumers see the error type.
17pub(crate) mod bootstrap_validator;
18pub mod component_source;
19pub mod migration;
20pub(crate) mod policy;
21pub(crate) mod sender_intents;
22pub(crate) mod typed_facade;
23
24use std::collections::BTreeMap;
25
26use openmls::{
27    component::ComponentData,
28    framing::{MlsMessageOut, ProcessedMessage, ProtocolMessage},
29    group::{
30        AppDataUpdates, MlsGroup as OpenMlsGroup, ProcessMessageError, ProposalError,
31        ResolveAppDataCommitError,
32    },
33    messages::proposals::{AppDataUpdateOperation, Proposal},
34    // `CommitMessageBundle` lives in `prelude` because the natural path
35    // (`openmls::group::commit_builder`) is private to the openmls crate.
36    // Re-importing through prelude is the only public path.
37    prelude::{CommitMessageBundle, ProcessedMessageContent},
38    storage::OpenMlsProvider,
39};
40use xmtp_mls_common::app_data::{component_id::ComponentId, component_registry::ComponentRegistry};
41
42use self::component_source::{
43    ComponentSourceError, apply_app_data_update_payload, read_from_app_data_dict,
44};
45use crate::groups::validated_commit::LibXMTPVersion;
46
47#[cfg(any(test, feature = "test-utils"))]
48tokio::task_local! {
49    /// Test-only override returned by [`load_component_registry`].
50    /// Stored as a tokio task-local (rather than a thread-local) so the
51    /// scope survives task migration across worker threads under
52    /// `multi_thread` runtimes.
53    pub static TEST_REGISTRY_OVERRIDE: ComponentRegistry;
54}
55
56/// Error returned by [`process_message_with_app_data`].
57///
58/// Wraps both an OpenMLS [`ProcessMessageError`] (for the underlying
59/// `process_message` failure modes) and a [`ComponentSourceError`] (for
60/// failures that happen while we decode an incoming `AppDataUpdate`
61/// payload). Splitting them keeps "the message was bad in OpenMLS terms"
62/// distinct from "we couldn't decode an AppData payload" so callers can
63/// log / retry / surface them differently.
64#[derive(Debug, thiserror::Error)]
65pub enum ProcessMessageWithAppDataError<StorageError: std::error::Error> {
66    /// Standard OpenMLS processing failure (decryption, validation, …).
67    #[error(transparent)]
68    OpenMls(#[from] ProcessMessageError<StorageError>),
69    /// Failed to decode an incoming `AppDataUpdate` payload via
70    /// [`apply_app_data_update_payload`]. Almost always indicates a
71    /// malformed proposal from a peer (or a wire-format mismatch with a
72    /// future version we don't understand yet).
73    ///
74    /// **Not retryable.** Decode failures are deterministic over the
75    /// exact bytes on the wire, so retrying the same message will fail
76    /// the same way. `GroupMessageProcessingError::is_retryable` and
77    /// `commit_result` treat this as a terminal wire-format violation
78    /// (mapped to `CommitResult::Invalid`).
79    #[error("failed to decode incoming AppDataUpdate payload: {0}")]
80    AppDataDecode(#[from] ComponentSourceError),
81    /// The group's committed `MIN_SUPPORTED_PROTOCOL_VERSION` floor
82    /// exceeds this client's version. Surfaced *before* any
83    /// `AppDataUpdate` payload is dispatched, so a client below the
84    /// floor (most commonly after an app downgrade — pausing normally
85    /// happens at the floor-bump commit itself, but a downgraded
86    /// client never processed one) pauses the group instead of
87    /// rejecting a commit it cannot interpret. Rejecting here is what
88    /// forks a group: peers above the floor accept the commit and
89    /// advance without us.
90    ///
91    /// Converted to `CommitValidationError::ProtocolVersionTooLow` at
92    /// the `mls_sync` boundary so the existing pause machinery
93    /// (`set_group_paused`, held cursor, reprocess-on-upgrade) applies
94    /// unchanged.
95    #[error(
96        "group's minimum supported protocol version {min_version} exceeds this client's version {own_version}"
97    )]
98    ProtocolVersionTooLow {
99        min_version: String,
100        own_version: String,
101    },
102    /// Staging an app-data commit failed after we interpreted its
103    /// proposals (`OpenMlsGroup::resolve_app_data_commit`). Carries the
104    /// same staging failure modes a commit without AppDataUpdate
105    /// proposals would surface from `process_message` directly.
106    #[error("failed to stage app-data commit: {0}")]
107    ResolveAppDataCommit(#[from] ResolveAppDataCommitError),
108}
109
110/// Walk a stream of `(ComponentId, &AppDataUpdateOperation)` tuples and
111/// produce the resulting [`AppDataUpdates`] the commit builder / message
112/// processor wants.
113///
114/// Accumulates per-component state in a local [`BTreeMap`]
115/// (`Some(bytes)` for an Update, `None` for a Remove) so that two proposals
116/// targeting the same component inside one batch chain correctly — the
117/// second one's `apply_app_data_update_payload` call sees the first
118/// proposal's effect as its `old_value`. The migration PR's bootstrap
119/// commit emits multiple `AppDataUpdate(COMPONENT_REGISTRY, ...)` proposals
120/// back-to-back and would otherwise lose all but the last one.
121///
122/// Returns `Ok(None)` when the iterator yields no proposals (an empty
123/// `BTreeMap::new()` is heap-free, so the common zero-proposal case costs
124/// essentially nothing).
125pub(crate) fn accumulate_app_data_updates<'a, I>(
126    mls_group: &OpenMlsGroup,
127    proposals: I,
128) -> Result<Option<AppDataUpdates>, ComponentSourceError>
129where
130    I: IntoIterator<Item = (openmls::component::ComponentId, &'a AppDataUpdateOperation)>,
131{
132    let mut in_batch: BTreeMap<openmls::component::ComponentId, Option<Vec<u8>>> = BTreeMap::new();
133
134    // Load the pre-commit registry once. It supplies the
135    // `ComponentType` tag the type-aware dispatcher in
136    // `apply_app_data_update_payload` uses when an unknown component id
137    // arrives. Registry updates that land in the same commit don't
138    // retroactively change this snapshot — the typed path would need
139    // an in-batch registry overlay to handle the corner case where the
140    // very same commit both registers a new component and writes to
141    // it.
142    let registry = load_component_registry(mls_group)?;
143
144    for (openmls_id, operation) in proposals {
145        let xmtp_id = ComponentId::from(openmls_id);
146        match operation {
147            AppDataUpdateOperation::Update(payload) => {
148                // Resolve `old_value` from in-batch state first; fall back
149                // to the pre-commit dict only if no earlier proposal in
150                // this batch touched the same component. The match borrows
151                // from `in_batch` only for the duration of the arm body —
152                // `apply_app_data_update_payload` returns an owned `Vec<u8>`
153                // that outlives the borrow, so the follow-up `insert` is
154                // legal without cloning the prior bytes.
155                let new_value = match in_batch.get(&openmls_id) {
156                    Some(Some(bytes)) => apply_app_data_update_payload(
157                        xmtp_id,
158                        payload.as_slice(),
159                        Some(bytes.as_slice()),
160                        &registry,
161                    ),
162                    Some(None) => {
163                        apply_app_data_update_payload(xmtp_id, payload.as_slice(), None, &registry)
164                    }
165                    None => {
166                        let from_dict = read_from_app_data_dict(xmtp_id, mls_group);
167                        apply_app_data_update_payload(
168                            xmtp_id,
169                            payload.as_slice(),
170                            from_dict.as_deref(),
171                            &registry,
172                        )
173                    }
174                }
175                .inspect_err(|e| {
176                    tracing::warn!(
177                        component_id = %xmtp_id,
178                        error = %e,
179                        "Failed to apply AppDataUpdate payload"
180                    );
181                })?;
182                in_batch.insert(openmls_id, Some(new_value));
183            }
184            AppDataUpdateOperation::Remove => {
185                // Maps straight to `updater.remove(&id)` below — the
186                // component impl's `apply_update_payload` is never
187                // consulted for `Remove`, so component-level Remove
188                // rejections (e.g. the whole-registry Remove ban in
189                // `ComponentRegistryComponent::expand_to_changes`) are
190                // enforced during commit validation
191                // (`ValidatedCommit::from_staged_commit`), not
192                // re-checked here. That's sound because both current
193                // commit-processing paths validate before applying.
194                in_batch.insert(openmls_id, None);
195            }
196        }
197    }
198
199    if in_batch.is_empty() {
200        return Ok(None);
201    }
202
203    let mut updater = mls_group.app_data_dictionary_updater();
204    for (id, value) in in_batch {
205        match value {
206            Some(bytes) => updater.set(ComponentData::from_parts(id, bytes.into())),
207            None => updater.remove(&id),
208        }
209    }
210    Ok(updater.changes())
211}
212
213/// AppDataUpdate-aware wrapper around [`OpenMlsGroup::process_message`].
214///
215/// `OpenMlsGroup::process_message` returns a commit covering
216/// `AppDataUpdate` proposals as
217/// [`ProcessedMessageContent::UnresolvedAppDataCommit`] — the application
218/// is required to interpret the proposals, compute the resulting
219/// [`AppDataUpdates`], and resume staging. This wrapper does that dance:
220///
221/// 1. `process_message` as usual.
222/// 2. On an unresolved app-data commit, hand its (already
223///    reference-resolved) `AppDataUpdate` proposals to
224///    [`accumulate_app_data_updates`] to compute the resulting
225///    [`AppDataUpdates`].
226/// 3. Call `resolve_app_data_commit` with those updates, staging the
227///    commit and yielding a regular `StagedCommitMessage`.
228///
229/// Callers replace `mls_group.process_message(provider, message)` with
230/// `process_message_with_app_data(mls_group, provider, message)` and get
231/// back the same `ProcessedMessage` they used to; the
232/// `UnresolvedAppDataCommit` variant never escapes this function.
233/// `own` is the client's parsed pkg_version (threaded from the caller's
234/// context rather than read from a constant so cross-version tests can
235/// override it).
236pub(crate) fn process_message_with_app_data<Provider: OpenMlsProvider>(
237    mls_group: &mut OpenMlsGroup,
238    provider: &Provider,
239    message: impl Into<ProtocolMessage>,
240    own: &LibXMTPVersion,
241) -> Result<ProcessedMessage, ProcessMessageWithAppDataError<Provider::StorageError>> {
242    let processed = mls_group.process_message(provider, message)?;
243
244    // PAUSE BEFORE PARSE: every commit on a below-floor group must pause
245    // (held cursor, `set_group_paused`), never process — above-floor
246    // peers accept it and advance, so rejecting instead of pausing forks
247    // the group. Checked here, *after* `process_message` authenticated
248    // the message, so the pause decision is never driven by
249    // unauthenticated framing bits a sender could spoof to freeze
250    // application-message processing on a below-floor group. For an
251    // `UnresolvedAppDataCommit` this runs before the proposals are
252    // interpreted below — the commit's app-data payloads may use wire
253    // formats introduced after this version. It reads ONLY the
254    // pre-commit dict — committed, already-validated state — and must
255    // never consider the commit's own proposals: a same-commit floor
256    // bump has not passed the super-admin policy check yet, and pausing
257    // on unvalidated input would let any member freeze the group for
258    // everyone. Application messages are unaffected; standalone
259    // proposals get the same floor-first hold in `mls_sync`'s
260    // `ProposalMessage` arm, which also keeps a below-floor client from
261    // ever advancing past a stored-by-peers proposal that a later commit
262    // references. (Staging a plain commit inside `process_message`
263    // interprets no app-data payloads; nothing is merged until
264    // `merge_staged_commit`.)
265    let is_commit = matches!(
266        processed.content(),
267        ProcessedMessageContent::StagedCommitMessage(_)
268            | ProcessedMessageContent::UnresolvedAppDataCommit(_)
269    );
270    if is_commit && let Some(min_version) = committed_floor_exceeding(mls_group, own) {
271        return Err(ProcessMessageWithAppDataError::ProtocolVersionTooLow {
272            min_version,
273            own_version: own.to_string(),
274        });
275    }
276
277    let unresolved = match processed.content() {
278        ProcessedMessageContent::UnresolvedAppDataCommit(unresolved) => unresolved,
279        _ => return Ok(processed),
280    };
281
282    // Collect owned (id, operation) tuples so the iterator doesn't keep
283    // `processed` borrowed — `resolve_app_data_commit` consumes it below.
284    // Proposals committed by reference are already resolved from the
285    // proposal store by `process_message`.
286    let collected: Vec<(openmls::component::ComponentId, AppDataUpdateOperation)> = unresolved
287        .app_data_update_proposals()
288        .map(|p| (p.component_id(), p.operation().clone()))
289        .collect();
290    let iter = collected.iter().map(|(id, op)| (*id, op));
291    let app_data_updates = accumulate_app_data_updates(mls_group, iter)?;
292
293    Ok(mls_group.resolve_app_data_commit(provider, processed, app_data_updates)?)
294}
295
296/// Stage a standalone `AppDataUpdate(Update)` proposal AND a follow-up
297/// commit that references it from the OpenMLS proposal store.
298///
299/// This is the shape XIP §1.5.2 / §3.4 prescribes for post-migration
300/// metadata updates: separate proposal and commit MLS messages, so the
301/// commit message carries only a `ProposalRef` (hash) rather than the
302/// AppDataUpdate payload bytes. Smaller commits, smaller proposal-
303/// processing hot paths, identical end state.
304///
305/// Returns `(proposal_msg, commit_bundle)`. The caller MUST publish
306/// `proposal_msg` and `commit_bundle.commit()` together in one
307/// `payloads_to_publish` batch (proposal first) so receivers see the
308/// proposal in the same network round trip before processing the
309/// commit that references it.
310///
311/// Call this inside `generate_prepared_commit` and an outer state transaction.
312/// Store the exact attempt and staged commit in that transaction.
313pub(crate) fn stage_app_data_propose_and_commit<Provider: OpenMlsProvider>(
314    mls_group: &mut OpenMlsGroup,
315    provider: &Provider,
316    signer: &impl openmls_traits::signatures::Signer,
317    component_id: ComponentId,
318    payload: Vec<u8>,
319) -> Result<(MlsMessageOut, CommitMessageBundle), GroupAppDataError<Provider::StorageError>> {
320    // Lazy-batching: we deliberately do NOT block on pre-existing
321    // pending proposals. This helper queues a new `AppDataUpdate` then
322    // commits via `consume_proposal_store(true)`, sweeping whatever
323    // else is in the store — concurrent `AppDataUpdate`s (accumulated
324    // into the dict by step 2), leaf-node `Update`s, membership
325    // `Add` / `Remove` / `SelfRemove`, PSK, etc. — all into one
326    // commit. That's the design: minimize commit count, let the
327    // producers of those proposals decide if they need to force their
328    // own commit (because they want to send a message right now or
329    // grant access immediately). MLS guarantees consistent state
330    // convergence on the wire regardless of which commit body carries
331    // which proposal; the sender's intent ledger may carry less
332    // information than the on-wire commit, but the producer of each
333    // folded-in proposal already accepted that outcome by leaving it
334    // pending instead of issuing its own commit.
335    let openmls_id = component_id.as_u16();
336    let operation = AppDataUpdateOperation::Update(payload.into());
337
338    // Step 1: publish a standalone proposal. This adds the proposal to
339    // the local pending-proposal store AND returns the wire-form
340    // MlsMessageOut for the proposal so the caller can broadcast it.
341    let (proposal_msg, _proposal_ref) = mls_group
342        .propose_app_data_update(provider, signer, openmls_id, operation)
343        .map_err(GroupAppDataError::Propose)?;
344
345    // Step 2: compute the per-component dict updates by sweeping every
346    // `AppDataUpdate` proposal currently in the store. The store may
347    // contain pre-existing `AppDataUpdate` proposals queued by earlier
348    // intents (e.g. two members each issuing a `GROUP_MEMBERSHIP`
349    // update, or a queued `update_group_name` that hasn't been
350    // committed yet); the accumulator chains them via the in-batch
351    // map so the final dict bytes match what
352    // `process_message_with_app_data` produces on the receive side.
353    //
354    // Non-`AppDataUpdate` proposals (Add/Remove/Update/PSK/etc.) also
355    // get swept by `consume_proposal_store(true)` at step 3 — they
356    // ride into the commit natively via OpenMLS and don't contribute
357    // to AppData dict updates, so we don't include them in this
358    // iteration.
359    //
360    // Failure mode if OpenMLS ever changes `consume_proposal_store(true)`'s
361    // sweep behavior or `pending_proposals()` ordering: sender and
362    // receiver compute different final dict bytes for the same
363    // component, the commit's confirmation tag mismatches, and
364    // receivers reject the commit with `WrongConfirmationTag`. The E2E
365    // tests in `groups/tests/test_proposals.rs` under the AppDataUpdate
366    // section will fail loudly on any OpenMLS bump that breaks this.
367    let pending_tuples: Vec<(openmls::component::ComponentId, AppDataUpdateOperation)> = mls_group
368        .pending_proposals()
369        .filter_map(|q| match q.proposal() {
370            Proposal::AppDataUpdate(p) => Some((p.component_id(), p.operation().clone())),
371            _ => None,
372        })
373        .collect();
374    let pending_iter = pending_tuples.iter().map(|(id, op)| (*id, op));
375    let app_data_updates =
376        accumulate_app_data_updates(mls_group, pending_iter).inspect_err(|e| {
377            tracing::error!(
378                component_id = %component_id,
379                error = %e,
380                "Failed to compute AppDataUpdates for standalone propose+commit"
381            );
382        })?;
383
384    // Step 3: build a commit that consumes the proposal store (picks up
385    // the just-queued proposal). No `add_proposal` call — the proposal
386    // is encoded as a `ProposalRef` because it comes from the store, not
387    // from inline staging.
388    let mut stage = mls_group
389        .commit_builder()
390        .consume_proposal_store(true)
391        .load_psks(provider.storage())?;
392    stage.with_app_data_dictionary_updates(app_data_updates);
393
394    let bundle = stage
395        .build(provider.rand(), provider.crypto(), signer, |_| true)?
396        .stage_commit(provider)?;
397
398    Ok((proposal_msg, bundle))
399}
400
401/// Errors surfaced by [`stage_app_data_propose_and_commit`].
402///
403/// Wrapped into `GroupError` via the `#[from]` impl on
404/// `GroupError::AppDataCommit` so the structured source is preserved at
405/// the call site (no string-flattening). The `pub(crate)` visibility
406/// matches the helper itself; the variant is only re-exported through
407/// the public `GroupError` enum.
408#[derive(Debug, thiserror::Error)]
409pub enum GroupAppDataError<StorageError: std::error::Error> {
410    /// `propose_app_data_update(…)` failed when staging the standalone
411    /// proposal that precedes the commit.
412    #[error("propose error: {0}")]
413    Propose(#[from] ProposalError<StorageError>),
414    /// `commit_builder().load_psks(…).build(…)` failed.
415    #[error("commit create error: {0}")]
416    CreateCommit(#[from] openmls::group::CreateCommitError),
417    /// `stage_commit(provider)` failed (storage / signature / staging error).
418    #[error("commit stage error: {0}")]
419    StageCommit(#[from] openmls::group::CommitBuilderStageError<StorageError>),
420    /// `apply_app_data_update_payload` failed while pre-computing the new
421    /// dict value the commit builder hands to OpenMLS. The most common
422    /// cause is a mismatch between the sender's idea of the current dict
423    /// state and the receiver's, which would surface as a confirmation
424    /// tag mismatch on the wire if it ever escaped.
425    #[error("apply payload error: {0}")]
426    ApplyPayload(#[from] self::component_source::ComponentSourceError),
427}
428
429// Specialize to the concrete SqlKeyStoreError because that's the only
430// storage instantiation used (see `GroupError::AppDataCommit` at
431// error.rs). It also lets us delegate to `RetryableError<Mls>` impls
432// already defined in `xmtp_db::errors` for the inner OpenMLS error
433// types — sibling pattern to `GroupError::Proposal(e) => e.is_retryable()`
434// — so SQLite-busy storage faults retry instead of permanently failing
435// the intent.
436impl xmtp_common::RetryableError for GroupAppDataError<xmtp_db::sql_key_store::SqlKeyStoreError> {
437    fn is_retryable(&self) -> bool {
438        match self {
439            // Delegate to the inner OpenMLS error's retryability so
440            // SQLite-busy storage faults during propose / stage retry
441            // rather than permanently fail the intent. The matching
442            // upstream impls live in `xmtp_db::errors`
443            // (`RetryableError<Mls>` for `ProposalError` /
444            // `CommitBuilderStageError`).
445            Self::Propose(e) => xmtp_common::retryable!(e),
446            Self::StageCommit(e) => xmtp_common::retryable!(e),
447            // Deterministic shape / staging-precondition failures —
448            // CreateCommit is upstream-`false`, and ApplyPayload is a
449            // sender-side encode failure that won't get better on
450            // retry.
451            Self::CreateCommit(_) | Self::ApplyPayload(_) => false,
452        }
453    }
454}
455
456/// Compute the [`AppDataUpdates`] required to commit any pending
457/// AppDataUpdate proposals in the group's proposal store.
458///
459/// Walks the proposal store and threads each `Update` / `Remove` through
460/// [`accumulate_app_data_updates`]. The result is what callers pass to
461/// [`CommitBuilder::with_app_data_dictionary_updates`] when committing
462/// pending proposals locally.
463///
464/// Returns `Ok(None)` when there are no AppDataUpdate proposals pending —
465/// this is the common case and lets the caller skip the `with_…` plumbing
466/// entirely without changing semantics.
467pub(crate) fn pending_app_data_updates(
468    mls_group: &OpenMlsGroup,
469) -> Result<Option<AppDataUpdates>, ComponentSourceError> {
470    let iter = mls_group
471        .pending_proposals()
472        .filter_map(|queued| match queued.proposal() {
473            Proposal::AppDataUpdate(app_data) => {
474                Some((app_data.component_id(), app_data.operation()))
475            }
476            _ => None,
477        });
478    accumulate_app_data_updates(mls_group, iter)
479}
480
481/// True when the group has completed the bootstrap migration from
482/// legacy GCE extensions to the AppData dictionary.
483///
484/// The discriminator is "does the dict have a `COMPONENT_REGISTRY`
485/// entry?" — bootstrap writes that entry as its first proposal, so
486/// its presence is the ground-truth marker that the group has been
487/// migrated.
488///
489/// This is intentionally distinct from [`MlsGroup::proposals_enabled`]:
490/// a group can have `proposals_enabled == true` without having yet
491/// completed its bootstrap commit. Read accessors key off this helper
492/// instead so they correctly fall back to the legacy GMM extension on
493/// proposals-enabled-but-unbootstrapped groups.
494pub(crate) fn is_migrated_group(mls_group: &OpenMlsGroup) -> bool {
495    is_migrated_extensions(mls_group.extensions())
496}
497
498/// Extensions-only variant of [`is_migrated_group`]. Kept in sync so
499/// every read-path gate lands on the same predicate (COMPONENT_REGISTRY
500/// present in the AppData dict) — consumers that only have an
501/// `Extensions` reference (e.g. commit-validation paths walking
502/// staged-commit extensions) can call this directly without
503/// materializing an `OpenMlsGroup`.
504///
505/// Test-only override: when a test harness has installed a
506/// [`TEST_REGISTRY_OVERRIDE`] scope the group is treated as migrated
507/// regardless of what the dict contains. This bridges the gap for
508/// tests that exercise post-bootstrap reader semantics without
509/// actually running the bootstrap commit (`enable_proposals()` end to
510/// end, which writes the real `COMPONENT_REGISTRY` entry). Production
511/// paths never hit this branch because the task-local is only
512/// initialized inside test scopes.
513pub(crate) fn is_migrated_extensions(
514    extensions: &openmls::extensions::Extensions<openmls::group::GroupContext>,
515) -> bool {
516    // Test-only override: treat the group as migrated when a
517    // [`TEST_REGISTRY_OVERRIDE`] scope is active *and* the dict has
518    // any entry — i.e. at least one post-capability AppDataUpdate has
519    // written something. The dict-has-any-entry clause matters so that
520    // pre-`enable_proposals()` test steps (which write via the legacy
521    // path and leave the dict empty) still see legacy-authoritative
522    // semantics.
523    #[cfg(any(test, feature = "test-utils"))]
524    if TEST_REGISTRY_OVERRIDE.try_with(|_| ()).is_ok() {
525        let has_any_entry = extensions
526            .app_data_dictionary()
527            .map(|ext| !ext.dictionary().is_empty())
528            .unwrap_or(false);
529        if has_any_entry {
530            return true;
531        }
532    }
533    extensions
534        .app_data_dictionary()
535        .map(|ext| {
536            ext.dictionary()
537                .contains(&ComponentId::COMPONENT_REGISTRY.as_u16())
538        })
539        .unwrap_or(false)
540}
541
542/// Load the [`ComponentRegistry`] for a group.
543///
544/// On a migrated group the registry lives in the AppData dict under
545/// [`ComponentId::COMPONENT_REGISTRY`]; on unmigrated groups it
546/// returns an empty registry (or the test override, when present —
547/// see [`TEST_REGISTRY_OVERRIDE`]).
548///
549/// Returns an error when a `COMPONENT_REGISTRY` entry is present in
550/// the dict but its bytes don't decode — silently swallowing that into
551/// an empty registry would let [`is_migrated_extensions`] (which only
552/// checks key existence) and this loader disagree about whether the
553/// group is migrated, and downstream readers built on an empty
554/// registry would silently lose every dict-backed component on the
555/// migrated path. Surfacing as
556/// [`ComponentSourceError::MalformedComponentValue`] keeps the
557/// wire-format-violation signal loud and reuses the same variant the
558/// rest of the dict-decode helpers already reach for.
559///
560/// ## Security model while the registry is empty (pre-bootstrap)
561///
562/// Empty registry is the **strictest** validator state, not the most
563/// permissive. Two layers make this safe:
564///
565/// 1. **Sender gate** (`mls_sync.rs`): the `AppDataUpdate` sender
566///    paths are guarded by [`is_migrated_group`] (`COMPONENT_REGISTRY`
567///    present in the dict). That's false on unmigrated groups, so the
568///    legacy GCE path runs and no `AppDataUpdate` proposals get
569///    emitted.
570///    (`test_update_group_name_uses_legacy_path_when_proposals_disabled`
571///    pins this.)
572/// 2. **Receiver deny-by-default**
573///    (`xmtp_mls_common::app_data::validation::validate_component_write`):
574///    any `AppDataUpdate` whose component has no registry entry is
575///    rejected with `ComponentPermissionError::NoRegistryEntry`,
576///    surfacing as `CommitValidationError::InsufficientPermissions` in
577///    [`validate_app_data_update_proposals_in_commit`]. So even if a
578///    Byzantine peer crafts a commit carrying `AppDataUpdate`
579///    proposals, honest receivers reject it.
580///
581/// Hardcoded components (`COMPONENT_REGISTRY`, `SUPER_ADMIN_LIST`)
582/// bypass the registry lookup by design — they're super-admin-only in
583/// code — so the bootstrap commit (which writes `COMPONENT_REGISTRY`
584/// as its first proposal) can land even against an empty registry.
585///
586/// Test code can inject a populated registry by wrapping its body in
587/// `TEST_REGISTRY_OVERRIDE.scope(registry, async { … }).await`.
588pub(crate) fn load_component_registry(
589    mls_group: &OpenMlsGroup,
590) -> Result<ComponentRegistry, ComponentSourceError> {
591    load_component_registry_from_extensions(mls_group.extensions())
592}
593
594/// Returns the group's committed `MIN_SUPPORTED_PROTOCOL_VERSION` floor
595/// when it exceeds `own_version`, reading ONLY the pre-commit AppData
596/// dict — committed, already-validated state.
597///
598/// This is the shared trigger for the "pause, don't fork" guards on the
599/// receive paths ([`process_message_with_app_data`] before dispatch;
600/// `ValidatedCommit::from_staged_commit` before interpreting migrated
601/// group state). It is deliberately blind to any floor bump carried by
602/// the commit currently being processed: that proposal has not passed
603/// the super-admin policy check yet, and a pause triggered by
604/// unvalidated input would let any member freeze the group permanently.
605/// The commit that *raises* the floor pauses below-floor receivers
606/// through the post-policy check at the end of commit validation
607/// instead. Consequence for protocol evolution: a release introducing
608/// a new wire format must land the group-floor bump in a *strictly
609/// earlier* commit than the first commit using that format.
610///
611/// Lenient on malformed state (non-UTF-8 floor bytes, unparseable
612/// semver ⇒ `None`), mirroring `enforce_min_version_monotonicity`'s
613/// treatment of malformed priors: garbage must never brick the group.
614pub(crate) fn committed_floor_exceeding(
615    mls_group: &OpenMlsGroup,
616    own: &LibXMTPVersion,
617) -> Option<String> {
618    committed_floor_exceeding_in_extensions(mls_group.extensions(), own)
619}
620
621/// Extensions-only variant of [`committed_floor_exceeding`], split out
622/// (like [`load_component_registry_from_extensions`]) so unit tests can
623/// exercise the parse-and-compare logic without materializing an
624/// `OpenMlsGroup`.
625pub(crate) fn committed_floor_exceeding_in_extensions(
626    extensions: &openmls::extensions::Extensions<openmls::group::GroupContext>,
627    own: &LibXMTPVersion,
628) -> Option<String> {
629    let bytes = extensions
630        .app_data_dictionary()?
631        .dictionary()
632        .get(&ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION.as_u16())?
633        .to_vec();
634    let floor = String::from_utf8(bytes).ok()?;
635    let floor_version = LibXMTPVersion::parse(&floor).ok()?;
636    (floor_version > *own).then_some(floor)
637}
638
639/// Extensions-only variant of [`load_component_registry`]. Mirrors the
640/// [`is_migrated_group`] / [`is_migrated_extensions`] split so unit
641/// tests can exercise the registry-decode path without materializing
642/// an `OpenMlsGroup`.
643pub(crate) fn load_component_registry_from_extensions(
644    extensions: &openmls::extensions::Extensions<openmls::group::GroupContext>,
645) -> Result<ComponentRegistry, ComponentSourceError> {
646    // Post-migration: the registry lives in the AppData dict under
647    // `COMPONENT_REGISTRY`. A migrated group's dict always has this
648    // entry (the bootstrap commit seeds it before flipping
649    // proposals_enabled), so if we find it, it's authoritative.
650    if let Some(ext) = extensions.app_data_dictionary()
651        && let Some(bytes) = ext
652            .dictionary()
653            .get(&ComponentId::COMPONENT_REGISTRY.as_u16())
654    {
655        return ComponentRegistry::from_bytes(bytes)
656            .map_err(|e| ComponentSourceError::MalformedComponentValue {
657                component_id: ComponentId::COMPONENT_REGISTRY,
658                reason: format!("registry decode: {e}"),
659            })
660            .inspect(|reg| {
661                // Tolerated (preserved-but-invisible) entries mean the
662                // dict was written by a newer protocol version or
663                // carries a historical invalid entry. Writes to those
664                // components fall to deny-by-default; everything else
665                // validates normally. Loud so poisoned-registry
666                // incidents are diagnosable from logs.
667                let unrecognized: Vec<_> = reg.unrecognized_ids().collect();
668                if !unrecognized.is_empty() {
669                    tracing::warn!(
670                        ?unrecognized,
671                        "component registry contains unrecognized entries; \
672                         treating them as unregistered (deny-by-default)"
673                    );
674                }
675            });
676    }
677
678    // Pre-migration or test override.
679    #[cfg(any(test, feature = "test-utils"))]
680    if let Ok(reg) = TEST_REGISTRY_OVERRIDE.try_with(|r| r.clone()) {
681        return Ok(reg);
682    }
683    Ok(ComponentRegistry::new())
684}
685
686#[cfg(test)]
687mod tests {
688    //! Unit coverage for the migration-marker predicate —
689    //! [`is_migrated_extensions`]. These pin the three read-side
690    //! invariants:
691    //!   (a) registry empty / dict missing => legacy-authoritative,
692    //!   (b) overlay no-op on unmigrated groups (even if `TEST_REGISTRY_OVERRIDE`
693    //!       is set but the dict is empty),
694    //!   (c) `COMPONENT_REGISTRY` in dict => migrated
695    //!       (production signal, independent of any test override).
696    //!
697    //! Post-bootstrap reader-see-dict-values coverage lives as an
698    //! integration test in `groups/tests/test_proposals.rs` — see
699    //! `test_app_data_update_overlays_legacy_gmm_on_conflict` — because
700    //! it needs the full MLS commit pipeline.
701    use super::*;
702    use openmls::extensions::{
703        AppDataDictionary, AppDataDictionaryExtension, Extension, Extensions,
704    };
705
706    fn extensions_with_dict(
707        entries: &[(u16, Vec<u8>)],
708    ) -> Extensions<openmls::group::GroupContext> {
709        let mut dict = AppDataDictionary::new();
710        for (id, bytes) in entries {
711            let _ = dict.insert(*id, bytes.clone());
712        }
713        Extensions::from_vec(vec![Extension::AppDataDictionary(
714            AppDataDictionaryExtension::new(dict),
715        )])
716        .expect("AppDataDictionary is a valid GroupContext extension")
717    }
718
719    fn empty_extensions() -> Extensions<openmls::group::GroupContext> {
720        Extensions::from_vec(vec![]).expect("empty extensions are always valid")
721    }
722
723    /// Parse a semver string the way the production caller does (once, from
724    /// the client's own `pkg_version`). Panics on invalid input — matching
725    /// `VersionInfo`, which asserts its own version is valid at construction.
726    fn ver(s: &str) -> LibXMTPVersion {
727        LibXMTPVersion::parse(s).unwrap()
728    }
729
730    #[test]
731    fn unmigrated_without_override_is_not_migrated() {
732        // Invariant (a): no dict, no override → legacy authoritative.
733        assert!(!is_migrated_extensions(&empty_extensions()));
734        // Dict present but empty → still not migrated.
735        assert!(!is_migrated_extensions(&extensions_with_dict(&[])));
736    }
737
738    #[test]
739    fn dict_without_registry_entry_is_not_migrated() {
740        // Invariant (a) corollary: a dict entry for some *other*
741        // component isn't enough to flip the gate in production —
742        // only `COMPONENT_REGISTRY` counts.
743        let exts =
744            extensions_with_dict(&[(ComponentId::GROUP_NAME.as_u16(), b"Group Name".to_vec())]);
745        assert!(!is_migrated_extensions(&exts));
746    }
747
748    #[test]
749    fn dict_with_registry_entry_is_migrated() {
750        // Invariant (c): production signal. `COMPONENT_REGISTRY` in the
751        // dict => migrated, regardless of any test override.
752        let exts =
753            extensions_with_dict(&[(ComponentId::COMPONENT_REGISTRY.as_u16(), vec![0x01, 0x02])]);
754        assert!(is_migrated_extensions(&exts));
755    }
756
757    #[tokio::test]
758    async fn override_without_dict_entries_is_not_migrated() {
759        // Invariant (b): with `TEST_REGISTRY_OVERRIDE` set but the dict
760        // empty (i.e. the pre-`enable_proposals()` window of an
761        // integration test), the gate stays closed. This is what lets
762        // step-1 assertions in `test_app_data_update_overlays_legacy_gmm_on_conflict`
763        // still read the legacy GMM value instead of being shadowed by
764        // an empty-dict overlay.
765        let reg = ComponentRegistry::new();
766        TEST_REGISTRY_OVERRIDE
767            .scope(reg, async {
768                assert!(!is_migrated_extensions(&empty_extensions()));
769                assert!(!is_migrated_extensions(&extensions_with_dict(&[])));
770            })
771            .await;
772    }
773
774    #[tokio::test]
775    async fn override_with_dict_entry_flips_migrated_in_tests() {
776        // Complement to the above: once a test has written at least
777        // one component to the dict, the test-override branch flips
778        // the gate so subsequent reads route through the overlay.
779        let reg = ComponentRegistry::new();
780        TEST_REGISTRY_OVERRIDE
781            .scope(reg, async {
782                let exts = extensions_with_dict(&[(
783                    ComponentId::GROUP_NAME.as_u16(),
784                    b"Dict Name".to_vec(),
785                )]);
786                assert!(is_migrated_extensions(&exts));
787            })
788            .await;
789    }
790
791    // ========================================================================
792    // committed_floor_exceeding_in_extensions
793    // ========================================================================
794    //
795    // The shared trigger for the pause-before-parse guards. Two properties
796    // are load-bearing: (1) it fires strictly on floor > own — equal or
797    // lower floors must not pause; (2) it is lenient on garbage — malformed
798    // floor bytes must read as "no floor", never as an error that could
799    // wedge the group.
800
801    #[test]
802    fn floor_above_own_version_fires() {
803        let exts = extensions_with_dict(&[(
804            ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION.as_u16(),
805            b"2.0.0".to_vec(),
806        )]);
807        assert_eq!(
808            committed_floor_exceeding_in_extensions(&exts, &ver("1.11.0")),
809            Some("2.0.0".to_string())
810        );
811        // Prerelease floors order correctly under semver: 1.11.0-dev
812        // exceeds 1.10.0 but not 1.11.0.
813        let exts = extensions_with_dict(&[(
814            ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION.as_u16(),
815            b"1.11.0-dev".to_vec(),
816        )]);
817        assert_eq!(
818            committed_floor_exceeding_in_extensions(&exts, &ver("1.10.0")),
819            Some("1.11.0-dev".to_string())
820        );
821        assert_eq!(
822            committed_floor_exceeding_in_extensions(&exts, &ver("1.11.0")),
823            None
824        );
825    }
826
827    #[test]
828    fn floor_at_or_below_own_version_does_not_fire() {
829        let exts = extensions_with_dict(&[(
830            ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION.as_u16(),
831            b"1.11.0".to_vec(),
832        )]);
833        // Equal: not paused — the floor is inclusive.
834        assert_eq!(
835            committed_floor_exceeding_in_extensions(&exts, &ver("1.11.0")),
836            None
837        );
838        // Above: not paused.
839        assert_eq!(
840            committed_floor_exceeding_in_extensions(&exts, &ver("1.12.0")),
841            None
842        );
843    }
844
845    #[test]
846    fn missing_floor_or_dict_does_not_fire() {
847        assert_eq!(
848            committed_floor_exceeding_in_extensions(&empty_extensions(), &ver("1.11.0")),
849            None
850        );
851        assert_eq!(
852            committed_floor_exceeding_in_extensions(&extensions_with_dict(&[]), &ver("1.11.0")),
853            None
854        );
855        // Dict present with other components but no floor entry.
856        let exts = extensions_with_dict(&[(ComponentId::GROUP_NAME.as_u16(), b"name".to_vec())]);
857        assert_eq!(
858            committed_floor_exceeding_in_extensions(&exts, &ver("1.11.0")),
859            None
860        );
861    }
862
863    #[test]
864    fn malformed_floor_is_lenient() {
865        // Non-UTF-8 bytes → no floor, never an error.
866        let exts = extensions_with_dict(&[(
867            ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION.as_u16(),
868            vec![0xFF, 0xFE],
869        )]);
870        assert_eq!(
871            committed_floor_exceeding_in_extensions(&exts, &ver("1.11.0")),
872            None
873        );
874        // Unparseable floor semver → no floor.
875        let exts = extensions_with_dict(&[(
876            ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION.as_u16(),
877            b"not-a-version".to_vec(),
878        )]);
879        assert_eq!(
880            committed_floor_exceeding_in_extensions(&exts, &ver("1.11.0")),
881            None
882        );
883        // The client's own version can no longer be unparseable here: it is
884        // parsed once and asserted valid when `VersionInfo` is built, so this
885        // guard only ever compares against a valid `LibXMTPVersion`.
886    }
887
888    // ========================================================================
889    // load_component_registry_from_extensions
890    // ========================================================================
891    //
892    // These pin the contract that the migration-marker
893    // (`is_migrated_extensions`, key-existence) and the registry loader
894    // (`load_component_registry_from_extensions`, parseability) agree on
895    // exactly one shape of disagreement: malformed bytes surface as a
896    // hard `MalformedComponentValue` error rather than silently
897    // collapsing to an empty registry. An empty registry on a "migrated"
898    // group would cause downstream readers (mutable_metadata, validators)
899    // to silently lose every dict-backed component, so this invariant is
900    // load-bearing.
901
902    #[test]
903    fn load_registry_no_dict_returns_empty() {
904        let reg = load_component_registry_from_extensions(&empty_extensions()).unwrap();
905        assert!(reg.is_empty());
906    }
907
908    #[test]
909    fn load_registry_dict_without_entry_returns_empty() {
910        // Dict present but no COMPONENT_REGISTRY entry => pre-bootstrap.
911        // An entry under some *other* component id must not be confused
912        // for the registry payload.
913        let exts =
914            extensions_with_dict(&[(ComponentId::GROUP_NAME.as_u16(), b"Group Name".to_vec())]);
915        let reg = load_component_registry_from_extensions(&exts).unwrap();
916        assert!(reg.is_empty());
917    }
918
919    #[test]
920    fn load_registry_with_valid_bytes_round_trips() {
921        let original = ComponentRegistry::new();
922        let bytes = original.to_bytes().expect("empty registry serializes");
923        let exts = extensions_with_dict(&[(ComponentId::COMPONENT_REGISTRY.as_u16(), bytes)]);
924        let loaded = load_component_registry_from_extensions(&exts).unwrap();
925        assert_eq!(loaded, original);
926    }
927
928    #[test]
929    fn load_registry_with_malformed_bytes_surfaces_error() {
930        // Pin the "fail loud, never return empty" invariant: a
931        // malformed `COMPONENT_REGISTRY` value must surface as
932        // `MalformedComponentValue` so downstream readers don't carry
933        // on with a phantom empty registry against an
934        // `is_migrated_extensions == true` dict.
935        let exts = extensions_with_dict(&[(
936            ComponentId::COMPONENT_REGISTRY.as_u16(),
937            vec![0xff, 0xff, 0xff],
938        )]);
939        let err = load_component_registry_from_extensions(&exts).unwrap_err();
940        assert!(
941            matches!(
942                err,
943                ComponentSourceError::MalformedComponentValue { component_id, .. }
944                    if component_id == ComponentId::COMPONENT_REGISTRY
945            ),
946            "expected MalformedComponentValue for COMPONENT_REGISTRY, got: {err:?}"
947        );
948    }
949}