-
Notifications
You must be signed in to change notification settings - Fork 90
Expand file tree
/
Copy pathcomponent_source.rs
More file actions
2546 lines (2364 loc) · 103 KB
/
Copy pathcomponent_source.rs
File metadata and controls
2546 lines (2364 loc) · 103 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
//! Single source-of-truth for the per-`ComponentId` read/encode/apply logic.
//!
//! Centralizes everything the rest of the MLS pipeline needs to know about
//! a well-known `ComponentId`: its logical [`ComponentType`], where its
//! current bytes live (OpenMLS AppData dictionary vs. legacy group context
//! extensions), and how to encode/apply `AppDataUpdate` payloads.
//!
//! The module declaration is `pub` only so `ComponentSourceError` can
//! satisfy the `private_interfaces` lint on the public `GroupError` variant
//! it's embedded in. All helpers remain `pub(crate)`.
//!
//! ## Inbox-id encoding
//!
//! The legacy GMM extension stores inbox ids as 64-character hex strings.
//! Anything serialized through the new `AppDataUpdate` path uses the
//! versioned [`InboxId`] newtype instead — the legacy on-the-wire format
//! is left untouched for unmigrated groups.
//!
//! See [`xmtp_mls_common::inbox_id`] for the full wire-format contract;
//! the short version is `varint(version) || 32-byte payload`, with
//! version 0 producing a 33-byte encoding.
// `ComponentMutation`, `component_type`, and the standalone
// `expand_app_data_update_to_changes` entry point are scaffolding for
// the standalone proposal-by-reference flow (`IntentKind::ProposeAppDataUpdate`)
// described in XIP §1.5.2 / §3.4. They have unit-test coverage but no
// production caller yet — the inline path goes through
// `apply_app_data_update_payload` instead. `expect` (not `allow`) so the
// compiler trips this when standalone-propose wiring lands, and we
// either drop the attribute or trim whichever scaffolding the new path
// supersedes.
#![expect(dead_code)]
use openmls::{
extensions::Extensions,
group::{GroupContext, MlsGroup as OpenMlsGroup, StagedCommit},
messages::proposals::AppDataUpdateOperation,
};
use tls_codec::{Deserialize, Serialize};
use xmtp_mls_common::{
app_data::{
component_id::ComponentId,
component_registry::ComponentRegistry,
components::type_dispatch::{apply_update_payload_for_type, expand_to_changes_for_type},
registry_table::lookup_component,
typed::ComponentTypedError,
},
group_mutable_metadata::{
GroupMutableMetadata, GroupMutableMetadataError, MetadataField,
find_mutable_metadata_extension,
},
inbox_id::{InboxId, InboxIdError},
tls_map::TlsMapError,
tls_set::{TlsSet, TlsSetDelta, TlsSetError, TlsSetMutation},
};
use xmtp_proto::xmtp::mls::message_contents::ComponentType;
/// Errors surfaced by the component_source layer.
///
/// `pub` (rather than `pub(crate)`) because [`GroupError`] embeds it via
/// `#[from]` for the AppDataUpdate path; `pub(crate)` would trigger
/// `private_interfaces` warnings on the public `GroupError` variant.
///
/// [`GroupError`]: super::super::error::GroupError
#[derive(Debug, thiserror::Error)]
pub enum ComponentSourceError {
/// The component id is outside the well-known XMTP range.
#[error("unknown component {0}")]
UnknownComponent(ComponentId),
/// The component is known but its wiring hasn't been built yet.
#[error("component {0} wiring is not yet implemented")]
NotImplemented(ComponentId),
/// An `AppDataUpdate::Update` write was attempted against an immutable
/// component. Insert-once writes should be expressed as `Insert`, not
/// caught here.
#[error("component {0} is immutable and cannot be updated via AppDataUpdate")]
ImmutableUpdate(ComponentId),
/// The supplied [`ComponentMutation`] does not match the component type
/// of the component it targets (e.g. a `Bytes` mutation against
/// `ADMIN_LIST`).
#[error("mutation shape does not match component {0}")]
MismatchedMutation(ComponentId),
/// Failed to convert an inbox id string or byte slice into an
/// [`InboxId`]. Wraps [`InboxIdError`] — callers that need to
/// distinguish "not hex" from "wrong length" can match the inner
/// variant.
#[error("invalid inbox id: {0}")]
InvalidInboxId(#[from] InboxIdError),
/// A wire-format violation on a component value: the bytes stored in
/// the AppData dictionary for a known component don't decode under the
/// expected encoding (e.g. non-UTF-8 bytes for a `Bytes`-typed
/// metadata attribute, malformed `TlsSet` for a collection component).
#[error("malformed value for component {component_id}: {reason}")]
MalformedComponentValue {
/// The component whose stored bytes failed to decode.
component_id: ComponentId,
/// Human-readable reason — surface to logs, not user-facing.
reason: String,
},
/// A `MetadataUpdate` intent referenced a metadata field name that has
/// no corresponding `ComponentId`. Most commonly fires when a future
/// metadata field is added to one of the senders without also being
/// added to [`metadata_field_to_component_id`].
#[error("unknown metadata field name: {0}")]
UnknownMetadataField(String),
/// Failed to read, decode, or encode the legacy group mutable metadata
/// extension while servicing a component-source request.
#[error(transparent)]
GroupMutableMetadata(#[from] GroupMutableMetadataError),
/// A TLS-codec operation on a delta or stored collection value failed.
#[error("tls codec error: {0}")]
TlsCodec(#[from] tls_codec::Error),
/// A `TlsSet::apply_delta` call failed while synthesizing the new full
/// value of a collection component from an incoming delta.
#[error("tls set apply error: {0}")]
TlsSetApply(#[from] TlsSetError),
/// A `TlsMap::apply_delta` call failed while synthesizing the new full
/// value of a map component from an incoming delta.
#[error("tls map apply error: {0}")]
TlsMapApply(#[from] TlsMapError),
}
impl ComponentSourceError {
/// Best-effort `ComponentId` extraction for the variants that carry
/// one — so error-mapping shims can preserve structured context
/// across the crate boundary into
/// [`GroupMutableMetadataError::MalformedComponent`] without
/// stringifying.
pub(crate) fn component_id(&self) -> Option<ComponentId> {
match self {
Self::UnknownComponent(id)
| Self::NotImplemented(id)
| Self::ImmutableUpdate(id)
| Self::MismatchedMutation(id)
| Self::MalformedComponentValue {
component_id: id, ..
} => Some(*id),
_ => None,
}
}
}
impl From<ComponentTypedError> for ComponentSourceError {
/// Surface trait-layer errors at the dispatch boundary. The
/// dispatch layer adds `UnknownComponent` / `NotImplemented` /
/// `UnknownMetadataField` / `GroupMutableMetadata` for things the
/// trait can't see; the variants below are the trait's domain
/// and round-trip 1:1.
fn from(err: ComponentTypedError) -> Self {
match err {
ComponentTypedError::ImmutableUpdate(id) => Self::ImmutableUpdate(id),
ComponentTypedError::MismatchedMutation(id) => Self::MismatchedMutation(id),
ComponentTypedError::MalformedValue {
component_id,
reason,
} => Self::MalformedComponentValue {
component_id,
reason,
},
ComponentTypedError::InvalidInboxId(e) => Self::InvalidInboxId(e),
ComponentTypedError::TlsCodec(e) => Self::TlsCodec(e),
ComponentTypedError::TlsSetApply(e) => Self::TlsSetApply(e),
ComponentTypedError::TlsMapApply(e) => Self::TlsMapApply(e),
ComponentTypedError::UnspecifiedType(id) => Self::MalformedComponentValue {
component_id: id,
reason: "registered ComponentType is Unspecified".to_string(),
},
}
}
}
impl From<ComponentSourceError> for GroupMutableMetadataError {
/// Preserve structure where possible. If the source already wraps a
/// `GroupMutableMetadataError` (e.g. `MissingExtension` raised by the
/// legacy `TryFrom<&OpenMlsGroup>` path on an unmigrated group),
/// unwrap and return that inner variant verbatim so callers can
/// match on `MissingExtension` / `MissingMetadataField` / etc.
///
/// For every other variant, surface as `MalformedComponent` and
/// preserve the offending `component_id` when it's available so
/// downstream consumers (bindings, error-mapping) can match
/// structurally on it. Variants without one surface as
/// `component_id: None`; the display string stays the
/// authoritative diagnostic.
fn from(err: ComponentSourceError) -> Self {
if let ComponentSourceError::GroupMutableMetadata(inner) = err {
return inner;
}
let component_id = err.component_id();
GroupMutableMetadataError::MalformedComponent {
component_id,
reason: err.to_string(),
}
}
}
/// Describes a single, atomic mutation that a per-field intent handler wants
/// to apply to a component. The encoder picks the wire shape (single-element
/// [`TlsSetDelta`] for collections, passthrough for bytes components).
///
/// The wire format supports batching (`TlsSetDelta.mutations` is a
/// `Vec<TlsSetMutation<K>>`), but this enum intentionally models a single
/// atomic mutation per variant — admin-list updates today arrive as
/// single-action intents (`UpdateAdminListIntentData` carries one inbox
/// id and one action), and coalescing happens at the commit layer via
/// [`super::accumulate_app_data_updates`]. The migration PR that wires
/// admin-list paths through `AppDataUpdate` should reshape this into
/// batched variants (e.g. `InboxIdSetDelta { component_id, mutations }`)
/// so a single proposal can carry multiple set mutations.
#[derive(Debug, Clone)]
pub(crate) enum ComponentMutation<'a> {
/// A whole-value replacement for a `Bytes`-typed component.
Bytes {
component_id: ComponentId,
new_value: &'a [u8],
},
/// Add a single inbox id to the admin list.
AdminListAdd { inbox_id: &'a str },
/// Remove a single inbox id from the admin list.
AdminListRemove { inbox_id: &'a str },
/// Add a single inbox id to the super-admin list.
SuperAdminListAdd { inbox_id: &'a str },
/// Remove a single inbox id from the super-admin list.
SuperAdminListRemove { inbox_id: &'a str },
}
impl ComponentMutation<'_> {
/// The `ComponentId` that this mutation targets.
pub(crate) fn component_id(&self) -> ComponentId {
match self {
Self::Bytes { component_id, .. } => *component_id,
Self::AdminListAdd { .. } | Self::AdminListRemove { .. } => ComponentId::ADMIN_LIST,
Self::SuperAdminListAdd { .. } | Self::SuperAdminListRemove { .. } => {
ComponentId::SUPER_ADMIN_LIST
}
}
}
}
/// Hardcoded logical type of a well-known component. Returns `None` for
/// app-range components (`0xC000-0xFEFF`) and for any well-known id that
/// is not yet wired into this match.
pub(crate) fn component_type(id: ComponentId) -> Option<ComponentType> {
match id {
// Hardcoded registry / list components. ComponentRegistry itself is a
// TlsMap, but permissions are enforced in code — it never flows
// through this module.
ComponentId::COMPONENT_REGISTRY => Some(ComponentType::TlsMapBytesBytes),
ComponentId::SUPER_ADMIN_LIST => Some(ComponentType::TlsSetInboxId),
ComponentId::ADMIN_LIST => Some(ComponentType::TlsSetInboxId),
// GroupMembership — TlsMap<InboxId, bytes>
ComponentId::GROUP_MEMBERSHIP => Some(ComponentType::TlsMapInboxIdBytes),
// GroupMutableMetadata-backed string components.
ComponentId::GROUP_NAME
| ComponentId::GROUP_DESCRIPTION
| ComponentId::GROUP_IMAGE_URL
| ComponentId::APP_DATA
| ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION => Some(ComponentType::String),
// GroupMutableMetadata-backed bytes components.
ComponentId::MESSAGE_DISAPPEAR_FROM_NS
| ComponentId::MESSAGE_DISAPPEAR_IN_NS
| ComponentId::COMMIT_LOG_SIGNER => Some(ComponentType::Bytes),
// External-commit policy: proto-encoded ExternalCommitPolicyEntry,
// replaced atomically via the generic AppDataUpdate intent. No
// per-id Component impl needed; helpers decode bytes via prost.
ComponentId::EXTERNAL_COMMIT_POLICY => Some(ComponentType::Bytes),
// Immutable metadata (not flowable through AppDataUpdate writes,
// but we still advertise the type for completeness).
ComponentId::CONVERSATION_TYPE
| ComponentId::CREATOR_INBOX_ID
| ComponentId::ONESHOT_MESSAGE => Some(ComponentType::Bytes),
ComponentId::DM_MEMBERS => Some(ComponentType::TlsSetInboxId),
_ => None,
}
}
/// Single source of truth for the `MetadataField` ↔ `ComponentId` bijection
/// over the Bytes-typed mutable-metadata family. Both lookup helpers below
/// and `merge_app_data_into_mutable_metadata` derive from this table.
const METADATA_FIELD_COMPONENT_MAP: &[(MetadataField, ComponentId)] = &[
(MetadataField::GroupName, ComponentId::GROUP_NAME),
(MetadataField::Description, ComponentId::GROUP_DESCRIPTION),
(
MetadataField::GroupImageUrlSquare,
ComponentId::GROUP_IMAGE_URL,
),
(
MetadataField::MessageDisappearFromNS,
ComponentId::MESSAGE_DISAPPEAR_FROM_NS,
),
(
MetadataField::MessageDisappearInNS,
ComponentId::MESSAGE_DISAPPEAR_IN_NS,
),
(
MetadataField::MinimumSupportedProtocolVersion,
ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION,
),
(
MetadataField::CommitLogSigner,
ComponentId::COMMIT_LOG_SIGNER,
),
(MetadataField::AppData, ComponentId::APP_DATA),
];
/// Map a [`MetadataField`] string to its corresponding `ComponentId`.
///
/// Returns `None` for unknown field names so this can also be called with a
/// raw string coming from a legacy intent payload.
pub(crate) fn metadata_field_to_component_id(field_name: &str) -> Option<ComponentId> {
METADATA_FIELD_COMPONENT_MAP
.iter()
.find(|(field, _)| field.as_str() == field_name)
.map(|(_, id)| *id)
}
/// Map a `ComponentId` back to the `MetadataField` attribute name that stores
/// it in the legacy `GroupMutableMetadata` extension.
///
/// Returns `None` for component ids that are not backed by a
/// `GroupMutableMetadata` attribute (e.g. `ADMIN_LIST`, `GROUP_MEMBERSHIP`,
/// or anything outside the mutable metadata family).
pub(crate) fn component_id_to_metadata_field(id: ComponentId) -> Option<MetadataField> {
METADATA_FIELD_COMPONENT_MAP
.iter()
.find(|(_, component_id)| *component_id == id)
.map(|(field, _)| *field)
}
/// Read the component's current bytes from whichever storage the group's
/// capability flag indicates: the OpenMLS AppData dictionary when
/// `proposals_enabled` is on, otherwise the legacy group context
/// extensions (translated into the new app-data wire format on the fly).
pub(crate) fn read_component_bytes(
id: ComponentId,
mls_group: &OpenMlsGroup,
proposals_enabled: bool,
) -> Result<Option<Vec<u8>>, ComponentSourceError> {
if proposals_enabled {
Ok(read_from_app_data_dict(id, mls_group))
} else {
read_from_legacy(id, mls_group.extensions())
}
}
/// Compute the post-commit value of a single component on a migrated group
/// by overlaying the staged commit's `AppDataUpdate` proposals on top of
/// the pre-commit dict. Last-write-wins matches the lazy-batching apply
/// order in [`super::accumulate_app_data_updates`]: every `Update(payload)`
/// is decoded against the running value (so collection deltas compose),
/// and `Remove` collapses to `None`.
///
/// Returns `Ok(None)` when the component is absent both before and after
/// the commit, or when it was explicitly removed. Returns `Err` only when
/// an `Update` payload fails to decode against the running value — the
/// same condition `validate_app_data_update_proposals_in_commit` would
/// also reject upstream, so callers can treat decode failure here as
/// "validator will surface the real error" and short-circuit.
///
/// Used by the commit validator to evaluate per-component invariants
/// (notably `MIN_SUPPORTED_PROTOCOL_VERSION`) that need the post-commit
/// view on migrated groups, where the legacy `GroupMutableMetadata`
/// extension diff that drives the same check on unmigrated groups is
/// unavailable.
///
/// # Registry semantics
///
/// `registry` is the **pre-commit** `COMPONENT_REGISTRY` (i.e. the state
/// of the dictionary entry before the staged commit applies). Callers
/// should load it once via [`super::load_component_registry`] on the
/// live `mls_group` and reuse it across all validator helpers — same
/// registry feeds [`super::validate_app_data_update_proposals_in_commit`]
/// and any other per-component checks.
///
/// **Implication for commits that modify `COMPONENT_REGISTRY` in the
/// same commit as a write to a newly-registered component**: such a
/// write fails with `UnknownComponent` here because the new entry is
/// not yet visible in the pre-commit registry. This matches what the
/// receiver-side validator
/// ([`super::validate_app_data_update_proposals_in_commit`]) enforces
/// today and is the documented convention across the migrated commit
/// path: registry mutations and writes that depend on those mutations
/// MUST land in separate commits.
///
/// The bootstrap commit is the only legitimate "register + write in
/// the same commit" pattern and is routed through a dedicated
/// validator ([`super::bootstrap_validator::validate_bootstrap_commit`])
/// that does not flow through this function.
pub(crate) fn read_post_commit_component_bytes(
id: ComponentId,
mls_group: &OpenMlsGroup,
staged_commit: &StagedCommit,
registry: &ComponentRegistry,
) -> Result<Option<Vec<u8>>, ComponentSourceError> {
let openmls_id: openmls::component::ComponentId = id.as_u16();
// Owned snapshot of operations targeting this specific component.
// Iterating `app_data_update_proposals()` yields short-lived
// `QueuedAppDataUpdateProposal` views that borrow into the staged
// commit — we can't hold their byte slices across iterations, so
// we materialize an owned form up front. `Update` payloads are
// typically tiny (version strings, single-key deltas), so the
// clone cost is negligible.
enum Op {
Update(Vec<u8>),
Remove,
}
let ops: Vec<Op> = staged_commit
.app_data_update_proposals()
.filter_map(|queued| {
let proposal = queued.app_data_update_proposal();
if proposal.component_id() != openmls_id {
return None;
}
Some(match proposal.operation() {
AppDataUpdateOperation::Update(payload) => Op::Update(payload.as_slice().to_vec()),
AppDataUpdateOperation::Remove => Op::Remove,
})
})
.collect();
if ops.is_empty() {
return Ok(read_from_app_data_dict(id, mls_group));
}
let mut current = read_from_app_data_dict(id, mls_group);
for op in &ops {
match op {
Op::Update(payload) => {
current = Some(apply_app_data_update_payload(
id,
payload,
current.as_deref(),
registry,
)?);
}
Op::Remove => current = None,
}
}
Ok(current)
}
/// Look up the component's bytes in the OpenMLS AppData dictionary.
///
/// `pub(crate)` so the commit validator (`validated_commit.rs`) can
/// pull the pre-commit stored bytes for a component and thread them
/// into [`expand_app_data_update_to_changes`] as `old_value` — the
/// validator uses that to resolve `RemoveByHash` mutations back to
/// their underlying inbox id. The parent `app_data` module also uses
/// it from `process_message_with_app_data`, `stage_app_data_propose_and_commit`,
/// and `pending_app_data_updates`.
pub(crate) fn read_from_app_data_dict(
id: ComponentId,
mls_group: &OpenMlsGroup,
) -> Option<Vec<u8>> {
let openmls_id: openmls::component::ComponentId = id.as_u16();
mls_group
.extensions()
.app_data_dictionary()
.and_then(|ext| ext.dictionary().get(&openmls_id))
.map(|bytes| bytes.to_vec())
}
/// Look up the component's bytes in the legacy group-context extensions and
/// translate them into the new app-data wire format.
///
/// For `GroupMutableMetadata`-backed bytes components this returns the
/// attribute's UTF-8 bytes. For `ADMIN_LIST` / `SUPER_ADMIN_LIST` it
/// re-encodes the legacy `Vec<String>` of hex inbox ids as a
/// `TlsSet<InboxId>`.
///
/// `GROUP_MEMBERSHIP` is intentionally unsupported here and returns
/// [`ComponentSourceError::NotImplemented`]: unmigrated groups read
/// membership via the dedicated `GROUP_MEMBERSHIP_EXTENSION_ID`
/// GroupContext extension (see [`extract_group_membership`]), not as
/// an AppData component. Migrated groups use the dict directly.
///
/// [`extract_group_membership`]: crate::groups::group_membership::extract_group_membership
fn read_from_legacy(
id: ComponentId,
extensions: &Extensions<GroupContext>,
) -> Result<Option<Vec<u8>>, ComponentSourceError> {
// Mutable-metadata-backed bytes components: pull the attribute out of
// the GMM extension. Missing extension → None; missing attribute → None.
if let Some(field) = component_id_to_metadata_field(id) {
let gmm = match find_mutable_metadata_extension(extensions) {
Some(bytes) => GroupMutableMetadata::try_from(bytes)?,
None => return Ok(None),
};
return Ok(gmm
.attributes
.get(field.as_str())
.map(|s| s.as_bytes().to_vec()));
}
match id {
ComponentId::ADMIN_LIST => {
let gmm = match find_mutable_metadata_extension(extensions) {
Some(bytes) => GroupMutableMetadata::try_from(bytes)?,
None => return Ok(None),
};
Ok(Some(encode_inbox_id_set(&gmm.admin_list)?))
}
ComponentId::SUPER_ADMIN_LIST => {
let gmm = match find_mutable_metadata_extension(extensions) {
Some(bytes) => GroupMutableMetadata::try_from(bytes)?,
None => return Ok(None),
};
Ok(Some(encode_inbox_id_set(&gmm.super_admin_list)?))
}
ComponentId::GROUP_MEMBERSHIP => Err(ComponentSourceError::NotImplemented(id)),
_ => Err(ComponentSourceError::UnknownComponent(id)),
}
}
/// Encode a [`ComponentMutation`] into the bytes that go inside an
/// `AppDataUpdateOperation::Update(bytes)` payload on the wire.
///
/// - `Bytes` components pass through verbatim.
/// - `AdminList*` / `SuperAdminList*` produce a single-element
/// [`TlsSetDelta`] keyed on an [`InboxId`].
pub(crate) fn encode_app_data_update_payload(
mutation: &ComponentMutation<'_>,
) -> Result<Vec<u8>, ComponentSourceError> {
match mutation {
ComponentMutation::Bytes {
component_id,
new_value,
} => {
// Phase-1 bytes components only cover the GMM-attribute family.
if component_id_to_metadata_field(*component_id).is_none() {
return Err(ComponentSourceError::MismatchedMutation(*component_id));
}
Ok(new_value.to_vec())
}
ComponentMutation::AdminListAdd { inbox_id }
| ComponentMutation::SuperAdminListAdd { inbox_id } => {
let key = inbox_id_str_to_bytes(inbox_id)?;
encode_inbox_id_set_delta(TlsSetMutation::Insert(key))
}
ComponentMutation::AdminListRemove { inbox_id }
| ComponentMutation::SuperAdminListRemove { inbox_id } => {
let key = inbox_id_str_to_bytes(inbox_id)?;
encode_inbox_id_set_delta(TlsSetMutation::Remove(key))
}
}
}
// `ExpandedComponentChange` lives in `xmtp_mls_common::app_data::typed`
// so the `Component` trait there can return it. Re-exported here so
// in-crate callers can construct the change list without pulling the
// xmtp_mls_common path in directly.
pub(crate) use xmtp_mls_common::app_data::typed::ExpandedComponentChange;
/// Expand an `AppDataUpdate` proposal payload into the per-element changes
/// that should be checked against the component registry.
///
/// - `Bytes` components: returns a single `Update` change with the new
/// payload bytes.
/// - Collection components (`ADMIN_LIST` / `SUPER_ADMIN_LIST`): parses the
/// payload as a `TlsSetDelta<InboxId>` and emits one entry per
/// mutation, with `op = Insert` for `Insert`, `op = Delete` for
/// `Remove` / `RemoveByHash`.
/// - `AppDataUpdateOperation::Remove` (any component): a single
/// `Delete` entry with no value.
///
/// `old_value` is the component's pre-commit stored bytes (from the
/// AppData dictionary). It's only consulted for `RemoveByHash`
/// resolution on collection components: given the prior `TlsSet<InboxId>`,
/// we build a `hash → InboxId` index and resolve each `RemoveByHash` back
/// to the concrete key being removed so the validator sees the inbox id
/// the peer is targeting. If the hash doesn't match any prior key (or
/// `old_value` is `None`), the expansion surfaces `value: None` and the
/// subsequent CRDT apply step surfaces the real error.
///
/// Used on the receiver side to feed `validate_component_write` for each
/// distinct change inside a single `AppDataUpdate` proposal.
///
/// The steady-state validator dispatches through `lookup_component`
/// directly so it can also call `Component::validate_invariant`
/// without a second binary search. This wrapper is retained for
/// callers that don't need the invariant hook.
pub(crate) fn expand_app_data_update_to_changes(
component_id: ComponentId,
operation: &AppDataUpdateOperation,
old_value: Option<&[u8]>,
registry: &ComponentRegistry,
) -> Result<Vec<ExpandedComponentChange>, ComponentSourceError> {
if let Some(component) = lookup_component(component_id) {
return component
.expand_to_changes(operation, old_value)
.map_err(Into::into);
}
// No per-id [`Component`] impl on this client. Two type-resolution
// sources, tried in order:
//
// 1. In-code [`component_type`] mapping — covers well-known XMTP
// ids whose type is known to this release but which have no
// typed decoder (e.g. the immutable seeds CREATOR_INBOX_ID,
// ONESHOT_MESSAGE — handled by bootstrap byte-compare, not by a
// `Component` impl).
// 2. On-dict [`ComponentRegistry`] entry — covers components a
// *newer* release ships that this client has never heard of;
// the registry's `component_type` tag is the type oracle.
//
// Either way, the closed type universe (6 variants) means every
// shape — including `TlsSet` / `TlsMap` deltas — surfaces a proper
// per-element change list to the validator. Old and new clients
// converge on the same dict state for the same wire bytes.
let ty = component_type(component_id)
.map_or_else(|| registered_component_type(component_id, registry), Ok)?;
expand_to_changes_for_type(component_id, ty, operation, old_value).map_err(Into::into)
}
/// Decode an incoming `AppDataUpdateOperation::Update(bytes)` payload
/// and produce the new full bytes of the component, given the prior
/// stored bytes (if any). `Update`-only — `Remove` carries no payload
/// and is handled directly by the caller.
///
/// Immutable components are rejected with
/// [`ComponentSourceError::ImmutableUpdate`] **only when a prior
/// value already exists** — the bootstrap commit is the canonical
/// first-insert path for immutable seeds, so this layer must allow
/// an `Update` whose `old_value` is `None`. The bootstrap validator
/// catches malicious initial values upstream via byte-compare.
pub(crate) fn apply_app_data_update_payload(
id: ComponentId,
payload: &[u8],
old_value: Option<&[u8]>,
registry: &ComponentRegistry,
) -> Result<Vec<u8>, ComponentSourceError> {
// Immutability gate. Reject only on overwrite — a fresh insert
// (no prior value) is the bootstrap commit's first write of an
// immutable seed and must succeed for honest receivers to reach
// the migrated state. Steady-state immutables always have a prior
// (inserted at bootstrap), so a Byzantine peer trying to mutate
// them post-bootstrap still hits this branch and gets rejected.
if id.is_immutable() && old_value.is_some() {
return Err(ComponentSourceError::ImmutableUpdate(id));
}
// Per-id `Component` impl on this client — handles all 13 well-
// known mutable components with a typed decoder.
if let Some(component) = lookup_component(id) {
return component
.apply_update_payload(payload, old_value)
.map_err(Into::into);
}
// Two type-resolution sources for components without a per-id
// impl, tried in order:
//
// 1. In-code [`component_type`] mapping — covers well-known XMTP
// ids whose type is known but which have no typed decoder
// (immutable seeds like CREATOR_INBOX_ID — bootstrap-only
// first-write path).
// 2. On-dict [`ComponentRegistry`] entry — covers components a
// *newer* release ships that this client has never heard of;
// the registry's `component_type` tag is the type oracle.
let ty = component_type(id).map_or_else(|| registered_component_type(id, registry), Ok)?;
apply_update_payload_for_type(id, ty, payload, old_value).map_err(Into::into)
}
/// Look up the [`ComponentType`] registered for a component id in the
/// on-dict [`ComponentRegistry`]. Returns
/// [`ComponentSourceError::UnknownComponent`] when no registry entry
/// exists — the deny-by-default rule that keeps unrecognized payloads
/// from being applied opaquely.
fn registered_component_type(
id: ComponentId,
registry: &ComponentRegistry,
) -> Result<ComponentType, ComponentSourceError> {
let meta = registry
.get(&id)
.map_err(|e| ComponentSourceError::MalformedComponentValue {
component_id: id,
reason: format!("registry lookup: {e}"),
})?
.ok_or(ComponentSourceError::UnknownComponent(id))?;
ComponentType::try_from(meta.component_type).map_err(|_| {
ComponentSourceError::MalformedComponentValue {
component_id: id,
reason: format!(
"registry entry has unknown component_type tag {}",
meta.component_type
),
}
})
}
/// Overlay AppData-dict component values onto a base [`GroupMutableMetadata`]
/// read from the legacy extension. On migrated groups the dict is
/// authoritative; for unmigrated components the legacy GMM stays as the
/// fallback, so callers always get a complete view.
///
/// Gated on [`super::is_migrated_group`] (defense-in-depth) so a stray
/// dict entry on a pre-bootstrap group can't shadow legacy GMM.
///
/// Wire formats (must match what the sender emits via
/// [`encode_app_data_update_payload`] / [`apply_app_data_update_payload`]):
/// - Bytes components: raw UTF-8 string bytes.
/// - `ADMIN_LIST` / `SUPER_ADMIN_LIST`: TLS-serialized `TlsSet<InboxId>`,
/// each id hex-encoded back to string form.
///
/// ## Independence from `COMPONENT_REGISTRY` parseability
///
/// This function reads metadata field entries directly from the dict and
/// **never** loads or validates the `COMPONENT_REGISTRY` payload — it
/// only uses [`super::is_migrated_extensions`] (key-existence check) as
/// the gate. So a malformed `COMPONENT_REGISTRY` blob does NOT cause
/// metadata reads to drop authoritative data: as long as the individual
/// metadata field bytes (`GROUP_NAME`, `ADMIN_LIST`, …) decode
/// correctly, they round-trip into the returned GMM. Registry corruption
/// is surfaced loudly on the *write* paths instead — the sender gate in
/// `mls_sync.rs` and the commit validator in `validated_commit.rs` both
/// call [`super::load_component_registry`] and propagate decode errors
/// — so a corrupt registry blocks state changes without making readable
/// data unreachable. See
/// `merge_with_malformed_registry_returns_valid_field` for the test
/// that pins this invariant.
pub(crate) fn merge_app_data_into_mutable_metadata(
base: &mut GroupMutableMetadata,
mls_group: &OpenMlsGroup,
) -> Result<(), ComponentSourceError> {
merge_app_data_into_mutable_metadata_from_extensions(base, mls_group.extensions())
}
/// Capability-aware [`GroupMutableMetadata`] extractor.
///
/// On migrated groups the legacy `GroupMutableMetadata` group context
/// extension is stripped by the bootstrap commit, so the static
/// [`xmtp_mls_common::group_mutable_metadata::extract_legacy_group_mutable_metadata`]
/// returns `MissingExtension` and any caller that swallows the error
/// with `.ok()` silently defaults every metadata field (notably:
/// disappearing-message settings and `MinimumSupportedProtocolVersion`
/// — the latter is what gates the XIP §3 pause-on-version-bump flow).
///
/// This helper returns the same `GroupMutableMetadata` shape but reads
/// from the right source per migration state:
///
/// - **Migrated** ([`super::is_migrated_group`] returns `true`): starts
/// from an empty composite and overlays every field from the AppData
/// dictionary via [`merge_app_data_into_mutable_metadata`].
/// - **Unmigrated**: parses the legacy GMM extension via
/// `GroupMutableMetadata::try_from(&OpenMlsGroup)`, matching the
/// legacy static helper byte-for-byte.
pub(crate) fn extract_group_mutable_metadata_capability_aware(
mls_group: &OpenMlsGroup,
) -> Result<GroupMutableMetadata, ComponentSourceError> {
if super::is_migrated_group(mls_group) {
let mut base =
GroupMutableMetadata::new(std::collections::HashMap::new(), Vec::new(), Vec::new());
merge_app_data_into_mutable_metadata(&mut base, mls_group)?;
Ok(base)
} else {
Ok(GroupMutableMetadata::try_from(mls_group)?)
}
}
/// Extensions-only variant of [`merge_app_data_into_mutable_metadata`].
/// Mirrors the [`super::is_migrated_group`] / [`super::is_migrated_extensions`]
/// and [`super::load_component_registry`] /
/// [`super::load_component_registry_from_extensions`] splits so unit
/// tests can pin the merge contract without materializing an
/// `OpenMlsGroup`.
pub(crate) fn merge_app_data_into_mutable_metadata_from_extensions(
base: &mut GroupMutableMetadata,
extensions: &openmls::extensions::Extensions<openmls::group::GroupContext>,
) -> Result<(), ComponentSourceError> {
if !super::is_migrated_extensions(extensions) {
return Ok(());
}
let Some(ext) = extensions.app_data_dictionary() else {
return Ok(());
};
let dict = ext.dictionary();
for (field, id) in METADATA_FIELD_COMPONENT_MAP {
if let Some(bytes) = dict.get(&id.as_u16()) {
// Each typed `Component`'s wire shape decides how the dict
// bytes round-trip back into the legacy
// `GroupMutableMetadata.attributes` string map:
//
// - `MESSAGE_DISAPPEAR_*` are 8-byte BE `i64` on the wire;
// format as a base-10 string for the legacy reader.
// - `COMMIT_LOG_SIGNER` is the raw 32-byte private key on
// the wire; hex-encode for the legacy reader.
// - All other metadata-attribute components are UTF-8.
let legacy_value = match *id {
ComponentId::MESSAGE_DISAPPEAR_FROM_NS | ComponentId::MESSAGE_DISAPPEAR_IN_NS => {
let arr: [u8; 8] = bytes.try_into().map_err(|_| {
ComponentSourceError::MalformedComponentValue {
component_id: *id,
reason: format!("expected 8 bytes (BE i64), got {}", bytes.len()),
}
})?;
i64::from_be_bytes(arr).to_string()
}
ComponentId::COMMIT_LOG_SIGNER => hex::encode(bytes),
_ => std::str::from_utf8(bytes)
.map_err(|e| ComponentSourceError::MalformedComponentValue {
component_id: *id,
reason: format!("non-UTF-8 bytes: {e}"),
})?
.to_string(),
};
base.attributes
.insert(field.as_str().to_string(), legacy_value);
}
}
// ADMIN_LIST / SUPER_ADMIN_LIST overlay: on migrated groups the
// dict is authoritative; decode the `TlsSet<InboxId>` and
// hex-encode each id back to string form for the base GMM.
for (component_id, list) in [
(ComponentId::ADMIN_LIST, &mut base.admin_list),
(ComponentId::SUPER_ADMIN_LIST, &mut base.super_admin_list),
] {
if let Some(bytes) = dict.get(&component_id.as_u16()) {
let set = TlsSet::<InboxId>::tls_deserialize_exact(bytes).map_err(|e| {
ComponentSourceError::MalformedComponentValue {
component_id,
reason: format!("invalid TlsSet<InboxId>: {e}"),
}
})?;
*list = set.iter().map(|id| id.to_hex()).collect();
}
}
Ok(())
}
// ============================================================================
// Inbox-id encoding helpers
// ============================================================================
//
// Inbox ids are SHA-256 hashes (see `xmtp_id::associations::member::inbox_id`).
// Their canonical string form is a 64-character hex string. Anything we put
// on the wire through the new `AppDataUpdate` path uses the
// versioned `InboxId` newtype instead — see the module-level docs for
// the rationale and `xmtp_mls_common::inbox_id` for the full contract.
/// Decode a hex-string inbox id into an [`InboxId`].
///
/// Returns [`ComponentSourceError::InvalidInboxId`] wrapping either
/// [`InboxIdError::InvalidHex`] (input wasn't hex) or
/// [`InboxIdError::InvalidLength`] (wrong byte length after decoding).
/// Callers that need to distinguish the failure modes can match the
/// inner variant.
pub(crate) fn inbox_id_str_to_bytes(inbox_id: &str) -> Result<InboxId, ComponentSourceError> {
InboxId::from_hex(inbox_id).map_err(Into::into)
}
/// Read the super-admin list from the AppData dictionary on a migrated
/// group. Returns `Ok(None)` on unmigrated groups (or migrated groups
/// that happen not to have written `SUPER_ADMIN_LIST` yet).
///
/// Gated on [`super::is_migrated_group`] for the same reason as
/// [`merge_app_data_into_mutable_metadata`] — keep stray dict entries
/// from shadowing the authoritative legacy path pre-bootstrap.
pub(crate) fn read_super_admin_list_from_dict(
mls_group: &OpenMlsGroup,
) -> Result<Option<Vec<String>>, ComponentSourceError> {
read_super_admin_list_from_extensions(mls_group.extensions())
}
/// Extensions-only variant of [`read_super_admin_list_from_dict`]. Use
/// the shim above when an `OpenMlsGroup` is at hand; this form is
/// available primarily for unit testing and for commit-validation
/// paths that only carry an `Extensions` reference.
pub(crate) fn read_super_admin_list_from_extensions(
extensions: &Extensions<GroupContext>,
) -> Result<Option<Vec<String>>, ComponentSourceError> {
if !super::is_migrated_extensions(extensions) {
return Ok(None);
}
let Some(ext) = extensions.app_data_dictionary() else {
return Ok(None);
};
let Some(bytes) = ext
.dictionary()
.get(&ComponentId::SUPER_ADMIN_LIST.as_u16())
else {
return Ok(None);
};
let set = TlsSet::<InboxId>::tls_deserialize_exact(bytes).map_err(|e| {
ComponentSourceError::MalformedComponentValue {
component_id: ComponentId::SUPER_ADMIN_LIST,
reason: format!("invalid TlsSet<InboxId>: {e}"),
}
})?;
Ok(Some(set.iter().map(|id| id.to_hex()).collect()))
}
/// Synthesize a [`GroupMetadata`] from the AppData dictionary on a
/// migrated group. Returns `Ok(None)` if the critical immutable seeds
/// aren't present (unmigrated group).
///
/// Encoding mirrors the sender-side synthesis in
/// [`xmtp_mls_common::app_data::migration::synthesize_canonical_subset_for_validation`]:
/// - `CONVERSATION_TYPE`: 4 big-endian bytes of `ConversationType as i32`
/// (see `encode_conversation_type` there).
/// - `CREATOR_INBOX_ID`: the versioned `InboxId` TLS wire form
/// (`varint(version) || 32-byte payload`) — the same shape every
/// other inbox-id-bearing component on the new path uses. Reader
/// hex-encodes the decoded id back into the legacy
/// `GroupMetadata::creator_inbox_id: String` slot.
/// - `DM_MEMBERS`: `TlsSet<InboxId>` with exactly two elements —
/// matches the declared `ComponentType::TlsSetInboxId` and the
/// sender's `encode_dm_members`. The writer rejects self-DMs
/// (identical slots) up front; readers that see a 1-element set
/// surface `MalformedComponentValue`.
/// - `ONESHOT_MESSAGE`: prost-encoded `OneshotMessage`.
pub(crate) fn read_group_metadata_from_dict(
mls_group: &OpenMlsGroup,
) -> Result<Option<GroupMetadataReturn>, ComponentSourceError> {
read_group_metadata_from_extensions(mls_group.extensions())
}
/// Extensions-only variant of [`read_group_metadata_from_dict`]. Same
/// rationale for the split as [`read_super_admin_list_from_extensions`].
pub(crate) fn read_group_metadata_from_extensions(
extensions: &Extensions<GroupContext>,
) -> Result<Option<GroupMetadataReturn>, ComponentSourceError> {
use prost::Message;
use xmtp_proto::xmtp::mls::message_contents::{
DmMembers as DmMembersProto, Inbox as InboxProto, OneshotMessage,
};
// Gated on the unified migration predicate — see
// `merge_app_data_into_mutable_metadata` for the rationale.
if !super::is_migrated_extensions(extensions) {
return Ok(None);
}
let Some(ext) = extensions.app_data_dictionary() else {
return Ok(None);
};
let dict = ext.dictionary();
let Some(ct_bytes) = dict.get(&ComponentId::CONVERSATION_TYPE.as_u16()) else {
return Ok(None);
};
let Some(creator_bytes) = dict.get(&ComponentId::CREATOR_INBOX_ID.as_u16()) else {
return Ok(None);
};
let ct_arr: [u8; 4] =
ct_bytes
.try_into()
.map_err(|_| ComponentSourceError::MalformedComponentValue {
component_id: ComponentId::CONVERSATION_TYPE,
reason: format!("expected 4 bytes, got {}", ct_bytes.len()),
})?;
let conversation_type = i32::from_be_bytes(ct_arr);
let creator_inbox_id = InboxId::tls_deserialize_exact(creator_bytes)
.map_err(|e| ComponentSourceError::MalformedComponentValue {
component_id: ComponentId::CREATOR_INBOX_ID,
reason: format!("invalid InboxId TLS encoding: {e}"),
})?
.to_hex();
// `DM_MEMBERS` on the wire is `TlsSet<InboxId>`; re-shape to
// `DmMembersProto` so downstream `GroupMetadata::try_from` is unchanged.
let dm_members = match dict.get(&ComponentId::DM_MEMBERS.as_u16()) {
Some(b) => {
let set = TlsSet::<InboxId>::tls_deserialize_exact(b).map_err(|e| {
ComponentSourceError::MalformedComponentValue {
component_id: ComponentId::DM_MEMBERS,
reason: format!("invalid TlsSet<InboxId>: {e}"),
}
})?;
let ids: Vec<InboxId> = set.iter().copied().collect();
if ids.len() != 2 {
return Err(ComponentSourceError::MalformedComponentValue {
component_id: ComponentId::DM_MEMBERS,
reason: format!("expected 2 inbox ids, got {}", ids.len()),
});
}
Some(DmMembersProto {
dm_member_one: Some(InboxProto {
inbox_id: ids[0].to_hex(),
}),
dm_member_two: Some(InboxProto {