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