Skip to main content

xmtp_mls_common/app_data/
migration.rs

1//! Shared types, errors, and encoding helpers for the app-data
2//! migration synthesis path.
3
4use std::collections::{BTreeMap, BTreeSet};
5
6use openmls::{
7    extensions::Extensions,
8    group::{GroupContext, MlsGroup as OpenMlsGroup},
9    messages::proposals::AppDataUpdateOperationType,
10};
11use prost::Message as _;
12use tls_codec::{Deserialize, Serialize, VLBytes};
13use xmtp_proto::xmtp::mls::message_contents::{
14    ComponentMetadata, ComponentPermissions, ComponentType,
15    GroupMembership as GroupMembershipProto, MembershipPolicy as MembershipPolicyProto,
16    MetadataPolicy as MetadataPolicyProto, PermissionsUpdatePolicy as PermissionsUpdatePolicyProto,
17    PolicySet as PolicySetProto,
18    membership_policy::{BasePolicy as MembershipBasePolicy, Kind as MembershipPolicyKind},
19    metadata_policy::{Kind as MetadataPolicyKind, MetadataBasePolicy},
20    permissions_update_policy::{Kind as PermissionsPolicyKind, PermissionsBasePolicy},
21};
22
23use crate::{
24    app_data::{
25        component_id::ComponentId,
26        component_registry::{ComponentRegistry, ComponentRegistryError, new_component_metadata},
27    },
28    group_mutable_metadata::MetadataField,
29    inbox_id::{InboxId, InboxIdError},
30    tls_map::{TlsMap, TlsMapDelta, TlsMapMutation},
31    tls_set::TlsSetDelta,
32};
33use xmtp_proto::xmtp::mls::message_contents::GroupMembershipEntry;
34/// Errors produced by the synthesis functions in this module.
35#[derive(Debug, thiserror::Error)]
36pub enum MigrationError {
37    /// A required `PolicySet` field was `None`. Both production presets
38    /// populate every field, so this only fires on corrupt input.
39    #[error("legacy PolicySet is missing required policy field: {0}")]
40    MissingPolicyField(&'static str),
41
42    /// `update_metadata_policy` referenced a metadata field we don't
43    /// recognize. Silently dropping it would lose permission
44    /// enforcement, so synthesis fails fast.
45    #[error("update_metadata_policy references unknown metadata field: {0}")]
46    UnknownMetadataField(String),
47
48    /// `add_admin_policy`/`remove_admin_policy` wasn't admin- or
49    /// super-admin-gated; the constrained-component `MetadataPolicy`
50    /// shape can't represent it.
51    #[error("ADMIN_LIST admin policy is not admin-or-super-admin (got base={0:?})")]
52    NonConstrainedAdminPolicy(Option<i32>),
53
54    /// `update_permissions_policy` must be `AllowIfSuperAdmin` —
55    /// `COMPONENT_REGISTRY` is hardcoded super-admin-only on the
56    /// receiver, so any other value would silently disagree.
57    #[error("update_permissions_policy must be AllowIfSuperAdmin (got {0:?})")]
58    UpdatePermissionsNotSuperAdmin(Option<i32>),
59
60    #[error("component registry error: {0}")]
61    Registry(#[from] ComponentRegistryError),
62
63    #[error("legacy mutable-metadata extension missing from group")]
64    MissingMutableMetadataExtension,
65
66    #[error("legacy group-membership extension missing from group")]
67    MissingGroupMembershipExtension,
68
69    #[error("legacy GroupMembership extension decode error: {0}")]
70    GroupMembershipDecode(#[from] prost::DecodeError),
71
72    /// Kept distinct from `GroupMembershipDecode` so incident-response
73    /// greps land on the right extension type.
74    #[error("legacy GroupMutablePermissionsV1 decode error: {0}")]
75    GroupPermissionsDecode(prost::DecodeError),
76
77    #[error("legacy GroupMutableMetadata decode error: {0}")]
78    MutableMetadataDecode(#[from] crate::group_mutable_metadata::GroupMutableMetadataError),
79
80    #[error("legacy GroupMetadata decode error: {0}")]
81    GroupMetadataDecode(#[from] crate::group_metadata::GroupMetadataError),
82
83    #[error("TLS codec error: {0}")]
84    TlsCodec(#[from] tls_codec::Error),
85
86    #[error("invalid inbox id: {0}")]
87    InvalidInboxId(#[from] InboxIdError),
88
89    /// A `GROUP_MEMBERSHIP` membership-policy variant we can't translate
90    /// onto `MetadataPolicyProto`. Mirrors `UnknownMetadataField` —
91    /// silently collapsing to Deny would lose enforcement.
92    #[error("unrecognized GROUP_MEMBERSHIP policy (base={0:?})")]
93    UnknownMembershipPolicy(Option<i32>),
94
95    #[error("invalid CONVERSATION_TYPE payload length: expected 4, got {0}")]
96    ConversationTypePayloadLength(usize),
97
98    /// Legacy `GroupMetadata.dm_members` had the same inbox in both slots.
99    /// `TlsSet<InboxId>` (the `DM_MEMBERS` wire encoding) dedupes by
100    /// value and would silently collapse this to one element, so we
101    /// fail loud instead of destroying information.
102    #[error("DmMembers self-reference: both slots contain inbox id {0}")]
103    DmMembersSelfReference(String),
104
105    /// `GroupMembershipEntry` envelope decoded but the `version` oneof
106    /// was unset (or set to a variant this build doesn't recognize).
107    /// Treated as a hard decode failure rather than a silent skip.
108    #[error("GroupMembershipEntry envelope has unknown or unset version")]
109    GroupMembershipEntryUnknownVersion,
110
111    /// Legacy `GroupMembership.failed_installations` carried an entry
112    /// whose length isn't 32 bytes (the Ed25519 installation-key size).
113    /// Either the legacy state is corrupt or the wire shape changed —
114    /// either way, fail loud rather than silently admit a malformed
115    /// installation ID into the validator's allow-set.
116    #[error("legacy failed_installations entry has invalid length: expected 32, got {0}")]
117    InvalidFailedInstallationLength(usize),
118
119    /// A bootstrap-time `GROUP_MEMBERSHIP` wire delta carried a
120    /// non-`Insert` mutation. Bootstrap is always "delta from empty,"
121    /// so anything but `Insert` means the sender is malformed.
122    #[error("GROUP_MEMBERSHIP bootstrap delta carried a non-Insert mutation")]
123    GroupMembershipNonInsertBootstrapMutation,
124
125    /// A bootstrap-time `GROUP_MEMBERSHIP` wire delta carried two
126    /// `Insert` mutations for the same inbox id. The wire shape is
127    /// `TlsMapDelta` which permits this in principle, but bootstrap
128    /// must be a deterministic snapshot — the duplicate would let the
129    /// sender's queue order diverge from honest receivers.
130    #[error("GROUP_MEMBERSHIP bootstrap delta has duplicate Insert for inbox {0}")]
131    GroupMembershipDuplicateInbox(String),
132
133    /// Legacy `MessageDisappearFromNS` / `MessageDisappearInNS` GMM
134    /// attribute didn't parse as a base-10 string of an `i64`. The
135    /// legacy reader at `MessageDisappearingSettings` returns
136    /// `MissingExtension` for this case; bootstrap synthesis fails
137    /// loud instead so the migrated dict can't silently drop a
138    /// configured value.
139    #[error("legacy {field} attribute is not a base-10 i64 string (got {value:?}): {reason}")]
140    InvalidDisappearingTimestamp {
141        field: &'static str,
142        value: String,
143        reason: String,
144    },
145
146    /// Legacy `CommitLogSigner` GMM attribute wasn't valid hex.
147    #[error("legacy CommitLogSigner attribute is not valid hex: {reason}")]
148    InvalidCommitLogSignerHex { reason: String },
149
150    /// Legacy `CommitLogSigner` decoded to the wrong number of bytes —
151    /// the AppData wire shape is the raw 32-byte Ed25519 private key.
152    #[error("legacy CommitLogSigner length: expected {expected}, got {actual}")]
153    InvalidCommitLogSignerLength { expected: usize, actual: usize },
154}
155
156/// Produce a populated [`ComponentRegistry`] from the legacy
157/// [`PolicySetProto`]. Deterministic: every honest peer synthesizes
158/// bit-identical output from the same input.
159///
160/// Mapping summary:
161///
162/// - Mutable scalar components pull insert/update from
163///   `update_metadata_policy[field_name]`; a missing key defaults to
164///   admin-only (super-admin for the 0x800A protocol-version floor),
165///   mirroring the legacy enforcer. Delete is hardcoded super-admin-only.
166///   Per-field `ComponentType` comes from
167///   [`metadata_field_registry_mapping`] — disappearing-message
168///   timestamps are bytes (BE-u64), the rest are utf-8 strings.
169/// - `ADMIN_LIST` is constrained: insert/update from `add_admin_policy`,
170///   delete from `remove_admin_policy`. All must be admin-or-
171///   super-admin (synthesis rejects otherwise).
172/// - `SUPER_ADMIN_LIST` and `COMPONENT_REGISTRY` are hardcoded
173///   super-admin-only and not written to the registry.
174/// - `GROUP_MEMBERSHIP` mirrors `add_member_policy`/`remove_member_policy`
175///   for insert/delete; update is `Allow` (anyone can advance
176///   installations).
177/// - Immutable components: super-admin insert, deny update + delete.
178pub fn synthesize_registry_from_policy_set(
179    policy_set: &PolicySetProto,
180) -> Result<ComponentRegistry, MigrationError> {
181    // Public entry point: maximal registry (every well-known component).
182    // The internal `build_registry` gates `DM_MEMBERS` / `ONESHOT_MESSAGE`
183    // for receiver-side synthesis where the group may not have them —
184    // those live in the immutable range and can't be removed once written.
185    build_registry(policy_set, true, true)
186}
187
188fn build_registry(
189    policy_set: &PolicySetProto,
190    include_dm_members: bool,
191    include_oneshot_message: bool,
192) -> Result<ComponentRegistry, MigrationError> {
193    let mut registry = ComponentRegistry::new();
194
195    // Defensive: every top-level policy must be present.
196    let add_member = policy_set
197        .add_member_policy
198        .as_ref()
199        .ok_or(MigrationError::MissingPolicyField("add_member_policy"))?;
200    let remove_member = policy_set
201        .remove_member_policy
202        .as_ref()
203        .ok_or(MigrationError::MissingPolicyField("remove_member_policy"))?;
204    let add_admin = policy_set
205        .add_admin_policy
206        .as_ref()
207        .ok_or(MigrationError::MissingPolicyField("add_admin_policy"))?;
208    let remove_admin = policy_set
209        .remove_admin_policy
210        .as_ref()
211        .ok_or(MigrationError::MissingPolicyField("remove_admin_policy"))?;
212    let update_permissions =
213        policy_set
214            .update_permissions_policy
215            .as_ref()
216            .ok_or(MigrationError::MissingPolicyField(
217                "update_permissions_policy",
218            ))?;
219
220    // update_permissions_policy MUST be super-admin-only. Any other
221    // value would be silently ignored because COMPONENT_REGISTRY's
222    // permissions are enforced in code (hardcoded super-admin-only).
223    validate_update_permissions_is_super_admin(update_permissions)?;
224
225    // Fail fast on unknown `update_metadata_policy` keys before doing
226    // any work — silently dropping them would lose permission
227    // enforcement.
228    let known: std::collections::HashSet<&'static str> = metadata_field_registry_mapping()
229        .iter()
230        .map(|(f, _, _)| f.as_str())
231        .collect();
232    for key in policy_set.update_metadata_policy.keys() {
233        if !known.contains(key.as_str()) {
234            return Err(MigrationError::UnknownMetadataField(key.clone()));
235        }
236    }
237
238    // Mutable scalar components: insert/update from
239    // `update_metadata_policy[field]`; delete is always super-admin-only.
240    // Per-field `ComponentType` comes from the mapping — strings for
241    // free-form text, bytes for the BE-u64 disappearing-message
242    // timestamps.
243    //
244    // A field ABSENT from `update_metadata_policy` must default to what the
245    // legacy enforcer applied to a missing field — admin-only
246    // (`group_permissions.rs`: "default to admin only for fields with
247    // missing policies"), NOT `Allow`. The protocol-version floor defaults
248    // to super-admin instead, matching every preconfigured PolicySet
249    // (`default_map`/`default_policy`/`policy_admin_only`), so a group that
250    // predates the field can't be wedged by a non-super-admin raising the
251    // monotonic 0x800A floor. Present keys are carried verbatim, so a DM's
252    // stored `Allow` (from `dm_map`) is preserved rather than tightened.
253    for (field, component_id, component_type) in metadata_field_registry_mapping() {
254        let default_base = if matches!(field, MetadataField::MinimumSupportedProtocolVersion) {
255            MetadataBasePolicy::AllowIfSuperAdmin
256        } else {
257            MetadataBasePolicy::AllowIfAdmin
258        };
259        let policy = policy_set
260            .update_metadata_policy
261            .get(field.as_str())
262            .cloned()
263            .unwrap_or_else(|| metadata_policy(default_base));
264
265        registry.set(
266            *component_id,
267            new_component_metadata(
268                ComponentPermissions {
269                    insert_policy: Some(policy.clone()),
270                    update_policy: Some(policy),
271                    delete_policy: Some(metadata_policy(MetadataBasePolicy::AllowIfSuperAdmin)),
272                },
273                *component_type,
274            ),
275        )?;
276    }
277
278    // COMMIT_LOG_SIGNER: super-admin-only regardless of PolicySet
279    // shape. The field DOES change post-creation (see
280    // `Group::update_commit_log_signer`), but its enforcement on the
281    // legacy side is implicit: `_commit_log_signer` is never present
282    // in `update_metadata_policy` (neither `default_map` nor `dm_map`
283    // populates it — `supported_fields()` excludes `CommitLogSigner`),
284    // so the policy enforcer at `group_permissions.rs` falls through
285    // to the `_`-prefix super-admin-only path. We encode that
286    // implicit policy explicitly here. A malicious peer that *does*
287    // ship `_commit_log_signer` inside `update_metadata_policy` will
288    // surface as `UnknownMetadataField` above — fail-loud beats
289    // silently downgrading enforcement.
290    let super_admin = metadata_policy(MetadataBasePolicy::AllowIfSuperAdmin);
291    registry.set(
292        ComponentId::COMMIT_LOG_SIGNER,
293        new_component_metadata(
294            ComponentPermissions {
295                insert_policy: Some(super_admin.clone()),
296                update_policy: Some(super_admin.clone()),
297                delete_policy: Some(super_admin.clone()),
298            },
299            ComponentType::Bytes,
300        ),
301    )?;
302
303    // ADMIN_LIST (SetInboxId, constrained).
304    let admin_policy = admin_list_policy_to_metadata_policy(add_admin)?;
305    let remove_admin_policy = admin_list_policy_to_metadata_policy(remove_admin)?;
306    registry.set(
307        ComponentId::ADMIN_LIST,
308        new_component_metadata(
309            ComponentPermissions {
310                insert_policy: Some(admin_policy.clone()),
311                update_policy: Some(admin_policy),
312                delete_policy: Some(remove_admin_policy),
313            },
314            ComponentType::TlsSetInboxId,
315        ),
316    )?;
317
318    // GROUP_MEMBERSHIP (TlsMapInboxIdBytes).
319    registry.set(
320        ComponentId::GROUP_MEMBERSHIP,
321        new_component_metadata(
322            ComponentPermissions {
323                insert_policy: Some(membership_policy_to_metadata_policy(add_member)?),
324                update_policy: Some(metadata_policy(MetadataBasePolicy::Allow)),
325                delete_policy: Some(membership_policy_to_metadata_policy(remove_member)?),
326            },
327            ComponentType::TlsMapInboxIdBytes,
328        ),
329    )?;
330
331    // Immutable seeds: super-admin insert, deny update + delete.
332    // `DM_MEMBERS` and `ONESHOT_MESSAGE` are gated on the `include_*`
333    // flags — registering them for a group that doesn't have them
334    // would pin their absence forever (immutable entries can't be
335    // removed after write). Receiver-side synthesis gates them
336    // symmetrically so byte-compare always lines up.
337    let immutable_permissions = ComponentPermissions {
338        insert_policy: Some(metadata_policy(MetadataBasePolicy::AllowIfSuperAdmin)),
339        update_policy: Some(metadata_policy(MetadataBasePolicy::Deny)),
340        delete_policy: Some(metadata_policy(MetadataBasePolicy::Deny)),
341    };
342    for id in [
343        ComponentId::CONVERSATION_TYPE,
344        ComponentId::CREATOR_INBOX_ID,
345    ] {
346        registry.set(
347            id,
348            new_component_metadata(immutable_permissions.clone(), ComponentType::Bytes),
349        )?;
350    }
351    if include_oneshot_message {
352        registry.set(
353            ComponentId::ONESHOT_MESSAGE,
354            new_component_metadata(immutable_permissions.clone(), ComponentType::Bytes),
355        )?;
356    }
357    if include_dm_members {
358        registry.set(
359            ComponentId::DM_MEMBERS,
360            new_component_metadata(immutable_permissions, ComponentType::TlsSetInboxId),
361        )?;
362    }
363
364    Ok(registry)
365}
366
367/// List of (`MetadataField`, `ComponentId`, `ComponentType`) tuples the
368/// registry knows about. Kept in one place so synthesis and validation
369/// can iterate the same set.
370///
371/// The legacy `GroupMutableMetadata` stored every value as a `String`
372/// (with disappearing-message timestamps stringified via `to_string()`),
373/// but on the new component side the natural representation differs:
374/// the disappearing-message timestamps round-trip as big-endian `u64`
375/// bytes, while everything else is utf-8 text. Tagging the type here
376/// drives `new_component_metadata` to register the correct
377/// `ComponentType`.
378///
379/// The `(ComponentId, ComponentType)` pairs in this table must agree
380/// with the static dispatch table at
381/// [`super::registry_table::WELL_KNOWN`] — pinned by the unit test
382/// `metadata_field_mapping_agrees_with_dispatch_table` below.
383fn metadata_field_registry_mapping() -> &'static [(MetadataField, ComponentId, ComponentType)] {
384    &[
385        (
386            MetadataField::GroupName,
387            ComponentId::GROUP_NAME,
388            ComponentType::String,
389        ),
390        (
391            MetadataField::Description,
392            ComponentId::GROUP_DESCRIPTION,
393            ComponentType::String,
394        ),
395        (
396            MetadataField::GroupImageUrlSquare,
397            ComponentId::GROUP_IMAGE_URL,
398            ComponentType::String,
399        ),
400        (
401            MetadataField::MessageDisappearFromNS,
402            ComponentId::MESSAGE_DISAPPEAR_FROM_NS,
403            ComponentType::Bytes,
404        ),
405        (
406            MetadataField::MessageDisappearInNS,
407            ComponentId::MESSAGE_DISAPPEAR_IN_NS,
408            ComponentType::Bytes,
409        ),
410        (
411            MetadataField::AppData,
412            ComponentId::APP_DATA,
413            ComponentType::String,
414        ),
415        (
416            MetadataField::MinimumSupportedProtocolVersion,
417            ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION,
418            ComponentType::String,
419        ),
420    ]
421}
422
423fn metadata_policy(base: MetadataBasePolicy) -> MetadataPolicyProto {
424    MetadataPolicyProto {
425        kind: Some(MetadataPolicyKind::Base(base as i32)),
426    }
427}
428
429/// Convert a legacy `add_admin_policy` / `remove_admin_policy` (typed
430/// as `PermissionsUpdatePolicy` on the wire) into the `MetadataPolicy`
431/// that gates `ADMIN_LIST` insert/update/delete on the new side.
432/// Only admin- or super-admin base policies are allowed: combinators
433/// or any other base value would silently break the constrained-
434/// component check in [`ComponentRegistry::validate_metadata`].
435fn admin_list_policy_to_metadata_policy(
436    p: &PermissionsUpdatePolicyProto,
437) -> Result<MetadataPolicyProto, MigrationError> {
438    match &p.kind {
439        Some(PermissionsPolicyKind::Base(base)) => match PermissionsBasePolicy::try_from(*base) {
440            Ok(PermissionsBasePolicy::AllowIfAdmin) => {
441                Ok(metadata_policy(MetadataBasePolicy::AllowIfAdmin))
442            }
443            Ok(PermissionsBasePolicy::AllowIfSuperAdmin) => {
444                Ok(metadata_policy(MetadataBasePolicy::AllowIfSuperAdmin))
445            }
446            _ => Err(MigrationError::NonConstrainedAdminPolicy(Some(*base))),
447        },
448        Some(PermissionsPolicyKind::AndCondition(_))
449        | Some(PermissionsPolicyKind::AnyCondition(_))
450        | None => Err(MigrationError::NonConstrainedAdminPolicy(None)),
451    }
452}
453
454/// Convert a legacy `MembershipPolicy` to a `MetadataPolicy`.
455/// `AllowIfAdminOrSuperAdmin` collapses to `AllowIfAdmin` because
456/// `MetadataPolicy::AllowIfAdmin` already means "admin or super admin".
457/// Combinators and unknown base values fail loud rather than silently
458/// collapsing to Deny.
459fn membership_policy_to_metadata_policy(
460    p: &MembershipPolicyProto,
461) -> Result<MetadataPolicyProto, MigrationError> {
462    match &p.kind {
463        Some(MembershipPolicyKind::Base(base)) => {
464            let mapped = match MembershipBasePolicy::try_from(*base) {
465                Ok(MembershipBasePolicy::Allow) => MetadataBasePolicy::Allow,
466                Ok(MembershipBasePolicy::Deny) => MetadataBasePolicy::Deny,
467                Ok(MembershipBasePolicy::AllowIfAdminOrSuperAdmin) => {
468                    MetadataBasePolicy::AllowIfAdmin
469                }
470                Ok(MembershipBasePolicy::AllowIfSuperAdmin) => {
471                    MetadataBasePolicy::AllowIfSuperAdmin
472                }
473                _ => return Err(MigrationError::UnknownMembershipPolicy(Some(*base))),
474            };
475            Ok(metadata_policy(mapped))
476        }
477        Some(MembershipPolicyKind::AndCondition(_))
478        | Some(MembershipPolicyKind::AnyCondition(_))
479        | None => Err(MigrationError::UnknownMembershipPolicy(None)),
480    }
481}
482
483fn validate_update_permissions_is_super_admin(
484    p: &PermissionsUpdatePolicyProto,
485) -> Result<(), MigrationError> {
486    match &p.kind {
487        Some(PermissionsPolicyKind::Base(base)) => match PermissionsBasePolicy::try_from(*base) {
488            Ok(PermissionsBasePolicy::AllowIfSuperAdmin) => Ok(()),
489            _ => Err(MigrationError::UpdatePermissionsNotSuperAdmin(Some(*base))),
490        },
491        _ => Err(MigrationError::UpdatePermissionsNotSuperAdmin(None)),
492    }
493}
494
495/// The receiver-side bootstrap expectation. The validator picks the
496/// comparison strategy per component:
497///
498/// - [`Self::strict`] — byte-compared against the sender's commit
499///   payload. Used for components whose canonical encoding is
500///   deterministic by construction: raw bytes/utf-8 (metadata
501///   attributes, `COMMIT_LOG_SIGNER`, `CONVERSATION_TYPE`),
502///   the versioned single-`InboxId` TLS wire form (`CREATOR_INBOX_ID`),
503///   and TLS-codec containers that sort their keys (`ADMIN_LIST`,
504///   `SUPER_ADMIN_LIST`, `DM_MEMBERS`, `ONESHOT_MESSAGE`).
505/// - [`Self::expected_registry`] — `COMPONENT_REGISTRY` is decoded
506///   first, then compared per entry as a typed [`ComponentMetadata`].
507///   The outer `TlsMapDelta` wrapper IS deterministic, but each
508///   entry's value is a prost-encoded `ComponentMetadata`. Prost
509///   tag-order emission is theoretically deterministic, but
510///   byte-compare is brittle against future proto evolution (newly
511///   optional fields, default-value elision differences across
512///   encoder versions or language bindings) and produces useless
513///   diffs ("byte 47 differs"). Decoded compare side-steps both.
514/// - [`Self::membership_sequence_ids`] — `GROUP_MEMBERSHIP`'s
515///   `failed_installations` is sender-authoritative (the migrator
516///   partitions per inbox by walking identity-update history, so
517///   different honest senders may legitimately disagree on bytes),
518///   so the validator only checks per-inbox `sequence_id`.
519/// - [`Self::allowed_failed_installations`] — bounds the universe of
520///   installation IDs the sender may legally place into ANY per-inbox
521///   `failed_installations`. Drawn from the legacy
522///   `GroupMembership.failed_installations` flat list, with each entry
523///   length-checked to 32 bytes (Ed25519 installation key). The sender
524///   is allowed to drop entries (e.g., when the owning inbox can't be
525///   determined) but not to add ones the legacy state never contained.
526///   Validator semantics: every per-inbox `failed_installations`
527///   entry must be 32 bytes AND present in this set.
528#[derive(Debug, Clone, PartialEq)]
529pub struct CanonicalBootstrapExpectation {
530    pub strict: BTreeMap<ComponentId, (AppDataUpdateOperationType, Vec<u8>)>,
531    pub expected_registry: BTreeMap<ComponentId, ComponentMetadata>,
532    pub membership_sequence_ids: BTreeMap<InboxId, u64>,
533    pub allowed_failed_installations: BTreeSet<[u8; 32]>,
534}
535
536/// Compute the [`CanonicalBootstrapExpectation`] from a pre-flip
537/// group's state. **Sync, fully local** — no API calls — so every
538/// honest receiver produces bit-identical output.
539///
540/// **ENCODING FREEZE.** The `strict` map this produces is byte-compared
541/// by every fielded validator against incoming bootstrap commits, and
542/// groups migrate lazily — so for any input an already-shipped receiver
543/// can encounter, this encoding is permanent. The
544/// `golden_bootstrap_synthesis_*` tests pin it byte-for-byte. An
545/// encoder change is only shippable together with a
546/// `PROPOSALS_MIN_PROTOCOL_VERSION` bump (below-floor receivers pause
547/// via the floor check in `validate_bootstrap_commit` instead of
548/// byte-comparing), and the old expectation logic must keep validating
549/// bootstraps produced by older senders.
550pub fn synthesize_canonical_subset_for_validation(
551    mls_group: &OpenMlsGroup,
552) -> Result<CanonicalBootstrapExpectation, MigrationError> {
553    synthesize_canonical_subset_from_extensions(mls_group.extensions())
554}
555
556/// Extensions-only variant of [`synthesize_canonical_subset_for_validation`].
557/// Lets tests exercise synthesis without standing up a real MLS group.
558pub fn synthesize_canonical_subset_from_extensions(
559    extensions: &Extensions<GroupContext>,
560) -> Result<CanonicalBootstrapExpectation, MigrationError> {
561    let gmm: crate::group_mutable_metadata::GroupMutableMetadata = extensions.try_into()?;
562    let registry = synthesize_registry_from_extensions(extensions)?;
563    let legacy_membership = extract_legacy_group_membership(extensions)?;
564    let legacy_metadata = crate::group_metadata::GroupMetadata::try_from(extensions)?;
565
566    let mut strict: BTreeMap<ComponentId, (AppDataUpdateOperationType, Vec<u8>)> = BTreeMap::new();
567
568    // COMPONENT_REGISTRY: decoded per-entry compare (see
569    // `CanonicalBootstrapExpectation` doc — bytes inside each entry are
570    // prost-encoded and brittle to byte-compare).
571    let mut expected_registry: BTreeMap<ComponentId, ComponentMetadata> = BTreeMap::new();
572    for entry in registry.iter() {
573        let (id, meta) = entry?;
574        expected_registry.insert(id, meta);
575    }
576
577    // Bytes/String metadata attributes. Skip fields that aren't set in
578    // the legacy GMM — the typed component encoders reject empty input
579    // for the fixed-length flavours (MESSAGE_DISAPPEAR_* expects 8 BE
580    // bytes, COMMIT_LOG_SIGNER expects 32) and emitting absent
581    // entries here would only pollute the dict with values readers
582    // would surface as `MissingExtension` anyway.
583    for (field, component_id, _) in metadata_field_registry_mapping() {
584        if let Some(s) = gmm.attributes.get(field.as_str()) {
585            strict.insert(
586                *component_id,
587                (
588                    AppDataUpdateOperationType::Update,
589                    encode_metadata_attribute_value(*component_id, s)?,
590                ),
591            );
592        }
593    }
594
595    // COMMIT_LOG_SIGNER lives in the same GMM attributes map. The
596    // legacy form is hex-encoded; the AppData wire form is the raw
597    // 32-byte private key.
598    if let Some(hex_str) = gmm.attributes.get(MetadataField::CommitLogSigner.as_str()) {
599        let raw = hex::decode(hex_str).map_err(|e| MigrationError::InvalidCommitLogSignerHex {
600            reason: e.to_string(),
601        })?;
602        if raw.len() != xmtp_cryptography::configuration::ED25519_KEY_LENGTH {
603            return Err(MigrationError::InvalidCommitLogSignerLength {
604                actual: raw.len(),
605                expected: xmtp_cryptography::configuration::ED25519_KEY_LENGTH,
606            });
607        }
608        strict.insert(
609            ComponentId::COMMIT_LOG_SIGNER,
610            (AppDataUpdateOperationType::Update, raw),
611        );
612    }
613
614    // ADMIN_LIST / SUPER_ADMIN_LIST: hex-decode inbox-id strings and
615    // serialize as TlsSet<InboxId> — matches the bridge encoder.
616    strict.insert(
617        ComponentId::ADMIN_LIST,
618        (
619            AppDataUpdateOperationType::Update,
620            encode_inbox_id_set(&gmm.admin_list)?,
621        ),
622    );
623    strict.insert(
624        ComponentId::SUPER_ADMIN_LIST,
625        (
626            AppDataUpdateOperationType::Update,
627            encode_inbox_id_set(&gmm.super_admin_list)?,
628        ),
629    );
630
631    // Immutable seeds. Route the shared `ConversationType` through
632    // its `From<_> for ConversationTypeProto` impl before casting to
633    // i32 — the two enums share variants today but are *separate*
634    // types with their own discriminants. Direct `as i32` on the shared
635    // enum would silently drift if either side renumbers. Mirrors the
636    // pattern in `group_metadata.rs::TryFrom<GroupMetadata> for Vec<u8>`.
637    let conversation_type_proto: xmtp_proto::xmtp::mls::message_contents::ConversationType =
638        legacy_metadata.conversation_type.into();
639    strict.insert(
640        ComponentId::CONVERSATION_TYPE,
641        (
642            AppDataUpdateOperationType::Update,
643            encode_conversation_type(conversation_type_proto as i32),
644        ),
645    );
646    // CREATOR_INBOX_ID rides the same versioned `InboxId` wire form
647    // (`varint(version) || 32-byte payload`) every other inbox-id-bearing
648    // component on the new path uses, so the bytes round-trip through
649    // the same decoder.
650    strict.insert(
651        ComponentId::CREATOR_INBOX_ID,
652        (
653            AppDataUpdateOperationType::Update,
654            InboxId::from_hex(&legacy_metadata.creator_inbox_id)?.tls_serialize_detached()?,
655        ),
656    );
657    if let Some(dm) = &legacy_metadata.dm_members {
658        strict.insert(
659            ComponentId::DM_MEMBERS,
660            (AppDataUpdateOperationType::Update, encode_dm_members(dm)?),
661        );
662    }
663    if let Some(oneshot) = &legacy_metadata.oneshot_message {
664        strict.insert(
665            ComponentId::ONESHOT_MESSAGE,
666            (AppDataUpdateOperationType::Update, oneshot.encode_to_vec()),
667        );
668    }
669
670    // GROUP_MEMBERSHIP: per-inbox sequence-id map keyed by [`InboxId`]
671    // (matches the `TlsMap<InboxId, VLBytes>` wire format).
672    let mut membership_sequence_ids: BTreeMap<InboxId, u64> = BTreeMap::new();
673    for (inbox_id_hex, seq) in legacy_membership.members.iter() {
674        let inbox_id = InboxId::from_hex(inbox_id_hex)?;
675        membership_sequence_ids.insert(inbox_id, *seq);
676    }
677
678    // Bound the universe of installation IDs the sender may legally
679    // emit into ANY per-inbox `failed_installations`. Each entry must
680    // be 32 bytes (Ed25519 installation-key size) — fail loud on
681    // anything else rather than silently admit it to the allow-set.
682    // Set semantics: the legacy field is `repeated bytes` so duplicates
683    // are possible but irrelevant for subset membership checks.
684    let mut allowed_failed_installations: BTreeSet<[u8; 32]> = BTreeSet::new();
685    for raw in &legacy_membership.failed_installations {
686        let key: [u8; 32] = raw
687            .as_slice()
688            .try_into()
689            .map_err(|_| MigrationError::InvalidFailedInstallationLength(raw.len()))?;
690        allowed_failed_installations.insert(key);
691    }
692
693    Ok(CanonicalBootstrapExpectation {
694        strict,
695        expected_registry,
696        membership_sequence_ids,
697        allowed_failed_installations,
698    })
699}
700
701/// Build a registry tailored to the legacy state in `extensions` —
702/// gates `DM_MEMBERS` / `ONESHOT_MESSAGE` on `GroupMetadata` presence so
703/// the registry bytes line up with the per-component entries the
704/// receiver will see in the bootstrap commit.
705fn synthesize_registry_from_extensions(
706    extensions: &Extensions<GroupContext>,
707) -> Result<ComponentRegistry, MigrationError> {
708    let policy_set_bytes = find_unknown_extension(
709        extensions,
710        xmtp_configuration::GROUP_PERMISSIONS_EXTENSION_ID,
711    )
712    .ok_or(MigrationError::MissingPolicyField(
713        "group_permissions extension",
714    ))?;
715    let permissions_proto =
716        xmtp_proto::xmtp::mls::message_contents::GroupMutablePermissionsV1::decode(
717            policy_set_bytes.as_slice(),
718        )
719        .map_err(MigrationError::GroupPermissionsDecode)?;
720    let policy_set = permissions_proto
721        .policies
722        .ok_or(MigrationError::MissingPolicyField("policies"))?;
723
724    let legacy_metadata = crate::group_metadata::GroupMetadata::try_from(extensions)?;
725    build_registry(
726        &policy_set,
727        legacy_metadata.dm_members.is_some(),
728        legacy_metadata.oneshot_message.is_some(),
729    )
730}
731
732fn find_unknown_extension(extensions: &Extensions<GroupContext>, id: u16) -> Option<&Vec<u8>> {
733    use openmls::extensions::{Extension, UnknownExtension};
734    extensions.iter().find_map(|extension| match extension {
735        Extension::Unknown(eid, UnknownExtension(data)) if *eid == id => Some(data),
736        _ => None,
737    })
738}
739
740fn extract_legacy_group_membership(
741    extensions: &Extensions<GroupContext>,
742) -> Result<GroupMembershipProto, MigrationError> {
743    let bytes = find_unknown_extension(
744        extensions,
745        xmtp_configuration::GROUP_MEMBERSHIP_EXTENSION_ID,
746    )
747    .ok_or(MigrationError::MissingGroupMembershipExtension)?;
748    Ok(GroupMembershipProto::decode(bytes.as_slice())?)
749}
750
751/// Translate a legacy `GroupMutableMetadata` attribute string into the
752/// AppData wire bytes for that component.
753///
754/// - `MESSAGE_DISAPPEAR_FROM_NS` / `MESSAGE_DISAPPEAR_IN_NS`: the legacy
755///   attribute is a decimal-stringified `i64`; emit 8 big-endian bytes.
756/// - String-typed attributes (group name/description/url/app data/min
757///   version): emit the raw UTF-8 bytes unchanged.
758fn encode_metadata_attribute_value(
759    component_id: ComponentId,
760    legacy_value: &str,
761) -> Result<Vec<u8>, MigrationError> {
762    match component_id {
763        ComponentId::MESSAGE_DISAPPEAR_FROM_NS => {
764            parse_disappearing_i64("messageDisappearFromNS", legacy_value)
765        }
766        ComponentId::MESSAGE_DISAPPEAR_IN_NS => {
767            parse_disappearing_i64("messageDisappearInNS", legacy_value)
768        }
769        _ => Ok(legacy_value.as_bytes().to_vec()),
770    }
771}
772
773fn parse_disappearing_i64(
774    field: &'static str,
775    legacy_value: &str,
776) -> Result<Vec<u8>, MigrationError> {
777    let n: i64 = legacy_value
778        .parse()
779        .map_err(
780            |err: std::num::ParseIntError| MigrationError::InvalidDisappearingTimestamp {
781                field,
782                value: legacy_value.to_string(),
783                reason: err.to_string(),
784            },
785        )?;
786    Ok(n.to_be_bytes().to_vec())
787}
788
789/// Encode hex inbox ids as a `TlsSet<InboxId>`. Must stay byte-identical
790/// to the bridge's `encode_inbox_id_set` or byte-compare validation
791/// fails.
792/// Encode an inbox-id set as a **bootstrap wire delta**: a
793/// `TlsSetDelta<InboxId>` of all-`Insert` mutations. The wire is
794/// always a delta; bootstrap is the case where the prior set is
795/// empty, so every mutation is an `Insert`. The dict stores the
796/// materialized `TlsSet` snapshot; receivers translate wire → dict
797/// via [`apply_wire_bytes`].
798fn encode_inbox_id_set(inbox_ids_hex: &[String]) -> Result<Vec<u8>, MigrationError> {
799    let ids: Vec<InboxId> = inbox_ids_hex
800        .iter()
801        .map(|s| InboxId::from_hex(s))
802        .collect::<Result<Vec<_>, _>>()?;
803    // Sort so the wire bytes are deterministic across senders. The
804    // canonical-subset validator byte-compares this output against
805    // the actual proposal, so non-determinism here would let an
806    // honest sender's payload mismatch the validator's expectation.
807    let mut sorted_ids: Vec<InboxId> = ids;
808    sorted_ids.sort();
809    sorted_ids.dedup();
810    let mut delta: TlsSetDelta<InboxId> = TlsSetDelta::new();
811    for id in sorted_ids {
812        delta = delta.insert(id);
813    }
814    Ok(delta.tls_serialize_detached()?)
815}
816
817/// Encode the DM's two members as a **bootstrap wire delta**: a
818/// `TlsSetDelta<InboxId>` of all-`Insert` mutations. (DM_MEMBERS is
819/// declared as `ComponentType::TlsSetInboxId`; the dict stores the
820/// materialized `TlsSet` snapshot.)
821fn encode_dm_members(
822    dm: &crate::group_metadata::DmMembers<xmtp_id::InboxId>,
823) -> Result<Vec<u8>, MigrationError> {
824    // Decode first, then compare on InboxId — hex strings can differ
825    // only in case ("ABC..." vs "abc...") and still represent the same
826    // inbox id. Self-DMs would otherwise slip past a string-compare
827    // and `TlsSet` would silently collapse to one element, losing
828    // fidelity.
829    let one_str: &str = dm.member_one_inbox_id.as_ref();
830    let two_str: &str = dm.member_two_inbox_id.as_ref();
831    let one = InboxId::from_hex(one_str)?;
832    let two = InboxId::from_hex(two_str)?;
833    if one == two {
834        // Include both raw inputs so case-divergent self-references
835        // ("ABC..." vs "abc...") are visible in logs without having
836        // to reproduce.
837        return Err(MigrationError::DmMembersSelfReference(format!(
838            "{} (member_one={one_str}, member_two={two_str})",
839            one.to_hex(),
840        )));
841    }
842    // Sort for deterministic wire bytes (validator byte-compare).
843    let (a, b) = if one <= two { (one, two) } else { (two, one) };
844    let delta = TlsSetDelta::<InboxId>::new().insert(a).insert(b);
845    Ok(delta.tls_serialize_detached()?)
846}
847
848/// `CONVERSATION_TYPE` payload codec: 4-byte big-endian `i32` matching
849/// `xmtp_proto::xmtp::mls::message_contents::ConversationType`.
850/// Fixed-width simplifies byte-compare validation.
851pub(crate) fn encode_conversation_type(value: i32) -> Vec<u8> {
852    value.to_be_bytes().to_vec()
853}
854
855/// Inverse of [`encode_conversation_type`]. Test-only by design: the
856/// receiver-side validator byte-compares the sender's CONVERSATION_TYPE
857/// payload against [`encode_conversation_type`]'s output without ever
858/// decoding it — equal bytes are semantically equal because the codec
859/// is fixed-width. Decode is only needed to round-trip-test the codec
860/// itself, so it stays gated behind `#[cfg(test)]` rather than leaking
861/// into production callers that might be tempted to re-decode (and
862/// then have to handle a wrong-length error path that the validator
863/// already rules out via byte-compare).
864#[cfg(test)]
865pub(crate) fn decode_conversation_type(bytes: &[u8]) -> Result<i32, MigrationError> {
866    let arr: [u8; 4] = bytes
867        .try_into()
868        .map_err(|_| MigrationError::ConversationTypePayloadLength(bytes.len()))?;
869    Ok(i32::from_be_bytes(arr))
870}
871
872/// Encode `GROUP_MEMBERSHIP` for the bootstrap **wire payload** as a
873/// `TlsMapDelta<InboxId, VLBytes>` of all-`Insert` mutations — one
874/// per inbox, each value a [`GroupMembershipEntry`] envelope
875/// (currently always wrapping a `V1`).
876///
877/// The wire is always a delta. Bootstrap is the case where the prior
878/// dict state is empty, so every mutation is an `Insert`. Steady-
879/// state updates use the same `TlsMapDelta` wire shape with mixed
880/// `Insert` / `Update` / `Delete` mutations describing only the
881/// inboxes that changed (see `update_group_membership.rs`).
882pub fn encode_group_membership_delta(
883    entries: &BTreeMap<InboxId, GroupMembershipEntry>,
884) -> Result<Vec<u8>, MigrationError> {
885    let mut delta: TlsMapDelta<InboxId, VLBytes> = TlsMapDelta::new();
886    for (inbox_id, entry) in entries {
887        delta = delta.insert(*inbox_id, VLBytes::new(entry.encode_to_vec()));
888    }
889    Ok(delta.tls_serialize_detached()?)
890}
891
892/// Decode a `GROUP_MEMBERSHIP` **wire payload** (a
893/// `TlsMapDelta<InboxId, VLBytes>` of all-`Insert` mutations against
894/// an empty prior — bootstrap shape) back to a
895/// `BTreeMap<InboxId, GroupMembershipEntry>`. Used by the bootstrap
896/// validator to inspect the proposal payload.
897pub fn decode_group_membership_delta(
898    bytes: &[u8],
899) -> Result<BTreeMap<InboxId, GroupMembershipEntry>, MigrationError> {
900    let delta = TlsMapDelta::<InboxId, VLBytes>::tls_deserialize_exact(bytes)?;
901    let mut out: BTreeMap<InboxId, GroupMembershipEntry> = BTreeMap::new();
902    for mutation in delta.mutations {
903        let (key, value) = match mutation {
904            TlsMapMutation::Insert { key, value } => (key, value),
905            TlsMapMutation::Update { .. } | TlsMapMutation::Delete { .. } => {
906                return Err(MigrationError::GroupMembershipNonInsertBootstrapMutation);
907            }
908        };
909        let envelope = GroupMembershipEntry::decode(value.as_slice())?;
910        if envelope.version.is_none() {
911            return Err(MigrationError::GroupMembershipEntryUnknownVersion);
912        }
913        if out.insert(key, envelope).is_some() {
914            return Err(MigrationError::GroupMembershipDuplicateInbox(key.to_hex()));
915        }
916    }
917    Ok(out)
918}
919
920/// Encode `GROUP_MEMBERSHIP` **dict-storage bytes** as a
921/// `TlsMap<InboxId, VLBytes>` snapshot. The dict always holds the
922/// raw map as state; this encoder is the symmetric of
923/// [`decode_group_membership_dict`] and is used by tests and by
924/// callers that need to construct expected dict bytes (the runtime
925/// path goes through `apply_app_data_update_payload`, which produces
926/// the same snapshot from a wire delta).
927pub fn encode_group_membership_dict(
928    entries: &BTreeMap<InboxId, GroupMembershipEntry>,
929) -> Result<Vec<u8>, MigrationError> {
930    let mut map: TlsMap<InboxId, VLBytes> = TlsMap::new();
931    for (inbox_id, entry) in entries {
932        map.insert(*inbox_id, VLBytes::new(entry.encode_to_vec()))
933            .map_err(|e| {
934                MigrationError::TlsCodec(tls_codec::Error::EncodingError(e.to_string()))
935            })?;
936    }
937    Ok(map.tls_serialize_detached()?)
938}
939
940/// Decode `GROUP_MEMBERSHIP` **dict-storage bytes** (a
941/// `TlsMap<InboxId, VLBytes>` snapshot) back to a `BTreeMap<InboxId,
942/// GroupMembershipEntry>`. Used by readers walking the AppData
943/// dictionary post-bootstrap.
944pub fn decode_group_membership_dict(
945    bytes: &[u8],
946) -> Result<BTreeMap<InboxId, GroupMembershipEntry>, MigrationError> {
947    let snapshot = TlsMap::<InboxId, VLBytes>::tls_deserialize_exact(bytes)?;
948    let mut out: BTreeMap<InboxId, GroupMembershipEntry> = BTreeMap::new();
949    for (key, value) in snapshot.into_iter() {
950        let envelope = GroupMembershipEntry::decode(value.as_slice())?;
951        if envelope.version.is_none() {
952            return Err(MigrationError::GroupMembershipEntryUnknownVersion);
953        }
954        out.insert(key, envelope);
955    }
956    Ok(out)
957}
958
959#[cfg(test)]
960mod tests {
961    use super::*;
962    use crate::inbox_id::INBOX_ID_BYTE_LEN;
963    use xmtp_proto::xmtp::mls::message_contents::{
964        MembershipPolicy as MembershipPolicyProto, MetadataPolicy as MetadataPolicyProto,
965        PermissionsUpdatePolicy as PermissionsUpdatePolicyProto,
966        group_membership_entry::{
967            V1 as GroupMembershipEntryV1, Version as GroupMembershipEntryVersion,
968        },
969        membership_policy::{BasePolicy as MembershipBase, Kind as MembershipKind},
970        metadata_policy::Kind as MetadataKind,
971        permissions_update_policy::Kind as PermissionsKind,
972    };
973
974    fn allow_metadata() -> MetadataPolicyProto {
975        MetadataPolicyProto {
976            kind: Some(MetadataKind::Base(MetadataBasePolicy::Allow as i32)),
977        }
978    }
979    fn allow_if_admin_metadata() -> MetadataPolicyProto {
980        MetadataPolicyProto {
981            kind: Some(MetadataKind::Base(MetadataBasePolicy::AllowIfAdmin as i32)),
982        }
983    }
984    fn allow_if_super_admin_metadata() -> MetadataPolicyProto {
985        MetadataPolicyProto {
986            kind: Some(MetadataKind::Base(
987                MetadataBasePolicy::AllowIfSuperAdmin as i32,
988            )),
989        }
990    }
991    fn admin_only_perms() -> PermissionsUpdatePolicyProto {
992        PermissionsUpdatePolicyProto {
993            kind: Some(PermissionsKind::Base(
994                PermissionsBasePolicy::AllowIfAdmin as i32,
995            )),
996        }
997    }
998    fn super_admin_only_perms() -> PermissionsUpdatePolicyProto {
999        PermissionsUpdatePolicyProto {
1000            kind: Some(PermissionsKind::Base(
1001                PermissionsBasePolicy::AllowIfSuperAdmin as i32,
1002            )),
1003        }
1004    }
1005    fn allow_membership() -> MembershipPolicyProto {
1006        MembershipPolicyProto {
1007            kind: Some(MembershipKind::Base(MembershipBase::Allow as i32)),
1008        }
1009    }
1010
1011    fn minimal_default_policy_set() -> PolicySetProto {
1012        PolicySetProto {
1013            add_member_policy: Some(allow_membership()),
1014            remove_member_policy: Some(allow_membership()),
1015            update_metadata_policy: std::collections::HashMap::new(),
1016            add_admin_policy: Some(admin_only_perms()),
1017            remove_admin_policy: Some(admin_only_perms()),
1018            update_permissions_policy: Some(super_admin_only_perms()),
1019        }
1020    }
1021
1022    #[test]
1023    fn synthesizes_all_well_known_components() {
1024        let registry = synthesize_registry_from_policy_set(&minimal_default_policy_set()).unwrap();
1025        // Mutable scalar family.
1026        for (_, id, _) in metadata_field_registry_mapping() {
1027            assert!(registry.contains(id), "missing {}", id);
1028        }
1029        // COMMIT_LOG_SIGNER.
1030        assert!(registry.contains(&ComponentId::COMMIT_LOG_SIGNER));
1031        // ADMIN_LIST + GROUP_MEMBERSHIP.
1032        assert!(registry.contains(&ComponentId::ADMIN_LIST));
1033        assert!(registry.contains(&ComponentId::GROUP_MEMBERSHIP));
1034        // Immutable seeds.
1035        assert!(registry.contains(&ComponentId::CONVERSATION_TYPE));
1036        assert!(registry.contains(&ComponentId::CREATOR_INBOX_ID));
1037        assert!(registry.contains(&ComponentId::DM_MEMBERS));
1038        assert!(registry.contains(&ComponentId::ONESHOT_MESSAGE));
1039        // Hardcoded are NOT in the registry.
1040        assert!(!registry.contains(&ComponentId::SUPER_ADMIN_LIST));
1041        assert!(!registry.contains(&ComponentId::COMPONENT_REGISTRY));
1042    }
1043
1044    #[test]
1045    fn synthesis_defaults_to_admin_for_missing_metadata_fields() {
1046        // A field absent from the legacy `update_metadata_policy` map must
1047        // mirror the legacy enforcer's missing-field fallback (admin-only),
1048        // NOT `Allow` — otherwise migrating a group that predates the field
1049        // silently downgrades it to anyone-editable.
1050        let registry = synthesize_registry_from_policy_set(&minimal_default_policy_set()).unwrap();
1051        let meta = registry.get(&ComponentId::GROUP_NAME).unwrap().unwrap();
1052        let perms = meta.permissions.unwrap();
1053        assert_eq!(perms.insert_policy.unwrap(), allow_if_admin_metadata());
1054        assert_eq!(perms.update_policy.unwrap(), allow_if_admin_metadata());
1055    }
1056
1057    #[test]
1058    fn synthesis_defaults_min_version_floor_to_super_admin_when_missing() {
1059        // The monotonic 0x800A protocol-version floor is a group-wedge lever:
1060        // a group whose stored PolicySet omits it (created before the field
1061        // existed, or every DM created before it) must NOT let a non-super-
1062        // admin raise it, or any member could pause the group permanently.
1063        let registry = synthesize_registry_from_policy_set(&minimal_default_policy_set()).unwrap();
1064        let meta = registry
1065            .get(&ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION)
1066            .unwrap()
1067            .unwrap();
1068        let perms = meta.permissions.unwrap();
1069        assert_eq!(
1070            perms.insert_policy.unwrap(),
1071            allow_if_super_admin_metadata()
1072        );
1073        assert_eq!(
1074            perms.update_policy.unwrap(),
1075            allow_if_super_admin_metadata()
1076        );
1077    }
1078
1079    #[test]
1080    fn synthesis_preserves_a_stored_min_version_policy() {
1081        // The super-admin default only fills a MISSING key; a stored floor
1082        // policy (e.g. a DM's `Allow` from `dm_map`) is carried verbatim so
1083        // migration stays faithful rather than unfaithfully tightening it.
1084        let mut ps = minimal_default_policy_set();
1085        ps.update_metadata_policy.insert(
1086            MetadataField::MinimumSupportedProtocolVersion
1087                .as_str()
1088                .to_string(),
1089            allow_metadata(),
1090        );
1091        let registry = synthesize_registry_from_policy_set(&ps).unwrap();
1092        let meta = registry
1093            .get(&ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION)
1094            .unwrap()
1095            .unwrap();
1096        assert_eq!(
1097            meta.permissions.unwrap().insert_policy.unwrap(),
1098            allow_metadata()
1099        );
1100    }
1101
1102    #[test]
1103    fn synthesis_uses_per_field_policy_when_present() {
1104        let mut ps = minimal_default_policy_set();
1105        ps.update_metadata_policy
1106            .insert("group_name".to_string(), allow_if_admin_metadata());
1107        let registry = synthesize_registry_from_policy_set(&ps).unwrap();
1108        let meta = registry.get(&ComponentId::GROUP_NAME).unwrap().unwrap();
1109        assert_eq!(
1110            meta.permissions.clone().unwrap().insert_policy.unwrap(),
1111            allow_if_admin_metadata()
1112        );
1113        // Description, still absent from the map, defaults to admin-only.
1114        let desc = registry
1115            .get(&ComponentId::GROUP_DESCRIPTION)
1116            .unwrap()
1117            .unwrap();
1118        assert_eq!(
1119            desc.permissions.unwrap().insert_policy.unwrap(),
1120            allow_if_admin_metadata()
1121        );
1122    }
1123
1124    #[test]
1125    fn synthesis_rejects_unknown_metadata_field() {
1126        let mut ps = minimal_default_policy_set();
1127        ps.update_metadata_policy
1128            .insert("something_new".to_string(), allow_metadata());
1129        let err = synthesize_registry_from_policy_set(&ps).unwrap_err();
1130        assert!(matches!(err, MigrationError::UnknownMetadataField(f) if f == "something_new"));
1131    }
1132
1133    #[test]
1134    fn synthesis_rejects_non_super_admin_update_permissions() {
1135        let mut ps = minimal_default_policy_set();
1136        ps.update_permissions_policy = Some(admin_only_perms());
1137        let err = synthesize_registry_from_policy_set(&ps).unwrap_err();
1138        assert!(matches!(
1139            err,
1140            MigrationError::UpdatePermissionsNotSuperAdmin(_)
1141        ));
1142    }
1143
1144    #[test]
1145    fn synthesis_admin_list_super_admin_only() {
1146        let mut ps = minimal_default_policy_set();
1147        ps.add_admin_policy = Some(super_admin_only_perms());
1148        ps.remove_admin_policy = Some(super_admin_only_perms());
1149        let registry = synthesize_registry_from_policy_set(&ps).unwrap();
1150        let admin = registry.get(&ComponentId::ADMIN_LIST).unwrap().unwrap();
1151        let perms = admin.permissions.unwrap();
1152        assert_eq!(
1153            perms.insert_policy.unwrap(),
1154            MetadataPolicyProto {
1155                kind: Some(MetadataKind::Base(
1156                    MetadataBasePolicy::AllowIfSuperAdmin as i32
1157                ))
1158            }
1159        );
1160    }
1161
1162    #[xmtp_common::test(unwrap_try = true)]
1163    fn metadata_field_mapping_agrees_with_dispatch_table() {
1164        // The `metadata_field_registry_mapping` table duplicates
1165        // ComponentType info that `WELL_KNOWN` now also carries (one
1166        // entry per `Component` impl). Pin the invariant: if a
1167        // future change drifts one of the tables out of sync (e.g.
1168        // changes a Bytes component to String only in WELL_KNOWN),
1169        // this test catches it before any commit goes out.
1170        use crate::app_data::registry_table::lookup_component;
1171        for (_field, component_id, expected_type) in metadata_field_registry_mapping() {
1172            let dispatched = lookup_component(*component_id)
1173                .unwrap_or_else(|| panic!("WELL_KNOWN missing entry for {component_id}"));
1174            assert_eq!(
1175                dispatched.component_type(),
1176                *expected_type,
1177                "metadata_field_registry_mapping disagrees with WELL_KNOWN for {component_id}"
1178            );
1179        }
1180    }
1181
1182    #[test]
1183    fn synthesis_sets_correct_component_type_per_field() {
1184        // Disappearing-message timestamps round-trip as BE-u64 bytes;
1185        // every other mutable scalar is utf-8 string. Keep this test in
1186        // lockstep with `metadata_field_registry_mapping`.
1187        let registry = synthesize_registry_from_policy_set(&minimal_default_policy_set()).unwrap();
1188        let expected: &[(ComponentId, ComponentType)] = &[
1189            (ComponentId::GROUP_NAME, ComponentType::String),
1190            (ComponentId::GROUP_DESCRIPTION, ComponentType::String),
1191            (ComponentId::GROUP_IMAGE_URL, ComponentType::String),
1192            (ComponentId::APP_DATA, ComponentType::String),
1193            (
1194                ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION,
1195                ComponentType::String,
1196            ),
1197            (ComponentId::MESSAGE_DISAPPEAR_FROM_NS, ComponentType::Bytes),
1198            (ComponentId::MESSAGE_DISAPPEAR_IN_NS, ComponentType::Bytes),
1199        ];
1200        for (id, ty) in expected {
1201            let meta = registry.get(id).unwrap().unwrap();
1202            assert_eq!(
1203                meta.component_type, *ty as i32,
1204                "wrong component_type for {id}"
1205            );
1206        }
1207    }
1208
1209    #[test]
1210    fn synthesis_deterministic_bytes() {
1211        // Bit-identical output from two calls on the same input is the
1212        // foundation invariant for byte-compare validation.
1213        let a = synthesize_registry_from_policy_set(&minimal_default_policy_set()).unwrap();
1214        let b = synthesize_registry_from_policy_set(&minimal_default_policy_set()).unwrap();
1215        assert_eq!(a.to_bytes().unwrap(), b.to_bytes().unwrap());
1216    }
1217
1218    #[test]
1219    fn membership_policy_rejects_combinator() {
1220        use xmtp_proto::xmtp::mls::message_contents::{
1221            MembershipPolicy as MembershipPolicyProto,
1222            membership_policy::{AndCondition as AndCondProto, Kind as MembershipKind},
1223        };
1224        let combinator = MembershipPolicyProto {
1225            kind: Some(MembershipKind::AndCondition(AndCondProto {
1226                policies: vec![],
1227            })),
1228        };
1229        let err = membership_policy_to_metadata_policy(&combinator).unwrap_err();
1230        assert!(matches!(err, MigrationError::UnknownMembershipPolicy(None)));
1231    }
1232
1233    #[test]
1234    fn membership_policy_rejects_unknown_base() {
1235        use xmtp_proto::xmtp::mls::message_contents::{
1236            MembershipPolicy as MembershipPolicyProto, membership_policy::Kind as MembershipKind,
1237        };
1238        let unknown = MembershipPolicyProto {
1239            kind: Some(MembershipKind::Base(9999)),
1240        };
1241        let err = membership_policy_to_metadata_policy(&unknown).unwrap_err();
1242        assert!(matches!(
1243            err,
1244            MigrationError::UnknownMembershipPolicy(Some(9999))
1245        ));
1246    }
1247
1248    #[test]
1249    fn group_membership_encode_round_trip() {
1250        let mut entries: BTreeMap<InboxId, GroupMembershipEntryV1> = BTreeMap::new();
1251        entries.insert(
1252            InboxId::from_bytes([0x01; 32]),
1253            GroupMembershipEntryV1 {
1254                sequence_id: 42,
1255                failed_installations: vec![vec![0xAA; 16]],
1256            },
1257        );
1258        entries.insert(
1259            InboxId::from_bytes([0x02; 32]),
1260            GroupMembershipEntryV1 {
1261                sequence_id: 99,
1262                failed_installations: vec![],
1263            },
1264        );
1265        let entries = entries
1266            .into_iter()
1267            .map(|(k, v)| {
1268                (
1269                    k,
1270                    GroupMembershipEntry {
1271                        version: Some(GroupMembershipEntryVersion::V1(v)),
1272                    },
1273                )
1274            })
1275            .collect::<BTreeMap<_, _>>();
1276        // Wire round-trip: encode as bootstrap delta, decode as
1277        // delta. Used by sender synthesis ↔ validator.
1278        let wire_bytes = encode_group_membership_delta(&entries).unwrap();
1279        let decoded = decode_group_membership_delta(&wire_bytes).unwrap();
1280        assert_eq!(decoded, entries);
1281    }
1282
1283    #[test]
1284    fn admin_list_policy_rejects_combinator() {
1285        use xmtp_proto::xmtp::mls::message_contents::{
1286            PermissionsUpdatePolicy as PermissionsUpdatePolicyProto,
1287            permissions_update_policy::{AndCondition as AndCondProto, Kind as PermissionsKind},
1288        };
1289        let combinator = PermissionsUpdatePolicyProto {
1290            kind: Some(PermissionsKind::AndCondition(AndCondProto {
1291                policies: vec![],
1292            })),
1293        };
1294        let err = admin_list_policy_to_metadata_policy(&combinator).unwrap_err();
1295        assert!(matches!(
1296            err,
1297            MigrationError::NonConstrainedAdminPolicy(None)
1298        ));
1299    }
1300
1301    #[test]
1302    fn decode_wire_rejects_unset_version() {
1303        // Encode a wire delta with one Insert whose envelope carries
1304        // `version: None`. The wire decoder must surface
1305        // `GroupMembershipEntryUnknownVersion` rather than silently
1306        // treating the entry as empty.
1307        let envelope = GroupMembershipEntry { version: None };
1308        let delta = TlsMapDelta::<InboxId, VLBytes>::new().insert(
1309            InboxId::from_bytes([0x04; 32]),
1310            VLBytes::new(envelope.encode_to_vec()),
1311        );
1312        let bytes = delta.tls_serialize_detached().unwrap();
1313        let err = decode_group_membership_delta(&bytes).unwrap_err();
1314        assert!(matches!(
1315            err,
1316            MigrationError::GroupMembershipEntryUnknownVersion
1317        ));
1318    }
1319
1320    #[test]
1321    fn decode_wire_rejects_non_insert_mutation() {
1322        // Bootstrap is delta-from-empty — `Update` or `Delete` against
1323        // an empty prior is meaningless and must be rejected.
1324        let delta = TlsMapDelta::<InboxId, VLBytes>::new().delete(InboxId::from_bytes([0x05; 32]));
1325        let bytes = delta.tls_serialize_detached().unwrap();
1326        let err = decode_group_membership_delta(&bytes).unwrap_err();
1327        assert!(matches!(
1328            err,
1329            MigrationError::GroupMembershipNonInsertBootstrapMutation
1330        ));
1331    }
1332
1333    #[test]
1334    fn decode_dict_round_trips() {
1335        // Dict storage uses the materialized `TlsMap` snapshot, not
1336        // the wire delta. Verify the dict decoder reads what the dict
1337        // would actually contain post-apply.
1338        let mut snapshot: TlsMap<InboxId, VLBytes> = TlsMap::new();
1339        let inbox = InboxId::from_bytes([0x06; 32]);
1340        let envelope = GroupMembershipEntry {
1341            version: Some(GroupMembershipEntryVersion::V1(GroupMembershipEntryV1 {
1342                sequence_id: 7,
1343                failed_installations: vec![],
1344            })),
1345        };
1346        snapshot
1347            .insert(inbox, VLBytes::new(envelope.encode_to_vec()))
1348            .unwrap();
1349        let bytes = snapshot.tls_serialize_detached().unwrap();
1350        let decoded = decode_group_membership_dict(&bytes).unwrap();
1351        assert_eq!(decoded.len(), 1);
1352        assert!(decoded.contains_key(&inbox));
1353    }
1354
1355    // ========================================================================
1356    // Wire-format / codec coverage for the bootstrap canonical subset
1357    // ========================================================================
1358    //
1359    // These pin the byte shape each component produces so a future tweak
1360    // to the encoder can't silently break byte-identity between sender
1361    // synthesis and the receiver's byte-compare validation.
1362
1363    fn hex_inbox(tag: u8) -> String {
1364        hex::encode([tag; INBOX_ID_BYTE_LEN])
1365    }
1366
1367    #[test]
1368    fn encode_inbox_id_set_emits_bootstrap_wire_delta() {
1369        // Two inbox ids → `TlsSetDelta<InboxId>` of all-`Insert`
1370        // mutations (bootstrap = delta-from-empty against an empty
1371        // prior). The wire is always a delta; bootstrap is the case
1372        // where every mutation happens to be an Insert. Each `InboxId`
1373        // on the wire is `varint(0) || 32 raw bytes`.
1374        use crate::tls_set::TlsSetMutation;
1375        let ids = vec![hex_inbox(0xAA), hex_inbox(0xBB)];
1376        let bytes = encode_inbox_id_set(&ids).unwrap();
1377        let delta = TlsSetDelta::<InboxId>::tls_deserialize_exact(&bytes)
1378            .expect("decodes as TlsSetDelta<InboxId>");
1379        assert_eq!(delta.mutations.len(), 2);
1380        // Sorted ascending so the wire bytes are deterministic.
1381        for (mutation, expected_tag) in delta.mutations.iter().zip([0xAA, 0xBB]) {
1382            match mutation {
1383                TlsSetMutation::Insert(id) => {
1384                    assert_eq!(id.as_bytes(), &[expected_tag; INBOX_ID_BYTE_LEN]);
1385                }
1386                other => panic!("expected Insert, got {other:?}"),
1387            }
1388        }
1389    }
1390
1391    #[test]
1392    fn encode_inbox_id_set_rejects_bad_hex() {
1393        let err = encode_inbox_id_set(&["not-hex".to_string()]).unwrap_err();
1394        assert!(matches!(err, MigrationError::InvalidInboxId(_)));
1395    }
1396
1397    #[test]
1398    fn encode_dm_members_produces_two_insert_delta() {
1399        use crate::tls_set::TlsSetMutation;
1400        let dm = crate::group_metadata::DmMembers {
1401            member_one_inbox_id: hex_inbox(0xCC),
1402            member_two_inbox_id: hex_inbox(0xDD),
1403        };
1404        let bytes = encode_dm_members(&dm).unwrap();
1405        let delta = TlsSetDelta::<InboxId>::tls_deserialize_exact(&bytes).unwrap();
1406        assert_eq!(delta.mutations.len(), 2);
1407        for (mutation, expected_tag) in delta.mutations.iter().zip([0xCC, 0xDD]) {
1408            match mutation {
1409                TlsSetMutation::Insert(id) => {
1410                    assert_eq!(id.as_bytes(), &[expected_tag; INBOX_ID_BYTE_LEN]);
1411                }
1412                other => panic!("expected Insert, got {other:?}"),
1413            }
1414        }
1415    }
1416
1417    #[test]
1418    fn encode_dm_members_rejects_self_reference() {
1419        // `TlsSet<InboxId>` dedupes by value. A self-DM (both slots
1420        // identical) would silently collapse to a one-element set —
1421        // fail loud instead so the fidelity loss is visible.
1422        let id = hex_inbox(0xEE);
1423        let dm = crate::group_metadata::DmMembers {
1424            member_one_inbox_id: id.clone(),
1425            member_two_inbox_id: id.clone(),
1426        };
1427        let err = encode_dm_members(&dm).unwrap_err();
1428        let MigrationError::DmMembersSelfReference(msg) = err else {
1429            panic!("expected DmMembersSelfReference, got {err:?}");
1430        };
1431        // Message carries the canonical hex plus both raw inputs.
1432        assert!(msg.starts_with(&id), "missing canonical hex: {msg}");
1433        assert!(msg.contains(&format!("member_one={id}")), "{msg}");
1434        assert!(msg.contains(&format!("member_two={id}")), "{msg}");
1435    }
1436
1437    #[test]
1438    fn encode_dm_members_rejects_case_divergent_self_reference() {
1439        // Hex encoding is case-insensitive, so two strings with
1440        // different cases can name the same inbox id. A naive
1441        // string-compare would miss this and `TlsSet` would silently
1442        // collapse the duplicate. Decode-then-compare catches it, and
1443        // the error carries both raw inputs so the case divergence is
1444        // visible in logs without reproducing.
1445        let lower = hex_inbox(0xEE);
1446        let upper = lower.to_ascii_uppercase();
1447        assert_ne!(lower, upper, "test premise: strings must differ");
1448        let dm = crate::group_metadata::DmMembers {
1449            member_one_inbox_id: lower.clone(),
1450            member_two_inbox_id: upper.clone(),
1451        };
1452        let err = encode_dm_members(&dm).unwrap_err();
1453        let MigrationError::DmMembersSelfReference(msg) = err else {
1454            panic!("expected DmMembersSelfReference, got {err:?}");
1455        };
1456        // Canonical (lowercase) hex first, then both raw inputs as
1457        // observed — proves we kept fidelity for log inspection.
1458        assert!(msg.starts_with(&lower), "missing canonical hex: {msg}");
1459        assert!(msg.contains(&format!("member_one={lower}")), "{msg}");
1460        assert!(msg.contains(&format!("member_two={upper}")), "{msg}");
1461    }
1462
1463    #[test]
1464    fn conversation_type_codec_round_trips() {
1465        // 0=Unspecified, 1=Group, 2=Dm today, plus a negative to pin
1466        // the two's-complement representation in case the enum is ever
1467        // widened.
1468        for v in [0_i32, 1, 2, -1, i32::MAX, i32::MIN] {
1469            let bytes = encode_conversation_type(v);
1470            assert_eq!(bytes.len(), 4, "always 4 bytes");
1471            assert_eq!(decode_conversation_type(&bytes).unwrap(), v);
1472        }
1473    }
1474
1475    #[test]
1476    fn conversation_type_decode_rejects_wrong_length() {
1477        let err = decode_conversation_type(&[0, 0, 0]).unwrap_err();
1478        assert!(matches!(
1479            err,
1480            MigrationError::ConversationTypePayloadLength(3)
1481        ));
1482        let err = decode_conversation_type(&[0; 8]).unwrap_err();
1483        assert!(matches!(
1484            err,
1485            MigrationError::ConversationTypePayloadLength(8)
1486        ));
1487    }
1488
1489    // ========================================================================
1490    // End-to-end canonical-subset coverage via synthetic Extensions
1491    // ========================================================================
1492
1493    /// Synthetic `Extensions<GroupContext>` with the four legacy
1494    /// extensions that bootstrap synthesis reads.
1495    fn build_test_extensions(
1496        gmm: crate::group_mutable_metadata::GroupMutableMetadata,
1497        policy_set: PolicySetProto,
1498        membership: xmtp_proto::xmtp::mls::message_contents::GroupMembership,
1499        metadata: crate::group_metadata::GroupMetadata,
1500    ) -> Extensions<GroupContext> {
1501        use openmls::extensions::{Extension, Metadata, UnknownExtension};
1502        use xmtp_configuration::{
1503            GROUP_MEMBERSHIP_EXTENSION_ID, GROUP_PERMISSIONS_EXTENSION_ID,
1504            MUTABLE_METADATA_EXTENSION_ID,
1505        };
1506        use xmtp_proto::xmtp::mls::message_contents::GroupMutablePermissionsV1;
1507
1508        let gmm_bytes: Vec<u8> = gmm.try_into().unwrap();
1509        let permissions_bytes = GroupMutablePermissionsV1 {
1510            policies: Some(policy_set),
1511        }
1512        .encode_to_vec();
1513        let membership_bytes = membership.encode_to_vec();
1514        let metadata_bytes: Vec<u8> = metadata.try_into().unwrap();
1515
1516        Extensions::from_vec(vec![
1517            Extension::Unknown(MUTABLE_METADATA_EXTENSION_ID, UnknownExtension(gmm_bytes)),
1518            Extension::Unknown(
1519                GROUP_PERMISSIONS_EXTENSION_ID,
1520                UnknownExtension(permissions_bytes),
1521            ),
1522            Extension::Unknown(
1523                GROUP_MEMBERSHIP_EXTENSION_ID,
1524                UnknownExtension(membership_bytes),
1525            ),
1526            Extension::ImmutableMetadata(Metadata::new(metadata_bytes)),
1527        ])
1528        .unwrap()
1529    }
1530
1531    fn default_gmm() -> crate::group_mutable_metadata::GroupMutableMetadata {
1532        crate::group_mutable_metadata::GroupMutableMetadata::new(
1533            std::collections::HashMap::new(),
1534            Vec::new(),
1535            Vec::new(),
1536        )
1537    }
1538
1539    fn empty_membership() -> xmtp_proto::xmtp::mls::message_contents::GroupMembership {
1540        xmtp_proto::xmtp::mls::message_contents::GroupMembership {
1541            members: std::collections::HashMap::new(),
1542            failed_installations: vec![],
1543        }
1544    }
1545
1546    fn plain_group_metadata() -> crate::group_metadata::GroupMetadata {
1547        crate::group_metadata::GroupMetadata::new(
1548            xmtp_proto::types::ConversationType::Group,
1549            hex_inbox(0x11),
1550            None,
1551            None,
1552        )
1553    }
1554
1555    #[test]
1556    fn canonical_subset_empty_group_omits_optional_seeds() {
1557        // Non-DM, non-oneshot group. DM_MEMBERS and ONESHOT_MESSAGE
1558        // must be absent from BOTH the strict byte-compare map and the
1559        // registry bytes — any asymmetry between them would trip the
1560        // receiver-side byte-compare check.
1561        let exts = build_test_extensions(
1562            default_gmm(),
1563            minimal_default_policy_set(),
1564            empty_membership(),
1565            plain_group_metadata(),
1566        );
1567        let subset = synthesize_canonical_subset_from_extensions(&exts).unwrap();
1568
1569        // Strict table contains every always-present component, but NOT
1570        // COMPONENT_REGISTRY (which compares semantically via
1571        // `expected_registry`).
1572        assert!(!subset.strict.contains_key(&ComponentId::COMPONENT_REGISTRY));
1573        assert!(subset.strict.contains_key(&ComponentId::ADMIN_LIST));
1574        assert!(subset.strict.contains_key(&ComponentId::SUPER_ADMIN_LIST));
1575        assert!(subset.strict.contains_key(&ComponentId::CONVERSATION_TYPE));
1576        assert!(subset.strict.contains_key(&ComponentId::CREATOR_INBOX_ID));
1577
1578        // Optional seeds gated on presence:
1579        // - DM_MEMBERS / ONESHOT_MESSAGE: only present for DM / oneshot groups.
1580        // - The `GroupMutableMetadata`-backed bytes/string attributes
1581        //   (GROUP_NAME, GROUP_DESCRIPTION, GROUP_IMAGE_URL,
1582        //   MESSAGE_DISAPPEAR_*, COMMIT_LOG_SIGNER, APP_DATA,
1583        //   MIN_SUPPORTED_PROTOCOL_VERSION) are seeded only when the
1584        //   legacy GMM has a value for them. An empty GMM produces no
1585        //   seed entries, matching the legacy reader semantics where
1586        //   "absent" surfaces as `MissingExtension` rather than as an
1587        //   empty value.
1588        assert!(!subset.strict.contains_key(&ComponentId::DM_MEMBERS));
1589        assert!(!subset.strict.contains_key(&ComponentId::ONESHOT_MESSAGE));
1590        assert!(!subset.strict.contains_key(&ComponentId::GROUP_NAME));
1591        assert!(
1592            !subset
1593                .strict
1594                .contains_key(&ComponentId::MESSAGE_DISAPPEAR_FROM_NS)
1595        );
1596        assert!(!subset.strict.contains_key(&ComponentId::COMMIT_LOG_SIGNER));
1597
1598        // The expected_registry must agree with the optional-seed
1599        // gating: no DM_MEMBERS / ONESHOT_MESSAGE entry, so the
1600        // semantic validator on the receiver side sees a symmetric
1601        // picture.
1602        assert!(
1603            !subset
1604                .expected_registry
1605                .contains_key(&ComponentId::DM_MEMBERS)
1606        );
1607        assert!(
1608            !subset
1609                .expected_registry
1610                .contains_key(&ComponentId::ONESHOT_MESSAGE)
1611        );
1612
1613        assert!(subset.membership_sequence_ids.is_empty());
1614    }
1615
1616    #[test]
1617    fn canonical_subset_dm_group_includes_dm_members() {
1618        let dm_members = crate::group_metadata::DmMembers {
1619            member_one_inbox_id: hex_inbox(0x22),
1620            member_two_inbox_id: hex_inbox(0x33),
1621        };
1622        let metadata = crate::group_metadata::GroupMetadata::new(
1623            xmtp_proto::types::ConversationType::Dm,
1624            hex_inbox(0x22),
1625            Some(dm_members.clone()),
1626            None,
1627        );
1628        let exts = build_test_extensions(
1629            default_gmm(),
1630            minimal_default_policy_set(),
1631            empty_membership(),
1632            metadata,
1633        );
1634        let subset = synthesize_canonical_subset_from_extensions(&exts).unwrap();
1635
1636        let (_, dm_bytes) = subset
1637            .strict
1638            .get(&ComponentId::DM_MEMBERS)
1639            .expect("DM group must include DM_MEMBERS in strict");
1640        assert_eq!(*dm_bytes, encode_dm_members(&dm_members).unwrap());
1641
1642        assert!(
1643            subset
1644                .expected_registry
1645                .contains_key(&ComponentId::DM_MEMBERS),
1646            "expected_registry must keep DM_MEMBERS for a DM group"
1647        );
1648    }
1649
1650    #[test]
1651    fn canonical_subset_oneshot_group_includes_oneshot() {
1652        use xmtp_proto::xmtp::mls::message_contents::OneshotMessage;
1653        // An empty `OneshotMessage` is enough to exercise the
1654        // presence-gated path — the wire bytes we assert on are the
1655        // prost encoding of whatever proto we feed in, not any
1656        // particular content shape.
1657        let oneshot = OneshotMessage { message_type: None };
1658        let metadata = crate::group_metadata::GroupMetadata::new(
1659            xmtp_proto::types::ConversationType::Group,
1660            hex_inbox(0x44),
1661            None,
1662            Some(oneshot.clone()),
1663        );
1664        let exts = build_test_extensions(
1665            default_gmm(),
1666            minimal_default_policy_set(),
1667            empty_membership(),
1668            metadata,
1669        );
1670        let subset = synthesize_canonical_subset_from_extensions(&exts).unwrap();
1671
1672        let (_, oneshot_bytes) = subset
1673            .strict
1674            .get(&ComponentId::ONESHOT_MESSAGE)
1675            .expect("oneshot group must include ONESHOT_MESSAGE");
1676        assert_eq!(*oneshot_bytes, oneshot.encode_to_vec());
1677
1678        assert!(
1679            subset
1680                .expected_registry
1681                .contains_key(&ComponentId::ONESHOT_MESSAGE)
1682        );
1683    }
1684
1685    #[test]
1686    fn canonical_subset_membership_sequence_ids() {
1687        let mut members = std::collections::HashMap::new();
1688        members.insert(hex_inbox(0x55), 7_u64);
1689        members.insert(hex_inbox(0x66), 42_u64);
1690        let membership = xmtp_proto::xmtp::mls::message_contents::GroupMembership {
1691            members,
1692            failed_installations: vec![],
1693        };
1694
1695        let exts = build_test_extensions(
1696            default_gmm(),
1697            minimal_default_policy_set(),
1698            membership,
1699            plain_group_metadata(),
1700        );
1701        let subset = synthesize_canonical_subset_from_extensions(&exts).unwrap();
1702
1703        assert_eq!(subset.membership_sequence_ids.len(), 2);
1704        assert_eq!(
1705            subset
1706                .membership_sequence_ids
1707                .get(&InboxId::from_bytes([0x55; 32]))
1708                .copied(),
1709            Some(7)
1710        );
1711        assert_eq!(
1712            subset
1713                .membership_sequence_ids
1714                .get(&InboxId::from_bytes([0x66; 32]))
1715                .copied(),
1716            Some(42)
1717        );
1718        // Empty legacy `failed_installations` → empty allow-set.
1719        assert!(subset.allowed_failed_installations.is_empty());
1720    }
1721
1722    #[test]
1723    fn canonical_subset_collects_legacy_failed_installations_into_allow_set() {
1724        // Bound the sender's blast radius: every per-inbox
1725        // `failed_installations` byte-string the sender ships must come
1726        // from this set. Duplicates in the legacy list collapse — the
1727        // contract is set membership, not multiset.
1728        let installation_a = [0xAA_u8; 32];
1729        let installation_b = [0xBB_u8; 32];
1730        let membership = xmtp_proto::xmtp::mls::message_contents::GroupMembership {
1731            members: std::collections::HashMap::new(),
1732            failed_installations: vec![
1733                installation_a.to_vec(),
1734                installation_b.to_vec(),
1735                installation_a.to_vec(), // duplicate — collapses in BTreeSet
1736            ],
1737        };
1738        let exts = build_test_extensions(
1739            default_gmm(),
1740            minimal_default_policy_set(),
1741            membership,
1742            plain_group_metadata(),
1743        );
1744        let subset = synthesize_canonical_subset_from_extensions(&exts).unwrap();
1745
1746        assert_eq!(subset.allowed_failed_installations.len(), 2);
1747        assert!(
1748            subset
1749                .allowed_failed_installations
1750                .contains(&installation_a)
1751        );
1752        assert!(
1753            subset
1754                .allowed_failed_installations
1755                .contains(&installation_b)
1756        );
1757    }
1758
1759    #[test]
1760    fn canonical_subset_rejects_non_32_byte_failed_installation() {
1761        // Anything other than a 32-byte Ed25519 installation key is
1762        // either corrupt legacy state or a wire-shape change — fail
1763        // loud rather than silently admit a bogus ID to the allow-set.
1764        let membership = xmtp_proto::xmtp::mls::message_contents::GroupMembership {
1765            members: std::collections::HashMap::new(),
1766            failed_installations: vec![vec![0xCC; 16]], // wrong length
1767        };
1768        let exts = build_test_extensions(
1769            default_gmm(),
1770            minimal_default_policy_set(),
1771            membership,
1772            plain_group_metadata(),
1773        );
1774        let err = synthesize_canonical_subset_from_extensions(&exts).unwrap_err();
1775        assert!(matches!(
1776            err,
1777            MigrationError::InvalidFailedInstallationLength(16)
1778        ));
1779    }
1780
1781    /// Format one strict entry as a stable, diffable line.
1782    fn strict_lines(subset: &CanonicalBootstrapExpectation) -> Vec<String> {
1783        subset
1784            .strict
1785            .iter()
1786            .map(|(id, (op, bytes))| format!("{id} {op:?} {}", hex::encode(bytes)))
1787            .collect()
1788    }
1789
1790    /// A fully-populated legacy fixture: every metadata attribute set,
1791    /// non-empty admin/super-admin lists. Fixed values only — the
1792    /// golden tests below pin the synthesis encoder's exact output.
1793    fn golden_gmm() -> crate::group_mutable_metadata::GroupMutableMetadata {
1794        use crate::group_mutable_metadata::MetadataField;
1795        let mut attributes = std::collections::HashMap::new();
1796        attributes.insert(
1797            MetadataField::GroupName.to_string(),
1798            "Golden Group".to_string(),
1799        );
1800        attributes.insert(
1801            MetadataField::Description.to_string(),
1802            "golden description".to_string(),
1803        );
1804        attributes.insert(
1805            MetadataField::GroupImageUrlSquare.to_string(),
1806            "https://example.com/golden.png".to_string(),
1807        );
1808        attributes.insert(
1809            MetadataField::AppData.to_string(),
1810            "golden-app-data".to_string(),
1811        );
1812        attributes.insert(
1813            MetadataField::MinimumSupportedProtocolVersion.to_string(),
1814            "1.11.0".to_string(),
1815        );
1816        attributes.insert(
1817            MetadataField::MessageDisappearFromNS.to_string(),
1818            "1000000000".to_string(),
1819        );
1820        attributes.insert(
1821            MetadataField::MessageDisappearInNS.to_string(),
1822            "2000000000".to_string(),
1823        );
1824        attributes.insert(
1825            MetadataField::CommitLogSigner.to_string(),
1826            hex::encode([0xAB; 32]),
1827        );
1828        crate::group_mutable_metadata::GroupMutableMetadata::new(
1829            attributes,
1830            vec![hex_inbox(0x22)],
1831            vec![hex_inbox(0x33)],
1832        )
1833    }
1834
1835    /// GOLDEN VECTORS — the strict byte-compare surface of the
1836    /// bootstrap synthesis encoder, pinned byte-for-byte.
1837    ///
1838    /// Every fielded validator re-synthesizes these exact bytes from
1839    /// pre-flip group state and byte-compares them against incoming
1840    /// bootstrap commits (`bootstrap_validator.rs`), and groups migrate
1841    /// lazily — so this encoding must never change for inputs that old
1842    /// receivers can see. If this test fails, you have changed the
1843    /// bootstrap wire encoding: that is only shippable together with a
1844    /// `PROPOSALS_MIN_PROTOCOL_VERSION` bump (below-floor receivers
1845    /// then pause instead of byte-comparing — see the floor check in
1846    /// `validate_bootstrap_commit`), and the old expectation code must
1847    /// keep validating bootstraps produced by older senders. Do NOT
1848    /// simply re-pin the hex to make the test pass.
1849    #[test]
1850    fn golden_bootstrap_synthesis_group() {
1851        let exts = build_test_extensions(
1852            golden_gmm(),
1853            minimal_default_policy_set(),
1854            empty_membership(),
1855            plain_group_metadata(),
1856        );
1857        let subset = synthesize_canonical_subset_from_extensions(&exts).unwrap();
1858
1859        let expected: Vec<String> = [
1860            // SUPER_ADMIN_LIST (0x8001): TlsSet<InboxId>, one element
1861            // (vlen 0x22=34, then TLS-framed versioned InboxId).
1862            "0x8001 Update 2200003333333333333333333333333333333333333333333333333333333333333333",
1863            // ADMIN_LIST (0x8002): TlsSet<InboxId>, one element.
1864            "0x8002 Update 2200002222222222222222222222222222222222222222222222222222222222222222",
1865            // GROUP_NAME (0x8004): utf-8 passthrough.
1866            "0x8004 Update 476f6c64656e2047726f7570",
1867            // GROUP_DESCRIPTION (0x8005)
1868            "0x8005 Update 676f6c64656e206465736372697074696f6e",
1869            // GROUP_IMAGE_URL (0x8006)
1870            "0x8006 Update 68747470733a2f2f6578616d706c652e636f6d2f676f6c64656e2e706e67",
1871            // MESSAGE_DISAPPEAR_FROM_NS (0x8007): 8-byte BE i64 1_000_000_000.
1872            "0x8007 Update 000000003b9aca00",
1873            // MESSAGE_DISAPPEAR_IN_NS (0x8008): 8-byte BE i64 2_000_000_000.
1874            "0x8008 Update 0000000077359400",
1875            // APP_DATA (0x8009)
1876            "0x8009 Update 676f6c64656e2d6170702d64617461",
1877            // MIN_SUPPORTED_PROTOCOL_VERSION (0x800A): utf-8 "1.11.0".
1878            "0x800A Update 312e31312e30",
1879            // COMMIT_LOG_SIGNER (0x800B): raw 32 key bytes.
1880            "0x800B Update abababababababababababababababababababababababababababababababab",
1881            // CREATOR_INBOX_ID (0xBFFE): versioned InboxId (varint v0 + 32 bytes).
1882            "0xBFFE Update 001111111111111111111111111111111111111111111111111111111111111111",
1883            // CONVERSATION_TYPE (0xBFFF): BE i32, Group = 1.
1884            "0xBFFF Update 00000001",
1885        ]
1886        .into_iter()
1887        .map(String::from)
1888        .collect();
1889
1890        assert_eq!(
1891            strict_lines(&subset),
1892            expected,
1893            "bootstrap synthesis encoder output changed — see the doc comment \
1894             on this test before touching the pins"
1895        );
1896    }
1897
1898    /// DM + oneshot variant of [`golden_bootstrap_synthesis_group`] —
1899    /// covers the optional strict seeds (DM_MEMBERS, ONESHOT_MESSAGE)
1900    /// the plain-group vector can't. Same freeze rules apply.
1901    #[test]
1902    fn golden_bootstrap_synthesis_dm_with_oneshot() {
1903        let dm_members = crate::group_metadata::DmMembers {
1904            member_one_inbox_id: hex_inbox(0x22),
1905            member_two_inbox_id: hex_inbox(0x33),
1906        };
1907        // Empty proto: zero encoded bytes, but the presence-gated seed
1908        // still appears in strict — a stable pin that doesn't depend
1909        // on OneshotMessage's inner shape.
1910        let oneshot =
1911            xmtp_proto::xmtp::mls::message_contents::OneshotMessage { message_type: None };
1912        let metadata = crate::group_metadata::GroupMetadata::new(
1913            xmtp_proto::types::ConversationType::Dm,
1914            hex_inbox(0x22),
1915            Some(dm_members),
1916            Some(oneshot),
1917        );
1918        let exts = build_test_extensions(
1919            golden_gmm(),
1920            minimal_default_policy_set(),
1921            empty_membership(),
1922            metadata,
1923        );
1924        let subset = synthesize_canonical_subset_from_extensions(&exts).unwrap();
1925
1926        let expected: Vec<String> = [
1927            "0x8001 Update 2200003333333333333333333333333333333333333333333333333333333333333333",
1928            "0x8002 Update 2200002222222222222222222222222222222222222222222222222222222222222222",
1929            "0x8004 Update 476f6c64656e2047726f7570",
1930            "0x8005 Update 676f6c64656e206465736372697074696f6e",
1931            "0x8006 Update 68747470733a2f2f6578616d706c652e636f6d2f676f6c64656e2e706e67",
1932            "0x8007 Update 000000003b9aca00",
1933            "0x8008 Update 0000000077359400",
1934            "0x8009 Update 676f6c64656e2d6170702d64617461",
1935            "0x800A Update 312e31312e30",
1936            "0x800B Update abababababababababababababababababababababababababababababababab",
1937            // ONESHOT_MESSAGE (0xBFFC): prost encoding of the empty
1938            // proto — zero bytes, seed present.
1939            "0xBFFC Update ",
1940            // DM_MEMBERS (0xBFFD): TlsSet<InboxId> of both members
1941            // (two-byte vlen 0x4044 = 68, elements in sorted order).
1942            "0xBFFD Update 40440000222222222222222222222222222222222222222222222222222222222222222200003333333333333333333333333333333333333333333333333333333333333333",
1943            // CREATOR_INBOX_ID (0xBFFE): versioned InboxId.
1944            "0xBFFE Update 002222222222222222222222222222222222222222222222222222222222222222",
1945            // CONVERSATION_TYPE (0xBFFF): BE i32, Dm = 2.
1946            "0xBFFF Update 00000002",
1947        ]
1948        .into_iter()
1949        .map(String::from)
1950        .collect();
1951
1952        assert_eq!(
1953            strict_lines(&subset),
1954            expected,
1955            "bootstrap synthesis encoder output changed — see the doc comment \
1956             on golden_bootstrap_synthesis_group before touching the pins"
1957        );
1958    }
1959
1960    #[test]
1961    fn canonical_subset_deterministic_across_calls() {
1962        // The validator byte-compares the sender's bootstrap commit
1963        // against this output on every receiver. Bit-identical output
1964        // from two calls on the same inputs is the entire point.
1965        let make = || {
1966            build_test_extensions(
1967                default_gmm(),
1968                minimal_default_policy_set(),
1969                empty_membership(),
1970                plain_group_metadata(),
1971            )
1972        };
1973        let a = synthesize_canonical_subset_from_extensions(&make()).unwrap();
1974        let b = synthesize_canonical_subset_from_extensions(&make()).unwrap();
1975        assert_eq!(a, b);
1976    }
1977}