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 ®istry,
161 ),
162 Some(None) => {
163 apply_app_data_update_payload(xmtp_id, payload.as_slice(), None, ®istry)
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 ®istry,
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}