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_group_state::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
13pub(crate) mod membership_upkeep;
14pub mod migration;
15pub(crate) mod sender_intents;
16
17use std::collections::{BTreeMap, HashMap, HashSet, hash_map::Entry};
18
19use openmls::{
20 component::ComponentData,
21 extensions::AppDataDictionary,
22 framing::{MlsMessageOut, ProcessedMessage, ProtocolMessage},
23 group::{
24 AppDataUpdates, MlsGroup as OpenMlsGroup, ProcessMessageError, ProposalError,
25 QueuedProposal, ResolveAppDataCommitError,
26 },
27 messages::proposals::{AppDataUpdateOperation, Proposal},
28 // `CommitMessageBundle` lives in `prelude` because the natural path
29 // (`openmls::group::commit_builder`) is private to the openmls crate.
30 // Re-importing through prelude is the only public path.
31 prelude::{CommitMessageBundle, ProcessedMessageContent},
32 storage::OpenMlsProvider,
33};
34use xmtp_mls_group_state::app_data::{
35 component_id::ComponentId, component_registry::ComponentRegistry,
36};
37
38use crate::groups::GroupError;
39use xmtp_configuration::ApplicationComponentDefinition;
40use xmtp_cryptography::secret::{Zeroize, Zeroizing};
41use xmtp_mls_group_state::app_data::component_source::{
42 ComponentSourceError, apply_app_data_update_payload, read_from_app_data_dict,
43};
44use xmtp_mls_group_state::app_data::protocol_floor::committed_floor_exceeding;
45use xmtp_mls_group_state::inbox_id::InboxId;
46use xmtp_mls_group_state::libxmtp_version::LibXMTPVersion;
47use xmtp_mls_validation::commit::{
48 AppDataPostStates, AppDataUpdateInCommit, AppDataUpdateList, CommitRuleError,
49 app_data_update_proposer_leaf, extract_commit_participant, membership_inbox_ids,
50 read_committed_metadata, validate_app_data_update_sequence, validate_post_dictionary_size,
51 validate_post_registry,
52};
53
54#[cfg(any(test, feature = "test-utils"))]
55tokio::task_local! {
56 /// Test-only override returned by [`load_component_registry`].
57 /// Stored as a tokio task-local (rather than a thread-local) so the
58 /// scope survives task migration across worker threads under
59 /// `multi_thread` runtimes.
60 pub static TEST_REGISTRY_OVERRIDE: ComponentRegistry;
61}
62
63/// Error returned by [`process_message_with_app_data`].
64///
65/// Wraps both an OpenMLS [`ProcessMessageError`] (for the underlying
66/// `process_message` failure modes) and a [`ComponentSourceError`] (for
67/// failures that happen while we decode an incoming `AppDataUpdate`
68/// payload). Splitting them keeps "the message was bad in OpenMLS terms"
69/// distinct from "we couldn't decode an AppData payload" so callers can
70/// log / retry / surface them differently.
71#[derive(Debug, thiserror::Error)]
72pub enum ProcessMessageWithAppDataError<StorageError: std::error::Error> {
73 /// Standard OpenMLS processing failure (decryption, validation, …).
74 #[error(transparent)]
75 OpenMls(#[from] ProcessMessageError<StorageError>),
76 /// Failed to decode an incoming `AppDataUpdate` payload via
77 /// [`apply_app_data_update_payload`]. Almost always indicates a
78 /// malformed proposal from a peer (or a wire-format mismatch with a
79 /// future version we don't understand yet).
80 ///
81 /// **Not retryable.** Decode failures are deterministic over the
82 /// exact bytes on the wire, so retrying the same message will fail
83 /// the same way. `GroupMessageProcessingError::is_retryable` and
84 /// `commit_result` treat this as a terminal wire-format violation
85 /// (mapped to `CommitResult::Invalid`).
86 #[error("failed to decode incoming AppDataUpdate payload: {0}")]
87 AppDataDecode(#[from] ComponentSourceError),
88 /// The group's committed `MIN_SUPPORTED_PROTOCOL_VERSION` floor
89 /// exceeds this client's version. Surfaced *before* any
90 /// `AppDataUpdate` payload is dispatched, so a client below the
91 /// floor (most commonly after an app downgrade — pausing normally
92 /// happens at the floor-bump commit itself, but a downgraded
93 /// client never processed one) pauses the group instead of
94 /// rejecting a commit it cannot interpret. Rejecting here is what
95 /// forks a group: peers above the floor accept the commit and
96 /// advance without us.
97 ///
98 /// Converted to `CommitRuleError::ProtocolVersionTooLow` at
99 /// the `mls_sync` boundary so the existing pause machinery
100 /// (`set_group_paused`, held cursor, reprocess-on-upgrade) applies
101 /// unchanged.
102 #[error(
103 "group's minimum supported protocol version {min_version} exceeds this client's version {own_version}"
104 )]
105 ProtocolVersionTooLow {
106 min_version: String,
107 own_version: String,
108 },
109 /// Staging an app-data commit failed after we interpreted its
110 /// proposals (`OpenMlsGroup::resolve_app_data_commit`). Carries the
111 /// same staging failure modes a commit without AppDataUpdate
112 /// proposals would surface from `process_message` directly.
113 #[error("failed to stage app-data commit: {0}")]
114 ResolveAppDataCommit(#[from] ResolveAppDataCommitError),
115}
116
117/// Walk a stream of `(ComponentId, &AppDataUpdateOperation)` tuples and
118/// produce the resulting [`AppDataUpdates`] the commit builder / message
119/// processor wants.
120///
121/// Returns `Ok(None)` when the iterator yields no proposals.
122pub(crate) fn accumulate_app_data_updates<'a, I>(
123 mls_group: &OpenMlsGroup,
124 proposals: I,
125) -> Result<Option<AppDataUpdates>, ComponentSourceError>
126where
127 I: IntoIterator<Item = (openmls::component::ComponentId, &'a AppDataUpdateOperation)>,
128{
129 let mut batch = AppDataBatch::new(mls_group)?;
130 for (id, operation) in proposals {
131 batch.apply(id, operation)?;
132 }
133 Ok(batch.updates())
134}
135
136/// The dictionary a sequence of `AppDataUpdate` proposals produces, built
137/// one proposal at a time.
138///
139/// Per-component state (`Some(bytes)` for an Update, `None` for a Remove)
140/// lets two proposals for the same component chain: the second sees the
141/// first's effect as its old value. Every payload decodes against the
142/// pre-commit registry, as on the receive side, so a registry change in
143/// the same commit does not change how its other updates are read.
144///
145/// A state can be the commit-log signer's private key, so each is wiped on
146/// drop until [`Self::updates`] hands it to OpenMLS.
147pub(crate) struct AppDataBatch<'a> {
148 group: &'a OpenMlsGroup,
149 registry: ComponentRegistry,
150 states: BTreeMap<openmls::component::ComponentId, Option<Zeroizing<Vec<u8>>>>,
151}
152
153impl<'a> AppDataBatch<'a> {
154 pub(crate) fn new(group: &'a OpenMlsGroup) -> Result<Self, ComponentSourceError> {
155 Ok(Self {
156 group,
157 registry: load_component_registry(group)?,
158 states: BTreeMap::new(),
159 })
160 }
161
162 /// A batch holding the group's pending proposals, in the order a
163 /// commit that consumes the proposal store applies them.
164 pub(crate) fn pending(group: &'a OpenMlsGroup) -> Result<Self, ComponentSourceError> {
165 let mut batch = Self::new(group)?;
166 for queued in group.pending_proposals() {
167 if let Proposal::AppDataUpdate(proposal) = queued.proposal() {
168 batch.apply(proposal.component_id(), proposal.operation())?;
169 }
170 }
171 Ok(batch)
172 }
173
174 pub(crate) fn apply(
175 &mut self,
176 id: openmls::component::ComponentId,
177 operation: &AppDataUpdateOperation,
178 ) -> Result<(), ComponentSourceError> {
179 let value = match operation {
180 AppDataUpdateOperation::Update(payload) => {
181 let xmtp_id = ComponentId::from(id);
182 Some(
183 apply_app_data_update_payload(
184 xmtp_id,
185 payload.as_slice(),
186 self.state(xmtp_id),
187 &self.registry,
188 )
189 .inspect_err(|e| {
190 tracing::warn!(
191 component_id = %xmtp_id,
192 error = %e,
193 "Failed to apply AppDataUpdate payload"
194 );
195 })?,
196 )
197 }
198 // A Remove never consults the component's payload rules, so
199 // component-level Remove rejections (e.g. the whole-registry
200 // Remove ban) are enforced by commit validation, which both
201 // commit-processing paths run before applying.
202 AppDataUpdateOperation::Remove => None,
203 };
204 // The replaced state is wiped on drop.
205 self.states.insert(id, value);
206 Ok(())
207 }
208
209 /// The component's value after the proposals applied so far.
210 pub(crate) fn state(&self, id: ComponentId) -> Option<&[u8]> {
211 match self.states.get(&id.as_u16()) {
212 Some(value) => value.as_deref().map(Vec::as_slice),
213 None => read_from_app_data_dict(id, self.group),
214 }
215 }
216
217 /// Reject `post`, the states of proposals after the ones applied so far,
218 /// when receivers would reject the commit's dictionary total.
219 // implements: META-077
220 pub(crate) fn fits(&self, post: &AppDataPostStates) -> Result<(), CommitRuleError> {
221 let states = &self.states;
222 let earlier = states.iter().map(|(id, s)| (ComponentId::from(*id), s));
223 let earlier = earlier.filter(|(id, _)| !post.contains_key(id));
224 let committed = self.group.extensions().app_data_dictionary();
225 let post = earlier.chain(post.iter().map(|(id, s)| (*id, s)));
226 validate_post_dictionary_size(committed.map(|e| e.dictionary()), post)
227 }
228
229 /// The [`AppDataUpdates`] for the commit; `None` when no proposal was
230 /// applied.
231 pub(crate) fn updates(self) -> Option<AppDataUpdates> {
232 if self.states.is_empty() {
233 return None;
234 }
235 let mut updater = self.group.app_data_dictionary_updater();
236 for (id, value) in self.states {
237 match value {
238 // The bytes move to OpenMLS without a copy.
239 Some(mut bytes) => updater.set(ComponentData::from_parts(
240 id,
241 std::mem::take(&mut *bytes).into(),
242 )),
243 None => updater.remove(&id),
244 }
245 }
246 updater.changes()
247 }
248}
249
250/// Most pending non-membership `AppDataUpdate`s one commit references:
251/// about 34 KiB of references.
252const MAX_APP_DATA_PROPOSALS: usize = 1_000;
253
254/// The pending proposals one commit can carry, in store order, and the
255/// batch they produce.
256pub(crate) struct Committable<'a> {
257 pub(crate) batch: AppDataBatch<'a>,
258 pub(crate) proposals: Vec<&'a QueuedProposal>,
259}
260
261impl<'a> Committable<'a> {
262 /// The pending proposals a commit can carry: each reference once, as a
263 /// commit covers it, and each `AppDataUpdate` only if every receiver
264 /// accepts it after the proposals kept before it. Receivers check the
265 /// list rule ([`AppDataUpdateList`]), the proposer's policy against the
266 /// chained value and the post-commit membership
267 /// ([`validate_app_data_update_sequence`]), and the registry and total the
268 /// commit leaves ([`validate_post_registry`], [`validate_post_dictionary_size`]).
269 ///
270 /// Receivers judge a standalone proposal alone, against the committed
271 /// dictionary. Proposals accepted one by one can still make an invalid
272 /// commit together, such as two Inserts of one key, an Update and a
273 /// Remove of one component, or two super-admin removals that leave
274 /// none. Every member rejects a commit that carries them.
275 ///
276 /// Each kept prefix is a commit receivers accept, so a proposal is
277 /// judged without looking ahead: a later proposal that would repair it
278 /// may itself be omitted, and the intent's own operations follow.
279 ///
280 /// No client proposes a leaf Update, and receivers check its credential
281 /// and that it keeps the replaced leaf's signature key, so a commit never
282 /// carries one: the commit builder would refuse a leaf it cannot use.
283 ///
284 /// The post-commit membership comes from the kept proposals, so the
285 /// filter runs again over the kept ones until that membership stops
286 /// changing. Each further pass keeps fewer, so this ends.
287 // implements: GMOD-047
288 pub(crate) fn pending(group: &'a OpenMlsGroup) -> Result<Self, GroupError> {
289 let mut seen = HashSet::new();
290 let mut budget = MAX_APP_DATA_PROPOSALS;
291 let mut proposals: Vec<_> = group
292 .pending_proposals()
293 .filter(|queued| seen.insert(queued.proposal_reference_ref()))
294 // implements: GMOD-047
295 // Membership proposals depend on each other; keep them all.
296 // Keep the oldest other AppDataUpdates; the rest expire with the epoch.
297 .filter(|queued| match queued.proposal() {
298 Proposal::AppDataUpdate(update)
299 if update.component_id() != ComponentId::GROUP_MEMBERSHIP.as_u16() =>
300 {
301 let keep = budget > 0;
302 budget = budget.saturating_sub(1);
303 keep
304 }
305 _ => true,
306 })
307 .collect();
308 let mut membership = membership_inbox_ids(read_from_app_data_dict(
309 ComponentId::GROUP_MEMBERSHIP,
310 group,
311 ));
312 loop {
313 let committable = Self::keep(group, proposals, membership.as_ref())?;
314 let kept = membership_inbox_ids(committable.batch.state(ComponentId::GROUP_MEMBERSHIP));
315 if kept == membership {
316 return Ok(committable);
317 }
318 membership = kept;
319 proposals = committable.proposals;
320 }
321 }
322
323 /// Keep each of `proposals` a receiver accepts after the ones kept
324 /// before it, with `membership` after the commit.
325 fn keep(
326 group: &'a OpenMlsGroup,
327 proposals: Vec<&'a QueuedProposal>,
328 membership: Option<&HashSet<InboxId>>,
329 ) -> Result<Self, GroupError> {
330 let (immutable, mutable) = read_committed_metadata(group)?;
331 let mut batch = AppDataBatch::new(group)?;
332 let mut list = AppDataUpdateList::default();
333 let mut proposers = HashMap::new();
334 let mut kept = Vec::with_capacity(proposals.len());
335 for queued in proposals {
336 if matches!(queued.proposal(), Proposal::Update(_)) {
337 continue;
338 }
339 if let Proposal::AppDataUpdate(update) = queued.proposal() {
340 let id = ComponentId::from(update.component_id());
341 let operation = update.operation();
342 if !list.admits(id, operation, || {
343 read_from_app_data_dict(id, group).is_some()
344 }) {
345 continue;
346 }
347 let Ok(leaf) = app_data_update_proposer_leaf(queued.sender()) else {
348 continue;
349 };
350 let proposer = match proposers.entry(*leaf) {
351 Entry::Occupied(entry) => entry.into_mut(),
352 Entry::Vacant(entry) => {
353 match extract_commit_participant(leaf, group, &immutable, &mutable) {
354 Ok(proposer) => entry.insert(proposer),
355 Err(_) => continue,
356 }
357 }
358 };
359 let update = AppDataUpdateInCommit {
360 component_id: id,
361 operation,
362 actor: (&*proposer).into(),
363 proposer_inbox_id: &proposer.inbox_id,
364 };
365 let Ok(post) = validate_app_data_update_sequence(
366 [update],
367 |id| batch.state(id),
368 &batch.registry,
369 immutable.dm_members.as_ref(),
370 membership,
371 ) else {
372 continue;
373 };
374 if validate_post_registry(&post).is_err() || batch.fits(&post).is_err() {
375 continue;
376 }
377 list.push(id, operation);
378 batch
379 .states
380 .extend(post.into_iter().map(|(id, state)| (id.as_u16(), state)));
381 }
382 kept.push(queued);
383 }
384 Ok(Self {
385 batch,
386 proposals: kept,
387 })
388 }
389}
390
391/// AppDataUpdate-aware wrapper around [`OpenMlsGroup::process_message`].
392///
393/// `OpenMlsGroup::process_message` returns a commit covering
394/// `AppDataUpdate` proposals as
395/// [`ProcessedMessageContent::UnresolvedAppDataCommit`] — the application
396/// is required to interpret the proposals, compute the resulting
397/// [`AppDataUpdates`], and resume staging. This wrapper does that dance:
398///
399/// 1. `process_message` as usual.
400/// 2. On an unresolved app-data commit, hand its (already
401/// reference-resolved) `AppDataUpdate` proposals to
402/// [`accumulate_app_data_updates`] to compute the resulting
403/// [`AppDataUpdates`].
404/// 3. Call `resolve_app_data_commit` with those updates, staging the
405/// commit and yielding a regular `StagedCommitMessage`.
406///
407/// Callers replace `mls_group.process_message(provider, message)` with
408/// `process_message_with_app_data(mls_group, provider, message)` and get
409/// back the same `ProcessedMessage` they used to; the
410/// `UnresolvedAppDataCommit` variant never escapes this function.
411/// `own` is the client's parsed pkg_version (threaded from the caller's
412/// context rather than read from a constant so cross-version tests can
413/// override it).
414pub(crate) fn process_message_with_app_data<Provider: OpenMlsProvider>(
415 mls_group: &mut OpenMlsGroup,
416 provider: &Provider,
417 message: impl Into<ProtocolMessage>,
418 own: &LibXMTPVersion,
419) -> Result<ProcessedMessage, ProcessMessageWithAppDataError<Provider::StorageError>> {
420 let processed = mls_group.process_message(provider, message)?;
421
422 // PAUSE BEFORE PARSE: every commit on a below-floor group must pause
423 // (held cursor, `set_group_paused`), never process — above-floor
424 // peers accept it and advance, so rejecting instead of pausing forks
425 // the group. Checked here, *after* `process_message` authenticated
426 // the message, so the pause decision is never driven by
427 // unauthenticated framing bits a sender could spoof to freeze
428 // application-message processing on a below-floor group. For an
429 // `UnresolvedAppDataCommit` this runs before the proposals are
430 // interpreted below — the commit's app-data payloads may use wire
431 // formats introduced after this version. It reads ONLY the
432 // pre-commit dict — committed, already-validated state — and must
433 // never consider the commit's own proposals: a same-commit floor
434 // bump has not passed the super-admin policy check yet, and pausing
435 // on unvalidated input would let any member freeze the group for
436 // everyone. Application messages are unaffected; standalone
437 // proposals get the same floor-first hold in `mls_sync`'s
438 // `ProposalMessage` arm, which also keeps a below-floor client from
439 // ever advancing past a stored-by-peers proposal that a later commit
440 // references. (Staging a plain commit inside `process_message`
441 // interprets no app-data payloads; nothing is merged until
442 // `merge_staged_commit`.)
443 let is_commit = matches!(
444 processed.content(),
445 ProcessedMessageContent::StagedCommitMessage(_)
446 | ProcessedMessageContent::UnresolvedAppDataCommit(_)
447 );
448 if is_commit && let Some(min_version) = committed_floor_exceeding(mls_group, own) {
449 return Err(ProcessMessageWithAppDataError::ProtocolVersionTooLow {
450 min_version,
451 own_version: own.to_string(),
452 });
453 }
454
455 let unresolved = match processed.content() {
456 ProcessedMessageContent::UnresolvedAppDataCommit(unresolved) => unresolved,
457 _ => return Ok(processed),
458 };
459
460 // Collect owned (id, operation) tuples so the iterator doesn't keep
461 // `processed` borrowed — `resolve_app_data_commit` consumes it below.
462 // Proposals committed by reference are already resolved from the
463 // proposal store by `process_message`.
464 let collected: Vec<(openmls::component::ComponentId, AppDataUpdateOperation)> = unresolved
465 .app_data_update_proposals()
466 .map(|p| (p.component_id(), p.operation().clone()))
467 .collect();
468 let iter = collected.iter().map(|(id, op)| (*id, op));
469 let app_data_updates = accumulate_app_data_updates(mls_group, iter)?;
470
471 Ok(mls_group.resolve_app_data_commit(provider, processed, app_data_updates)?)
472}
473
474/// Stage a standalone `AppDataUpdate(Update)` proposal AND a follow-up
475/// commit that references it from the OpenMLS proposal store.
476///
477/// This is the shape XIP §1.5.2 / §3.4 prescribes for post-migration
478/// metadata updates: separate proposal and commit MLS messages, so the
479/// commit message carries only a `ProposalRef` (hash) rather than the
480/// AppDataUpdate payload bytes. Smaller commits, smaller proposal-
481/// processing hot paths, identical end state.
482///
483/// Returns `(proposal_msg, commit_bundle)`. The caller MUST publish
484/// `proposal_msg` and `commit_bundle.commit()` together in one
485/// `payloads_to_publish` batch (proposal first) so receivers see the
486/// proposal in the same network round trip before processing the
487/// commit that references it.
488///
489/// Call this inside `generate_prepared_commit` and an outer state transaction.
490/// Store the exact attempt and staged commit in that transaction.
491pub(crate) fn stage_app_data_propose_and_commit<Provider: OpenMlsProvider>(
492 mls_group: &mut OpenMlsGroup,
493 provider: &Provider,
494 signer: &impl openmls_traits::signatures::Signer,
495 catalogue: &[ApplicationComponentDefinition],
496 component_id: ComponentId,
497 payload: Vec<u8>,
498) -> Result<(MlsMessageOut, CommitMessageBundle), GroupAppDataError<Provider::StorageError>> {
499 let (mut proposals, bundle) = stage_app_data_proposals_and_commit(
500 mls_group,
501 provider,
502 signer,
503 catalogue,
504 vec![(component_id, AppDataUpdateOperation::Update(payload.into()))],
505 )?;
506 Ok((proposals.remove(0), bundle))
507}
508
509/// Propose every `updates` operation and stage one commit of them, using
510/// the same pre-commit state. The commit also carries this client's
511/// membership upkeep for `catalogue` inline.
512pub(crate) fn stage_app_data_proposals_and_commit<Provider: OpenMlsProvider>(
513 mls_group: &mut OpenMlsGroup,
514 provider: &Provider,
515 signer: &impl openmls_traits::signatures::Signer,
516 catalogue: &[ApplicationComponentDefinition],
517 updates: Vec<(ComponentId, AppDataUpdateOperation)>,
518) -> Result<(Vec<MlsMessageOut>, CommitMessageBundle), GroupAppDataError<Provider::StorageError>> {
519 // Lazy-batching: we deliberately do NOT block on pre-existing
520 // pending proposals. This helper queues a new `AppDataUpdate` then
521 // commits via `consume_proposal_store(true)`, sweeping whatever
522 // else is in the store — concurrent `AppDataUpdate`s (accumulated
523 // into the dict by step 2), membership `Add` / `Remove`, etc. — all
524 // into one commit, less what `omit_uncommittable_proposals` omitted.
525 // That's the design: minimize commit count, let the producers of
526 // those proposals decide if they need to force their own commit
527 // (because they want to send a message right now or grant access
528 // immediately). MLS guarantees consistent state
529 // convergence on the wire regardless of which commit body carries
530 // which proposal; the sender's intent ledger may carry less
531 // information than the on-wire commit, but the producer of each
532 // folded-in proposal already accepted that outcome by leaving it
533 // pending instead of issuing its own commit.
534 let mut proposals = Vec::with_capacity(updates.len());
535 for (component_id, operation) in updates {
536 let (proposal, _) = mls_group
537 .propose_app_data_update(provider, signer, component_id.as_u16(), operation)
538 .map_err(GroupAppDataError::Propose)?;
539 proposals.push(proposal);
540 }
541
542 // Step 2: registry reconciliation and cleanup after any swept removal,
543 // with the dict updates of every `AppDataUpdate` in the store (earlier
544 // intents' queued proposals included) followed by the inline upkeep, so
545 // the final dict bytes match what `process_message_with_app_data`
546 // produces on the receive side. If OpenMLS ever changes
547 // `consume_proposal_store(true)`'s sweep or `pending_proposals()`
548 // ordering, receivers reject the commit with `WrongConfirmationTag`; the
549 // AppDataUpdate E2E tests in `groups/tests/test_proposals.rs` catch that.
550 let (upkeep, app_data_updates) = membership_upkeep::membership_upkeep(mls_group, catalogue)
551 .map_err(|e| GroupAppDataError::Upkeep(Box::new(e)))?;
552
553 // Step 3: build a commit that consumes the proposal store. The
554 // just-queued proposals are encoded as `ProposalRef`s; only the upkeep
555 // is inline.
556 let mut stage = crate::groups::mls_ext::commit_without_group_info(
557 mls_group,
558 provider.storage(),
559 |builder| builder.consume_proposal_store(true).add_proposals(upkeep),
560 )?;
561 stage.with_app_data_dictionary_updates(app_data_updates);
562
563 let bundle = stage
564 .build(provider.rand(), provider.crypto(), signer, |_| true)?
565 .stage_commit(provider)?;
566
567 Ok((proposals, bundle))
568}
569
570/// Errors surfaced by [`stage_app_data_propose_and_commit`].
571///
572/// Wrapped into `GroupError` via the `#[from]` impl on
573/// `GroupError::AppDataCommit` so the structured source is preserved at
574/// the call site (no string-flattening). The `pub(crate)` visibility
575/// matches the helper itself; the variant is only re-exported through
576/// the public `GroupError` enum.
577#[derive(Debug, thiserror::Error)]
578pub enum GroupAppDataError<StorageError: std::error::Error> {
579 /// `propose_app_data_update(…)` failed when staging the standalone
580 /// proposal that precedes the commit.
581 #[error("propose error: {0}")]
582 Propose(#[from] ProposalError<StorageError>),
583 /// `commit_builder().load_psks(…).build(…)` failed.
584 #[error("commit create error: {0}")]
585 CreateCommit(#[from] openmls::group::CreateCommitError),
586 /// `stage_commit(provider)` failed (storage / signature / staging error).
587 #[error("commit stage error: {0}")]
588 StageCommit(#[from] openmls::group::CommitBuilderStageError<StorageError>),
589 /// `apply_app_data_update_payload` failed while pre-computing the new
590 /// dict value the commit builder hands to OpenMLS. The most common
591 /// cause is a mismatch between the sender's idea of the current dict
592 /// state and the receiver's, which would surface as a confirmation
593 /// tag mismatch on the wire if it ever escaped.
594 #[error("apply payload error: {0}")]
595 ApplyPayload(#[from] xmtp_mls_group_state::app_data::component_source::ComponentSourceError),
596 /// Computing the removed-member cleanup for a swept removal failed, or
597 /// the commit would grow the dictionary past its total.
598 #[error("membership upkeep error: {0}")]
599 Upkeep(#[source] Box<crate::groups::GroupError>),
600}
601
602// Specialize to the concrete SqlKeyStoreError because that's the only
603// storage instantiation used (see `GroupError::AppDataCommit` at
604// error.rs). It also lets us delegate to `RetryableError<Mls>` impls
605// already defined in `xmtp_db::errors` for the inner OpenMLS error
606// types — sibling pattern to `GroupError::Proposal(e) => e.is_retryable()`
607// — so SQLite-busy storage faults retry instead of permanently failing
608// the intent.
609impl xmtp_common::RetryableError for GroupAppDataError<xmtp_db::sql_key_store::SqlKeyStoreError> {
610 fn is_retryable(&self) -> bool {
611 match self {
612 // Delegate to the inner OpenMLS error's retryability so
613 // SQLite-busy storage faults during propose / stage retry
614 // rather than permanently fail the intent. The matching
615 // upstream impls live in `xmtp_db::errors`
616 // (`RetryableError<Mls>` for `ProposalError` /
617 // `CommitBuilderStageError`).
618 Self::Propose(e) => xmtp_common::retryable!(e),
619 Self::StageCommit(e) => xmtp_common::retryable!(e),
620 Self::Upkeep(e) => xmtp_common::retryable!(e),
621 // Deterministic shape / staging-precondition failures —
622 // CreateCommit is upstream-`false`, and ApplyPayload is a
623 // sender-side encode failure that won't get better on
624 // retry.
625 Self::CreateCommit(_) | Self::ApplyPayload(_) => false,
626 }
627 }
628}
629
630/// The dictionary a commit built now starts from: the committed one with
631/// the updates of the pending proposals it can carry applied (see
632/// [`Committable::pending`]).
633pub(crate) fn pending_dictionary(
634 mls_group: &OpenMlsGroup,
635) -> Result<PendingDictionary, GroupError> {
636 let mut dictionary = PendingDictionary(
637 mls_group
638 .extensions()
639 .app_data_dictionary()
640 .map(|extension| extension.dictionary().clone())
641 .unwrap_or_default(),
642 );
643 for (id, value) in Committable::pending(mls_group)?
644 .batch
645 .updates()
646 .into_iter()
647 .flatten()
648 {
649 let replaced = match value {
650 Some(value) => dictionary.0.insert(id, value),
651 None => dictionary.0.remove(&id),
652 };
653 wipe_component_value(replaced);
654 }
655 Ok(dictionary)
656}
657
658/// A copy of a group's AppData dictionary. Its `GROUP_ROOT_KEY` entry is the
659/// group root, and in groups older clients created its `COMMIT_LOG_SIGNER`
660/// entry is a private signing key, so every entry is wiped on drop.
661pub(crate) struct PendingDictionary(AppDataDictionary);
662
663impl std::ops::Deref for PendingDictionary {
664 type Target = AppDataDictionary;
665
666 fn deref(&self) -> &AppDataDictionary {
667 &self.0
668 }
669}
670
671impl Drop for PendingDictionary {
672 fn drop(&mut self) {
673 let ids: Vec<_> = self.0.entries().map(|entry| entry.id()).collect();
674 for id in ids {
675 wipe_component_value(self.0.remove(&id));
676 }
677 }
678}
679
680/// Wipe a component value taken out of an OpenMLS dictionary. The value
681/// converts into its own buffer without a copy.
682fn wipe_component_value(value: Option<impl Into<Vec<u8>>>) {
683 if let Some(value) = value {
684 value.into().zeroize();
685 }
686}
687
688/// Read the committed component registry from the dictionary.
689/// Missing registry entries reject component writes by default.
690pub(crate) fn load_component_registry(
691 mls_group: &OpenMlsGroup,
692) -> Result<ComponentRegistry, ComponentSourceError> {
693 load_component_registry_from_extensions(mls_group.extensions())
694}
695
696/// Read the component registry without loading an OpenMLS group.
697pub(crate) fn load_component_registry_from_extensions(
698 extensions: &openmls::extensions::Extensions<openmls::group::GroupContext>,
699) -> Result<ComponentRegistry, ComponentSourceError> {
700 // The committed dictionary is the source of truth for the registry.
701 if let Some(ext) = extensions.app_data_dictionary()
702 && let Some(bytes) = ext
703 .dictionary()
704 .get(&ComponentId::COMPONENT_REGISTRY.as_u16())
705 {
706 return ComponentRegistry::from_bytes(bytes)
707 .map_err(|e| ComponentSourceError::MalformedComponentValue {
708 component_id: ComponentId::COMPONENT_REGISTRY,
709 reason: format!("registry decode: {e}"),
710 })
711 .inspect(|reg| {
712 // Tolerated (preserved-but-invisible) entries mean the
713 // dict was written by a newer protocol version or
714 // carries a historical invalid entry. Writes to those
715 // components fall to deny-by-default; everything else
716 // validates normally. Loud so poisoned-registry
717 // incidents are diagnosable from logs.
718 let unrecognized: Vec<_> = reg.unrecognized_ids().collect();
719 if !unrecognized.is_empty() {
720 tracing::warn!(
721 ?unrecognized,
722 "component registry contains unrecognized entries; \
723 treating them as unregistered (deny-by-default)"
724 );
725 }
726 });
727 }
728
729 // Pre-migration or test override.
730 #[cfg(any(test, feature = "test-utils"))]
731 if let Ok(reg) = TEST_REGISTRY_OVERRIDE.try_with(|r| r.clone()) {
732 return Ok(reg);
733 }
734 Ok(ComponentRegistry::new())
735}
736
737#[cfg(test)]
738mod tests {
739 use super::*;
740 use openmls::extensions::{
741 AppDataDictionary, AppDataDictionaryExtension, Extension, Extensions,
742 };
743
744 fn extensions_with_dict(
745 entries: &[(u16, Vec<u8>)],
746 ) -> Extensions<openmls::group::GroupContext> {
747 let mut dict = AppDataDictionary::new();
748 for (id, bytes) in entries {
749 let _ = dict.insert(*id, bytes.clone());
750 }
751 Extensions::from_vec(vec![Extension::AppDataDictionary(
752 AppDataDictionaryExtension::new(dict),
753 )])
754 .expect("AppDataDictionary is a valid GroupContext extension")
755 }
756
757 fn empty_extensions() -> Extensions<openmls::group::GroupContext> {
758 Extensions::from_vec(vec![]).expect("empty extensions are always valid")
759 }
760
761 // ========================================================================
762 // load_component_registry_from_extensions
763 // ========================================================================
764 //
765 // These pin the contract that the migration-marker
766 // (registry entry presence) and the registry loader
767 // (`load_component_registry_from_extensions`, parseability) agree on
768 // exactly one shape of disagreement: malformed bytes surface as a
769 // hard `MalformedComponentValue` error rather than silently
770 // collapsing to an empty registry. An empty registry on a "migrated"
771 // group would cause downstream readers (mutable_metadata, validators)
772 // to silently lose every dict-backed component, so this invariant is
773 // load-bearing.
774
775 #[xmtp_common::test(unwrap_try = true)]
776 fn load_registry_no_dict_returns_empty() {
777 let reg = load_component_registry_from_extensions(&empty_extensions()).unwrap();
778 assert!(reg.is_empty());
779 }
780
781 #[xmtp_common::test(unwrap_try = true)]
782 fn load_registry_dict_without_entry_returns_empty() {
783 // Dict present but no COMPONENT_REGISTRY entry => pre-bootstrap.
784 // An entry under some *other* component id must not be confused
785 // for the registry payload.
786 let exts =
787 extensions_with_dict(&[(ComponentId::GROUP_NAME.as_u16(), b"Group Name".to_vec())]);
788 let reg = load_component_registry_from_extensions(&exts).unwrap();
789 assert!(reg.is_empty());
790 }
791
792 #[xmtp_common::test(unwrap_try = true)]
793 fn load_registry_with_valid_bytes_round_trips() {
794 let original = ComponentRegistry::new();
795 let bytes = original.to_bytes().expect("empty registry serializes");
796 let exts = extensions_with_dict(&[(ComponentId::COMPONENT_REGISTRY.as_u16(), bytes)]);
797 let loaded = load_component_registry_from_extensions(&exts).unwrap();
798 assert_eq!(loaded, original);
799 }
800
801 #[xmtp_common::test(unwrap_try = true)]
802 fn load_registry_with_malformed_bytes_surfaces_error() {
803 // Pin the "fail loud, never return empty" invariant: a
804 // malformed `COMPONENT_REGISTRY` value must surface as
805 // `MalformedComponentValue` so downstream readers don't carry
806 // on with a phantom empty registry against an
807 // dictionary with a registry entry.
808 let exts = extensions_with_dict(&[(
809 ComponentId::COMPONENT_REGISTRY.as_u16(),
810 vec![0xff, 0xff, 0xff],
811 )]);
812 let err = load_component_registry_from_extensions(&exts).unwrap_err();
813 assert!(
814 matches!(
815 err,
816 ComponentSourceError::MalformedComponentValue { component_id, .. }
817 if component_id == ComponentId::COMPONENT_REGISTRY
818 ),
819 "expected MalformedComponentValue for COMPONENT_REGISTRY, got: {err:?}"
820 );
821 }
822}