Skip to main content

xmtp_mls_common/
group_mutable_metadata.rs

1use openmls::{
2    extensions::{Extension, Extensions, UnknownExtension},
3    group::GroupContext,
4};
5use prost::Message;
6use std::{collections::HashMap, fmt};
7use thiserror::Error;
8use xmtp_cryptography::Secret;
9use xmtp_proto::xmtp::mls::message_contents::{
10    GroupMutableMetadataV1 as GroupMutableMetadataProto, Inboxes as InboxesProto,
11};
12
13use super::group::{DMMetadataOptions, GroupMetadataOptions};
14use xmtp_configuration::{
15    DEFAULT_GROUP_DESCRIPTION, DEFAULT_GROUP_IMAGE_URL_SQUARE, DEFAULT_GROUP_NAME,
16    MUTABLE_METADATA_EXTENSION_ID,
17};
18
19/// Errors that can occur when working with GroupMutableMetadata.
20#[derive(Debug, Error)]
21pub enum GroupMutableMetadataError {
22    #[error("serialization: {0}")]
23    Serialization(#[from] prost::EncodeError),
24    #[error("deserialization: {0}")]
25    Deserialization(#[from] prost::DecodeError),
26    #[error("missing extension")]
27    MissingExtension,
28    #[error("mutable extension updates only")]
29    NonMutableExtensionUpdate,
30    #[error("only one change per update permitted")]
31    TooManyUpdates,
32    #[error("no changes in this update")]
33    NoUpdates,
34    #[error("missing metadata field")]
35    MissingMetadataField,
36    /// A well-known component in the AppData dictionary failed to
37    /// decode — surfaced by the migrated-group read paths when a
38    /// component's wire bytes can't be parsed.
39    ///
40    /// Structured rather than a flat `String` so downstream consumers
41    /// (bindings, error mapping) can match on the offending
42    /// `component_id` to discriminate failure modes without parsing a
43    /// display string. `component_id` is `Option` because the upstream
44    /// `ComponentSourceError` has a few variants that don't carry one
45    /// (e.g. wrapped legacy-metadata errors); those map to `None`. The
46    /// inner `reason` is diagnostic-only — typically a formatted
47    /// `ComponentSourceError` — and should not be matched against.
48    #[error("malformed app-data component {component_id:?}: {reason}")]
49    MalformedComponent {
50        /// Component whose wire bytes failed to decode. `None` for
51        /// errors that don't originate at a specific component.
52        component_id: Option<super::app_data::component_id::ComponentId>,
53        /// Diagnostic string (display-only; not a stable API).
54        reason: String,
55    },
56}
57
58/// Represents the "updateable" metadata fields for a group.
59/// Members ability to update metadata is gated by the group permissions.
60///
61/// New fields should be added to the `supported_fields` function for Metadata Update Support.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
63pub enum MetadataField {
64    GroupName,
65    Description,
66    GroupImageUrlSquare,
67    MessageDisappearFromNS,
68    MessageDisappearInNS,
69    MinimumSupportedProtocolVersion,
70    CommitLogSigner,
71    AppData,
72}
73
74impl MetadataField {
75    /// String representations used as keys in the GroupMutableMetadata attributes map.
76    pub const fn as_str(&self) -> &'static str {
77        match self {
78            MetadataField::GroupName => "group_name",
79            MetadataField::Description => "description",
80            MetadataField::GroupImageUrlSquare => "group_image_url_square",
81            MetadataField::MessageDisappearFromNS => "message_disappear_from_ns",
82            MetadataField::MessageDisappearInNS => "message_disappear_in_ns",
83            MetadataField::MinimumSupportedProtocolVersion => "minimum_supported_protocol_version",
84            // Uses SUPER_ADMIN_METADATA_PREFIX ("_") to make this field super-admin only
85            MetadataField::CommitLogSigner => "_commit_log_signer",
86            MetadataField::AppData => "app_data",
87        }
88    }
89}
90
91impl fmt::Display for MetadataField {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        write!(f, "{}", self.as_str())
94    }
95}
96
97/// Settings for disappearing messages in a conversation.
98///
99/// # Fields
100///
101/// * `from_ns` - The timestamp (in nanoseconds) from when messages should be tracked for deletion.
102/// * `in_ns` - The duration (in nanoseconds) after which tracked messages will be deleted.
103#[derive(Default, Debug, Copy, Clone, PartialEq)]
104pub struct MessageDisappearingSettings {
105    pub from_ns: i64,
106    pub in_ns: i64,
107}
108
109impl MessageDisappearingSettings {
110    pub fn new(from_ns: i64, in_ns: i64) -> Self {
111        Self { from_ns, in_ns }
112    }
113
114    pub fn is_enabled(&self) -> bool {
115        self.from_ns > 0 && self.in_ns > 0
116    }
117
118    /// The deadline of an application message sent at `sent_at_ns`, or `None` when
119    /// the settings are disabled or the message predates `from_ns`. A deadline past
120    /// `i64::MAX` clamps to it.
121    // implements: META-050
122    pub fn expire_at_ns(&self, sent_at_ns: i64) -> Option<i64> {
123        (self.is_enabled() && sent_at_ns >= self.from_ns)
124            .then(|| sent_at_ns.saturating_add(self.in_ns))
125    }
126}
127
128/// Represents the mutable metadata for a group.
129///
130/// This struct is stored as an MLS Unknown Group Context Extension.
131#[derive(Debug, Clone, PartialEq)]
132pub struct GroupMutableMetadata {
133    /// Map to store various metadata attributes (e.g., group name, description).
134    /// Allows libxmtp to receive attributes from updated versions not yet captured in MetadataField.
135    pub attributes: HashMap<String, String>,
136    /// List of admin inbox IDs for this group.
137    /// See `GroupMutablePermissions` for more details on admin permissions.
138    pub admin_list: Vec<String>,
139    /// List of super admin inbox IDs for this group.
140    /// See `GroupMutablePermissions` for more details on super admin permissions.
141    pub super_admin_list: Vec<String>,
142}
143
144impl GroupMutableMetadata {
145    /// Creates a new GroupMutableMetadata instance.
146    pub fn new(
147        attributes: HashMap<String, String>,
148        admin_list: Vec<String>,
149        super_admin_list: Vec<String>,
150    ) -> Self {
151        Self {
152            attributes,
153            admin_list,
154            super_admin_list,
155        }
156    }
157
158    /// Creates a new GroupMutableMetadata instance with default values.
159    /// The creator is automatically added as a super admin.
160    /// See `GroupMutablePermissions` for more details on super admin permissions.
161    pub fn new_default(
162        creator_inbox_id: String,
163        commit_log_signer: Option<Secret>,
164        opts: GroupMetadataOptions,
165    ) -> Self {
166        let mut attributes = HashMap::new();
167        attributes.insert(
168            MetadataField::GroupName.to_string(),
169            opts.name.unwrap_or_else(|| DEFAULT_GROUP_NAME.to_string()),
170        );
171        attributes.insert(
172            MetadataField::Description.to_string(),
173            opts.description
174                .unwrap_or_else(|| DEFAULT_GROUP_DESCRIPTION.to_string()),
175        );
176        attributes.insert(
177            MetadataField::GroupImageUrlSquare.to_string(),
178            opts.image_url_square
179                .unwrap_or_else(|| DEFAULT_GROUP_IMAGE_URL_SQUARE.to_string()),
180        );
181        attributes.insert(
182            MetadataField::AppData.to_string(),
183            opts.app_data.unwrap_or_default(),
184        );
185
186        if let Some(message_disappearing_settings) = opts.message_disappearing_settings {
187            attributes.insert(
188                MetadataField::MessageDisappearFromNS.to_string(),
189                message_disappearing_settings.from_ns.to_string(),
190            );
191            attributes.insert(
192                MetadataField::MessageDisappearInNS.to_string(),
193                message_disappearing_settings.in_ns.to_string(),
194            );
195        }
196
197        if let Some(signer) = commit_log_signer {
198            attributes.insert(
199                MetadataField::CommitLogSigner.to_string(),
200                hex::encode(signer.as_slice()),
201            );
202        }
203
204        let admin_list = vec![];
205        let super_admin_list = vec![creator_inbox_id.clone()];
206        Self {
207            attributes,
208            admin_list,
209            super_admin_list,
210        }
211    }
212
213    // Admin / super admin is not needed for a DM
214    pub fn new_dm_default(
215        _creator_inbox_id: String,
216        _dm_target_inbox_id: &str,
217        commit_log_signer: Option<Secret>,
218        opts: DMMetadataOptions,
219    ) -> Self {
220        let mut attributes = HashMap::new();
221        // TODO: would it be helpful to incorporate the dm inbox ids in the name or description?
222        attributes.insert(
223            MetadataField::GroupName.to_string(),
224            DEFAULT_GROUP_NAME.to_string(),
225        );
226        attributes.insert(
227            MetadataField::Description.to_string(),
228            DEFAULT_GROUP_DESCRIPTION.to_string(),
229        );
230        attributes.insert(
231            MetadataField::GroupImageUrlSquare.to_string(),
232            DEFAULT_GROUP_IMAGE_URL_SQUARE.to_string(),
233        );
234        if let Some(message_disappearing_settings) = opts.message_disappearing_settings {
235            attributes.insert(
236                MetadataField::MessageDisappearFromNS.to_string(),
237                message_disappearing_settings.from_ns.to_string(),
238            );
239            attributes.insert(
240                MetadataField::MessageDisappearInNS.to_string(),
241                message_disappearing_settings.in_ns.to_string(),
242            );
243        }
244
245        if let Some(signer) = commit_log_signer {
246            attributes.insert(
247                MetadataField::CommitLogSigner.to_string(),
248                hex::encode(signer.as_slice()),
249            );
250        }
251
252        let admin_list = vec![];
253        let super_admin_list = vec![];
254        Self {
255            attributes,
256            admin_list,
257            super_admin_list,
258        }
259    }
260
261    /// Returns a vector of supported metadata fields.
262    ///
263    /// These fields will receive default permission policies for new groups.
264    pub fn supported_fields() -> Vec<MetadataField> {
265        vec![
266            MetadataField::GroupName,
267            MetadataField::Description,
268            MetadataField::GroupImageUrlSquare,
269            MetadataField::MessageDisappearFromNS,
270            MetadataField::MessageDisappearInNS,
271            MetadataField::MinimumSupportedProtocolVersion,
272            MetadataField::AppData,
273        ]
274    }
275
276    /// Checks if the given inbox ID is an admin.
277    pub fn is_admin(&self, inbox_id: &String) -> bool {
278        self.admin_list.contains(inbox_id)
279    }
280
281    /// Checks if the given inbox ID is a super admin.
282    pub fn is_super_admin(&self, inbox_id: &String) -> bool {
283        self.super_admin_list.contains(inbox_id)
284    }
285
286    /// Retrieves the commit log signer secret from the metadata attributes.
287    /// Returns None if the field is not present or if hex decoding fails.
288    pub fn commit_log_signer(&self) -> Option<Secret> {
289        self.attributes
290            .get(&MetadataField::CommitLogSigner.to_string())
291            .and_then(|hex_str| hex::decode(hex_str).ok())
292            .map(Secret::new)
293    }
294}
295
296impl TryFrom<GroupMutableMetadata> for Vec<u8> {
297    type Error = GroupMutableMetadataError;
298
299    /// Converts GroupMutableMetadata to a byte vector for storage as an MLS Unknown Group Context Extension.
300    fn try_from(value: GroupMutableMetadata) -> Result<Self, Self::Error> {
301        let mut buf = Vec::new();
302        let proto_val = GroupMutableMetadataProto {
303            attributes: value.attributes.clone(),
304            admin_list: Some(InboxesProto {
305                inbox_ids: value.admin_list,
306            }),
307            super_admin_list: Some(InboxesProto {
308                inbox_ids: value.super_admin_list,
309            }),
310        };
311        proto_val.encode(&mut buf)?;
312
313        Ok(buf)
314    }
315}
316
317impl TryFrom<&Vec<u8>> for GroupMutableMetadata {
318    type Error = GroupMutableMetadataError;
319
320    /// Converts a byte vector to GroupMutableMetadata.
321    fn try_from(value: &Vec<u8>) -> Result<Self, Self::Error> {
322        let proto_val = GroupMutableMetadataProto::decode(value.as_slice())?;
323        Self::try_from(proto_val)
324    }
325}
326
327impl TryFrom<GroupMutableMetadataProto> for GroupMutableMetadata {
328    type Error = GroupMutableMetadataError;
329
330    /// Converts a GroupMutableMetadataProto to GroupMutableMetadata.
331    fn try_from(value: GroupMutableMetadataProto) -> Result<Self, Self::Error> {
332        let admin_list = value
333            .admin_list
334            .ok_or(GroupMutableMetadataError::MissingMetadataField)?
335            .inbox_ids;
336
337        let super_admin_list = value
338            .super_admin_list
339            .ok_or(GroupMutableMetadataError::MissingMetadataField)?
340            .inbox_ids;
341
342        Ok(Self::new(
343            value.attributes.clone(),
344            admin_list,
345            super_admin_list,
346        ))
347    }
348}
349
350/// Finds the mutable metadata extension in the given MLS Extensions.
351///
352/// This function searches for an Unknown Extension with the
353/// [MUTABLE_METADATA_EXTENSION_ID].
354pub fn find_mutable_metadata_extension(extensions: &Extensions<GroupContext>) -> Option<&Vec<u8>> {
355    extensions.iter().find_map(|extension| {
356        if let Extension::Unknown(MUTABLE_METADATA_EXTENSION_ID, UnknownExtension(metadata)) =
357            extension
358        {
359            return Some(metadata);
360        }
361        None
362    })
363}
364
365/// Single source of truth for the `MetadataField` ↔ `ComponentId`
366/// bijection over the Bytes/String-typed mutable-metadata family. The
367/// dict↔legacy merge below and the lookup helpers in
368/// `xmtp_mls::groups::app_data::component_source` all derive from this
369/// table.
370pub const METADATA_FIELD_COMPONENT_MAP: &[(
371    MetadataField,
372    super::app_data::component_id::ComponentId,
373)] = &[
374    (
375        MetadataField::GroupName,
376        super::app_data::component_id::ComponentId::GROUP_NAME,
377    ),
378    (
379        MetadataField::Description,
380        super::app_data::component_id::ComponentId::GROUP_DESCRIPTION,
381    ),
382    (
383        MetadataField::GroupImageUrlSquare,
384        super::app_data::component_id::ComponentId::GROUP_IMAGE_URL,
385    ),
386    (
387        MetadataField::MessageDisappearFromNS,
388        super::app_data::component_id::ComponentId::MESSAGE_DISAPPEAR_FROM_NS,
389    ),
390    (
391        MetadataField::MessageDisappearInNS,
392        super::app_data::component_id::ComponentId::MESSAGE_DISAPPEAR_IN_NS,
393    ),
394    (
395        MetadataField::MinimumSupportedProtocolVersion,
396        super::app_data::component_id::ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION,
397    ),
398    (
399        MetadataField::CommitLogSigner,
400        super::app_data::component_id::ComponentId::COMMIT_LOG_SIGNER,
401    ),
402    (
403        MetadataField::AppData,
404        super::app_data::component_id::ComponentId::APP_DATA,
405    ),
406];
407
408/// Overlay the AppData dictionary's metadata components onto `base`.
409/// No-op when the extensions carry no AppData dictionary.
410///
411/// Value translation per component family:
412/// - `MESSAGE_DISAPPEAR_*`: 8-byte BE `i64` on the wire → base-10
413///   string for the legacy reader.
414/// - `COMMIT_LOG_SIGNER`: raw 32 key bytes → hex string.
415/// - Every other metadata attribute: UTF-8 passthrough.
416/// - `ADMIN_LIST` / `SUPER_ADMIN_LIST`: `TlsSet<InboxId>` → hex-string
417///   lists (dict is authoritative on migrated groups).
418pub fn merge_dict_into_mutable_metadata(
419    base: &mut GroupMutableMetadata,
420    extensions: &Extensions<GroupContext>,
421) -> Result<(), GroupMutableMetadataError> {
422    use super::app_data::component_id::ComponentId;
423
424    let Some(ext) = extensions.app_data_dictionary() else {
425        return Ok(());
426    };
427    let dict = ext.dictionary();
428
429    for (field, id) in METADATA_FIELD_COMPONENT_MAP {
430        if let Some(bytes) = dict.get(&id.as_u16()) {
431            let legacy_value = decode_metadata_component(*id, bytes)?;
432            base.attributes
433                .insert(field.as_str().to_string(), legacy_value);
434        }
435    }
436
437    insert_absent_bounded_string_defaults(base, &[]);
438
439    for (component_id, list) in [
440        (ComponentId::ADMIN_LIST, &mut base.admin_list),
441        (ComponentId::SUPER_ADMIN_LIST, &mut base.super_admin_list),
442    ] {
443        if let Some(bytes) = dict.get(&component_id.as_u16()) {
444            *list = decode_inbox_id_list(component_id, bytes)?;
445        }
446    }
447    Ok(())
448}
449
450/// Merge available dictionary fields for legacy migration. Keep defaults for
451/// absent fields. Omit malformed values without replacing them with defaults.
452// implements: MIG-005
453pub fn merge_dict_into_mutable_metadata_lossy(
454    base: &mut GroupMutableMetadata,
455    extensions: &Extensions<GroupContext>,
456) -> Vec<GroupMutableMetadataError> {
457    use super::app_data::component_id::ComponentId;
458
459    let Some(ext) = extensions.app_data_dictionary() else {
460        return vec![];
461    };
462    let dict = ext.dictionary();
463    let mut errors = vec![];
464    let mut unavailable = vec![];
465    for (field, id) in METADATA_FIELD_COMPONENT_MAP {
466        if let Some(bytes) = dict.get(&id.as_u16()) {
467            let value = if *id == ComponentId::COMMIT_LOG_SIGNER && bytes.len() != 32 {
468                Err(GroupMutableMetadataError::MalformedComponent {
469                    component_id: Some(*id),
470                    reason: format!("expected 32 bytes, got {}", bytes.len()),
471                })
472            } else {
473                decode_metadata_component(*id, bytes)
474            };
475            match value {
476                Ok(value) => {
477                    base.attributes.insert(field.as_str().to_string(), value);
478                }
479                Err(error) => {
480                    base.attributes.remove(field.as_str());
481                    unavailable.push(*field);
482                    errors.push(error);
483                }
484            }
485        }
486    }
487    insert_absent_bounded_string_defaults(base, &unavailable);
488    for (id, list) in [
489        (ComponentId::ADMIN_LIST, &mut base.admin_list),
490        (ComponentId::SUPER_ADMIN_LIST, &mut base.super_admin_list),
491    ] {
492        if let Some(bytes) = dict.get(&id.as_u16()) {
493            match decode_inbox_id_list(id, bytes) {
494                Ok(value) => *list = value,
495                Err(error) => {
496                    list.clear();
497                    errors.push(error);
498                }
499            }
500        }
501    }
502    errors
503}
504
505/// The four bounded-string metadata fields that the pre-dictionary
506/// [`GroupMutableMetadata::new_default`] always seeded, each with an
507/// empty-string default.
508///
509/// The dictionary omits a component that was never set, which is the
510/// correct wire behaviour: absent and set-to-empty are genuinely
511/// different states and the dictionary keeps them apart. The legacy
512/// `attributes` map has no way to say "absent", and every consumer of
513/// it — the FFI getters, and the `GroupUpdated` metadata-change
514/// diff — was written against a map in which these four keys are
515/// always present. Defaulting them here keeps the dict→legacy
516/// projection faithful to that older shape without touching the
517/// dictionary itself.
518const DEFAULTED_BOUNDED_STRING_FIELDS: &[(MetadataField, &str)] = &[
519    (MetadataField::GroupName, DEFAULT_GROUP_NAME),
520    (MetadataField::Description, DEFAULT_GROUP_DESCRIPTION),
521    (
522        MetadataField::GroupImageUrlSquare,
523        DEFAULT_GROUP_IMAGE_URL_SQUARE,
524    ),
525    (MetadataField::AppData, ""),
526];
527
528/// Give every field in [`DEFAULTED_BOUNDED_STRING_FIELDS`] its legacy
529/// default when the dictionary carried no such component. Fields that
530/// the dictionary did carry keep their decoded value.
531fn insert_absent_bounded_string_defaults(
532    base: &mut GroupMutableMetadata,
533    unavailable: &[MetadataField],
534) {
535    for (field, default) in DEFAULTED_BOUNDED_STRING_FIELDS {
536        if unavailable.contains(field) {
537            continue;
538        }
539        base.attributes
540            .entry(field.as_str().to_string())
541            .or_insert_with(|| (*default).to_string());
542    }
543}
544
545/// Decode one Bytes/String-family component's wire bytes into its
546/// legacy string value, per the translation rules documented on
547/// [`merge_dict_into_mutable_metadata`].
548fn decode_metadata_component(
549    id: super::app_data::component_id::ComponentId,
550    bytes: &[u8],
551) -> Result<String, GroupMutableMetadataError> {
552    use super::app_data::component_id::ComponentId;
553
554    match id {
555        ComponentId::MESSAGE_DISAPPEAR_FROM_NS | ComponentId::MESSAGE_DISAPPEAR_IN_NS => {
556            let arr: [u8; 8] =
557                bytes
558                    .try_into()
559                    .map_err(|_| GroupMutableMetadataError::MalformedComponent {
560                        component_id: Some(id),
561                        reason: format!("expected 8 bytes (BE i64), got {}", bytes.len()),
562                    })?;
563            Ok(i64::from_be_bytes(arr).to_string())
564        }
565        ComponentId::COMMIT_LOG_SIGNER => Ok(hex::encode(bytes)),
566        _ => Ok(std::str::from_utf8(bytes)
567            .map_err(|e| GroupMutableMetadataError::MalformedComponent {
568                component_id: Some(id),
569                reason: format!("non-UTF-8 bytes: {e}"),
570            })?
571            .to_string()),
572    }
573}
574
575/// Decode an `ADMIN_LIST` / `SUPER_ADMIN_LIST` component's wire bytes
576/// (`TlsSet<InboxId>`) into the legacy hex-string list form.
577fn decode_inbox_id_list(
578    component_id: super::app_data::component_id::ComponentId,
579    bytes: &[u8],
580) -> Result<Vec<String>, GroupMutableMetadataError> {
581    use super::inbox_id::InboxId;
582    use super::tls_set::TlsSet;
583    use tls_codec::Deserialize as _;
584
585    let set = TlsSet::<InboxId>::tls_deserialize_exact(bytes).map_err(|e| {
586        GroupMutableMetadataError::MalformedComponent {
587            component_id: Some(component_id),
588            reason: format!("invalid TlsSet<InboxId>: {e}"),
589        }
590    })?;
591    Ok(set.iter().map(|id| id.to_hex()).collect())
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597    use std::collections::HashMap;
598
599    /// The deadline is the backend sent time plus the duration, exactly: a message
600    /// sent before `from_ns` or under disabled settings gets none, one sent at
601    /// `from_ns` gets one, and a sum past `i64::MAX` clamps rather than overflowing.
602    // verifies: META-050
603    #[xmtp_common::test]
604    fn message_expiry_from_sent_time() {
605        let settings = MessageDisappearingSettings::new(1_000, 50);
606        assert_eq!(settings.expire_at_ns(999), None);
607        assert_eq!(settings.expire_at_ns(1_000), Some(1_050));
608        assert_eq!(settings.expire_at_ns(5_000), Some(5_050));
609
610        for disabled in [(0, 50), (1_000, 0), (-1, 50), (1_000, -1)] {
611            let disabled = MessageDisappearingSettings::new(disabled.0, disabled.1);
612            assert_eq!(disabled.expire_at_ns(5_000), None, "{disabled:?}");
613        }
614
615        let long = MessageDisappearingSettings::new(1, i64::MAX);
616        assert_eq!(long.expire_at_ns(1), Some(i64::MAX));
617        assert_eq!(long.expire_at_ns(i64::MAX), Some(i64::MAX));
618        let edge = MessageDisappearingSettings::new(1, i64::MAX - 10);
619        assert_eq!(edge.expire_at_ns(10), Some(i64::MAX));
620        assert_eq!(edge.expire_at_ns(11), Some(i64::MAX));
621        assert_eq!(edge.expire_at_ns(9), Some(i64::MAX - 1));
622    }
623
624    #[xmtp_common::test(unwrap_try = true)]
625    fn test_commit_log_signer_utility_method() {
626        // Test with valid hex-encoded signer
627        let test_secret_bytes = vec![1u8; 32];
628        let test_secret_hex = hex::encode(&test_secret_bytes);
629
630        let mut attributes = HashMap::new();
631        attributes.insert(
632            MetadataField::CommitLogSigner.to_string(),
633            test_secret_hex.clone(),
634        );
635
636        let metadata = GroupMutableMetadata::new(attributes, vec![], vec![]);
637
638        let retrieved_secret = metadata.commit_log_signer().unwrap();
639        assert_eq!(retrieved_secret.as_slice(), &test_secret_bytes);
640
641        // Test with missing signer
642        let empty_metadata = GroupMutableMetadata::new(HashMap::new(), vec![], vec![]);
643        assert!(empty_metadata.commit_log_signer().is_none());
644
645        // Test with invalid hex
646        let mut bad_attributes = HashMap::new();
647        bad_attributes.insert(
648            MetadataField::CommitLogSigner.to_string(),
649            "invalid_hex".to_string(),
650        );
651
652        let bad_metadata = GroupMutableMetadata::new(bad_attributes, vec![], vec![]);
653        assert!(bad_metadata.commit_log_signer().is_none());
654    }
655
656    /// Regression: a dictionary omits a bounded-string component that
657    /// was never set, but the legacy `attributes` map that the FFI
658    /// getters and the `GroupUpdated` diff read from must still carry
659    /// the four fields with their empty-string defaults. Losing them
660    /// surfaced `undefined` instead of `""` across the JS SDKs.
661    #[xmtp_common::test(unwrap_try = true)]
662    fn test_merge_defaults_absent_bounded_string_components() {
663        use super::super::app_data::component_id::ComponentId;
664        use openmls::extensions::{AppDataDictionary, AppDataDictionaryExtension};
665        use openmls::group::GroupContext;
666
667        // A dictionary with only GROUP_NAME set: the three other
668        // bounded-string components are absent, exactly as a group
669        // created with no metadata options would be.
670        let mut dict = AppDataDictionary::new();
671        let _ = dict.insert(ComponentId::GROUP_NAME.as_u16(), b"Only Name".to_vec());
672        let extensions: Extensions<GroupContext> =
673            Extensions::from_vec(vec![Extension::AppDataDictionary(
674                AppDataDictionaryExtension::new(dict),
675            )])
676            .unwrap();
677
678        let mut base = GroupMutableMetadata::new(HashMap::new(), vec![], vec![]);
679        merge_dict_into_mutable_metadata(&mut base, &extensions).unwrap();
680
681        // The component that was present keeps its decoded value.
682        assert_eq!(
683            base.attributes
684                .get(MetadataField::GroupName.as_str())
685                .map(String::as_str),
686            Some("Only Name"),
687            "a present component must not be overwritten by its default",
688        );
689
690        // The absent ones read as "" rather than being missing.
691        for field in [
692            MetadataField::Description,
693            MetadataField::GroupImageUrlSquare,
694            MetadataField::AppData,
695        ] {
696            assert_eq!(
697                base.attributes.get(field.as_str()).map(String::as_str),
698                Some(""),
699                "absent {} must default to an empty string",
700                field.as_str(),
701            );
702        }
703
704        // Fields that legacy only seeded when actually set stay
705        // absent — they never had an empty-string default.
706        for field in [
707            MetadataField::MessageDisappearFromNS,
708            MetadataField::MessageDisappearInNS,
709            MetadataField::MinimumSupportedProtocolVersion,
710            MetadataField::CommitLogSigner,
711        ] {
712            assert!(
713                !base.attributes.contains_key(field.as_str()),
714                "{} must not gain a default",
715                field.as_str(),
716            );
717        }
718    }
719
720    /// A malformed component fails the merge rather than being dropped:
721    /// commit validation and the archive exporter both rely on it.
722    #[xmtp_common::test(unwrap_try = true)]
723    fn test_merge_rejects_malformed_component() {
724        use super::super::app_data::component_id::ComponentId;
725        use openmls::extensions::{AppDataDictionary, AppDataDictionaryExtension};
726        use openmls::group::GroupContext;
727
728        let mut dict = AppDataDictionary::new();
729        let _ = dict.insert(ComponentId::GROUP_NAME.as_u16(), b"Good Name".to_vec());
730        let _ = dict.insert(ComponentId::ADMIN_LIST.as_u16(), vec![0xff, 0xff, 0xff]);
731        let extensions: Extensions<GroupContext> =
732            Extensions::from_vec(vec![Extension::AppDataDictionary(
733                AppDataDictionaryExtension::new(dict),
734            )])
735            .unwrap();
736
737        let mut base = GroupMutableMetadata::new(HashMap::new(), vec![], vec![]);
738        assert!(matches!(
739            merge_dict_into_mutable_metadata(&mut base, &extensions),
740            Err(GroupMutableMetadataError::MalformedComponent {
741                component_id: Some(ComponentId::ADMIN_LIST),
742                ..
743            })
744        ));
745    }
746
747    // verifies: MIG-005
748    #[xmtp_common::test(unwrap_try = true)]
749    fn test_lossy_merge_omits_malformed_fields_without_defaults() {
750        use super::super::app_data::component_id::ComponentId;
751        use openmls::extensions::{AppDataDictionary, AppDataDictionaryExtension};
752        use openmls::group::GroupContext;
753
754        let mut dict = AppDataDictionary::new();
755        let _ = dict.insert(ComponentId::GROUP_NAME.as_u16(), vec![0xff]);
756        let _ = dict.insert(
757            ComponentId::GROUP_DESCRIPTION.as_u16(),
758            b"Readable".to_vec(),
759        );
760        let _ = dict.insert(ComponentId::ADMIN_LIST.as_u16(), vec![0xff, 0xff, 0xff]);
761        let _ = dict.insert(ComponentId::COMMIT_LOG_SIGNER.as_u16(), vec![1, 2, 3]);
762        let extensions: Extensions<GroupContext> =
763            Extensions::from_vec(vec![Extension::AppDataDictionary(
764                AppDataDictionaryExtension::new(dict),
765            )])?;
766        let mut base = GroupMutableMetadata::new(
767            HashMap::from([
768                ("group_name".to_string(), "Stale".to_string()),
769                (
770                    MetadataField::CommitLogSigner.as_str().to_string(),
771                    hex::encode([7; 32]),
772                ),
773            ]),
774            vec!["stale-admin".to_string()],
775            vec![],
776        );
777        let errors = merge_dict_into_mutable_metadata_lossy(&mut base, &extensions);
778        let mut components: Vec<_> = errors
779            .into_iter()
780            .map(|error| match error {
781                GroupMutableMetadataError::MalformedComponent {
782                    component_id: Some(id),
783                    ..
784                } => id.as_u16(),
785                error => panic!("unexpected metadata error: {error:?}"),
786            })
787            .collect();
788        components.sort();
789        let mut expected = [
790            ComponentId::GROUP_NAME.as_u16(),
791            ComponentId::ADMIN_LIST.as_u16(),
792            ComponentId::COMMIT_LOG_SIGNER.as_u16(),
793        ];
794        expected.sort();
795        assert_eq!(components, expected);
796        assert!(!base.attributes.contains_key("group_name"));
797        assert!(
798            !base
799                .attributes
800                .contains_key(MetadataField::CommitLogSigner.as_str())
801        );
802        assert_eq!(base.attributes["description"], "Readable");
803        assert_eq!(base.attributes["group_image_url_square"], "");
804        assert_eq!(base.attributes["app_data"], "");
805        assert!(base.admin_list.is_empty());
806    }
807}