Skip to main content

xmtp_mls_common/
group_mutable_metadata.rs

1use openmls::{
2    extensions::{Extension, Extensions, UnknownExtension},
3    group::{GroupContext, MlsGroup as OpenMlsGroup},
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
119/// Represents the mutable metadata for a group.
120///
121/// This struct is stored as an MLS Unknown Group Context Extension.
122#[derive(Debug, Clone, PartialEq)]
123pub struct GroupMutableMetadata {
124    /// Map to store various metadata attributes (e.g., group name, description).
125    /// Allows libxmtp to receive attributes from updated versions not yet captured in MetadataField.
126    pub attributes: HashMap<String, String>,
127    /// List of admin inbox IDs for this group.
128    /// See `GroupMutablePermissions` for more details on admin permissions.
129    pub admin_list: Vec<String>,
130    /// List of super admin inbox IDs for this group.
131    /// See `GroupMutablePermissions` for more details on super admin permissions.
132    pub super_admin_list: Vec<String>,
133}
134
135impl GroupMutableMetadata {
136    /// Creates a new GroupMutableMetadata instance.
137    pub fn new(
138        attributes: HashMap<String, String>,
139        admin_list: Vec<String>,
140        super_admin_list: Vec<String>,
141    ) -> Self {
142        Self {
143            attributes,
144            admin_list,
145            super_admin_list,
146        }
147    }
148
149    /// Creates a new GroupMutableMetadata instance with default values.
150    /// The creator is automatically added as a super admin.
151    /// See `GroupMutablePermissions` for more details on super admin permissions.
152    pub fn new_default(
153        creator_inbox_id: String,
154        commit_log_signer: Option<Secret>,
155        opts: GroupMetadataOptions,
156    ) -> Self {
157        let mut attributes = HashMap::new();
158        attributes.insert(
159            MetadataField::GroupName.to_string(),
160            opts.name.unwrap_or_else(|| DEFAULT_GROUP_NAME.to_string()),
161        );
162        attributes.insert(
163            MetadataField::Description.to_string(),
164            opts.description
165                .unwrap_or_else(|| DEFAULT_GROUP_DESCRIPTION.to_string()),
166        );
167        attributes.insert(
168            MetadataField::GroupImageUrlSquare.to_string(),
169            opts.image_url_square
170                .unwrap_or_else(|| DEFAULT_GROUP_IMAGE_URL_SQUARE.to_string()),
171        );
172        attributes.insert(
173            MetadataField::AppData.to_string(),
174            opts.app_data.unwrap_or_default(),
175        );
176
177        if let Some(message_disappearing_settings) = opts.message_disappearing_settings {
178            attributes.insert(
179                MetadataField::MessageDisappearFromNS.to_string(),
180                message_disappearing_settings.from_ns.to_string(),
181            );
182            attributes.insert(
183                MetadataField::MessageDisappearInNS.to_string(),
184                message_disappearing_settings.in_ns.to_string(),
185            );
186        }
187
188        if let Some(signer) = commit_log_signer {
189            attributes.insert(
190                MetadataField::CommitLogSigner.to_string(),
191                hex::encode(signer.as_slice()),
192            );
193        }
194
195        let admin_list = vec![];
196        let super_admin_list = vec![creator_inbox_id.clone()];
197        Self {
198            attributes,
199            admin_list,
200            super_admin_list,
201        }
202    }
203
204    // Admin / super admin is not needed for a DM
205    pub fn new_dm_default(
206        _creator_inbox_id: String,
207        _dm_target_inbox_id: &str,
208        commit_log_signer: Option<Secret>,
209        opts: DMMetadataOptions,
210    ) -> Self {
211        let mut attributes = HashMap::new();
212        // TODO: would it be helpful to incorporate the dm inbox ids in the name or description?
213        attributes.insert(
214            MetadataField::GroupName.to_string(),
215            DEFAULT_GROUP_NAME.to_string(),
216        );
217        attributes.insert(
218            MetadataField::Description.to_string(),
219            DEFAULT_GROUP_DESCRIPTION.to_string(),
220        );
221        attributes.insert(
222            MetadataField::GroupImageUrlSquare.to_string(),
223            DEFAULT_GROUP_IMAGE_URL_SQUARE.to_string(),
224        );
225        if let Some(message_disappearing_settings) = opts.message_disappearing_settings {
226            attributes.insert(
227                MetadataField::MessageDisappearFromNS.to_string(),
228                message_disappearing_settings.from_ns.to_string(),
229            );
230            attributes.insert(
231                MetadataField::MessageDisappearInNS.to_string(),
232                message_disappearing_settings.in_ns.to_string(),
233            );
234        }
235
236        if let Some(signer) = commit_log_signer {
237            attributes.insert(
238                MetadataField::CommitLogSigner.to_string(),
239                hex::encode(signer.as_slice()),
240            );
241        }
242
243        let admin_list = vec![];
244        let super_admin_list = vec![];
245        Self {
246            attributes,
247            admin_list,
248            super_admin_list,
249        }
250    }
251
252    /// Returns a vector of supported metadata fields.
253    ///
254    /// These fields will receive default permission policies for new groups.
255    pub fn supported_fields() -> Vec<MetadataField> {
256        vec![
257            MetadataField::GroupName,
258            MetadataField::Description,
259            MetadataField::GroupImageUrlSquare,
260            MetadataField::MessageDisappearFromNS,
261            MetadataField::MessageDisappearInNS,
262            MetadataField::MinimumSupportedProtocolVersion,
263            MetadataField::AppData,
264        ]
265    }
266
267    /// Checks if the given inbox ID is an admin.
268    pub fn is_admin(&self, inbox_id: &String) -> bool {
269        self.admin_list.contains(inbox_id)
270    }
271
272    /// Checks if the given inbox ID is a super admin.
273    pub fn is_super_admin(&self, inbox_id: &String) -> bool {
274        self.super_admin_list.contains(inbox_id)
275    }
276
277    /// Retrieves the commit log signer secret from the metadata attributes.
278    /// Returns None if the field is not present or if hex decoding fails.
279    pub fn commit_log_signer(&self) -> Option<Secret> {
280        self.attributes
281            .get(&MetadataField::CommitLogSigner.to_string())
282            .and_then(|hex_str| hex::decode(hex_str).ok())
283            .map(Secret::new)
284    }
285}
286
287impl TryFrom<GroupMutableMetadata> for Vec<u8> {
288    type Error = GroupMutableMetadataError;
289
290    /// Converts GroupMutableMetadata to a byte vector for storage as an MLS Unknown Group Context Extension.
291    fn try_from(value: GroupMutableMetadata) -> Result<Self, Self::Error> {
292        let mut buf = Vec::new();
293        let proto_val = GroupMutableMetadataProto {
294            attributes: value.attributes.clone(),
295            admin_list: Some(InboxesProto {
296                inbox_ids: value.admin_list,
297            }),
298            super_admin_list: Some(InboxesProto {
299                inbox_ids: value.super_admin_list,
300            }),
301        };
302        proto_val.encode(&mut buf)?;
303
304        Ok(buf)
305    }
306}
307
308impl TryFrom<&Vec<u8>> for GroupMutableMetadata {
309    type Error = GroupMutableMetadataError;
310
311    /// Converts a byte vector to GroupMutableMetadata.
312    fn try_from(value: &Vec<u8>) -> Result<Self, Self::Error> {
313        let proto_val = GroupMutableMetadataProto::decode(value.as_slice())?;
314        Self::try_from(proto_val)
315    }
316}
317
318impl TryFrom<GroupMutableMetadataProto> for GroupMutableMetadata {
319    type Error = GroupMutableMetadataError;
320
321    /// Converts a GroupMutableMetadataProto to GroupMutableMetadata.
322    fn try_from(value: GroupMutableMetadataProto) -> Result<Self, Self::Error> {
323        let admin_list = value
324            .admin_list
325            .ok_or(GroupMutableMetadataError::MissingMetadataField)?
326            .inbox_ids;
327
328        let super_admin_list = value
329            .super_admin_list
330            .ok_or(GroupMutableMetadataError::MissingMetadataField)?
331            .inbox_ids;
332
333        Ok(Self::new(
334            value.attributes.clone(),
335            admin_list,
336            super_admin_list,
337        ))
338    }
339}
340
341impl TryFrom<&Extensions<GroupContext>> for GroupMutableMetadata {
342    type Error = GroupMutableMetadataError;
343
344    /// Attempts to extract GroupMutableMetadata from MLS Extensions.
345    fn try_from(value: &Extensions<GroupContext>) -> Result<Self, Self::Error> {
346        match find_mutable_metadata_extension(value) {
347            Some(metadata) => GroupMutableMetadata::try_from(metadata),
348            None => Err(GroupMutableMetadataError::MissingExtension),
349        }
350    }
351}
352
353impl TryFrom<&OpenMlsGroup> for GroupMutableMetadata {
354    type Error = GroupMutableMetadataError;
355
356    /// Attempts to extract GroupMutableMetadata from an OpenMlsGroup.
357    fn try_from(group: &OpenMlsGroup) -> Result<Self, Self::Error> {
358        let extensions = group.extensions();
359        extensions.try_into()
360    }
361}
362
363/// Finds the mutable metadata extension in the given MLS Extensions.
364///
365/// This function searches for an Unknown Extension with the
366/// [MUTABLE_METADATA_EXTENSION_ID].
367pub fn find_mutable_metadata_extension(extensions: &Extensions<GroupContext>) -> Option<&Vec<u8>> {
368    extensions.iter().find_map(|extension| {
369        if let Extension::Unknown(MUTABLE_METADATA_EXTENSION_ID, UnknownExtension(metadata)) =
370            extension
371        {
372            return Some(metadata);
373        }
374        None
375    })
376}
377
378/// Read `GroupMutableMetadata` from the **legacy** group-context
379/// extension only.
380///
381/// Use only when the caller is certain the group is unmigrated — on
382/// post-bootstrap groups the legacy extension is gone and this returns
383/// [`GroupMutableMetadataError::MissingExtension`].
384///
385/// For capability-aware reads that handle both legacy and migrated
386/// groups, use `extract_group_mutable_metadata_capability_aware` in
387/// the `xmtp_mls` crate at
388/// `xmtp_mls::groups::app_data::component_source`.
389/// (`xmtp_mls_common` cannot rustdoc-link to it because the dependency
390/// direction is one-way — this comment is the pointer.)
391pub fn extract_legacy_group_mutable_metadata(
392    group: &OpenMlsGroup,
393) -> Result<GroupMutableMetadata, GroupMutableMetadataError> {
394    extract_legacy_group_mutable_metadata_from_extensions(group.extensions())
395}
396
397/// Same as [`extract_legacy_group_mutable_metadata`], but reads directly from a
398/// group's `GroupContext` extensions — no full `OpenMlsGroup` needed.
399pub fn extract_legacy_group_mutable_metadata_from_extensions(
400    extensions: &Extensions<GroupContext>,
401) -> Result<GroupMutableMetadata, GroupMutableMetadataError> {
402    find_mutable_metadata_extension(extensions)
403        .ok_or(GroupMutableMetadataError::MissingExtension)?
404        .try_into()
405}
406
407/// Single source of truth for the `MetadataField` ↔ `ComponentId`
408/// bijection over the Bytes/String-typed mutable-metadata family. The
409/// dict↔legacy merge below and the lookup helpers in
410/// `xmtp_mls::groups::app_data::component_source` all derive from this
411/// table.
412pub const METADATA_FIELD_COMPONENT_MAP: &[(
413    MetadataField,
414    super::app_data::component_id::ComponentId,
415)] = &[
416    (
417        MetadataField::GroupName,
418        super::app_data::component_id::ComponentId::GROUP_NAME,
419    ),
420    (
421        MetadataField::Description,
422        super::app_data::component_id::ComponentId::GROUP_DESCRIPTION,
423    ),
424    (
425        MetadataField::GroupImageUrlSquare,
426        super::app_data::component_id::ComponentId::GROUP_IMAGE_URL,
427    ),
428    (
429        MetadataField::MessageDisappearFromNS,
430        super::app_data::component_id::ComponentId::MESSAGE_DISAPPEAR_FROM_NS,
431    ),
432    (
433        MetadataField::MessageDisappearInNS,
434        super::app_data::component_id::ComponentId::MESSAGE_DISAPPEAR_IN_NS,
435    ),
436    (
437        MetadataField::MinimumSupportedProtocolVersion,
438        super::app_data::component_id::ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION,
439    ),
440    (
441        MetadataField::CommitLogSigner,
442        super::app_data::component_id::ComponentId::COMMIT_LOG_SIGNER,
443    ),
444    (
445        MetadataField::AppData,
446        super::app_data::component_id::ComponentId::APP_DATA,
447    ),
448];
449
450/// Production migration predicate over raw extensions: the group is
451/// post-bootstrap iff the AppData dictionary carries the
452/// `COMPONENT_REGISTRY` entry (the bootstrap commit's first write).
453///
454/// `xmtp_mls::groups::app_data::is_migrated_extensions` layers a
455/// test-only registry override on top of this; use that one inside
456/// `xmtp_mls`. This variant exists for crates below `xmtp_mls` in the
457/// dependency graph (e.g. the archive exporter).
458pub fn extensions_are_migrated(extensions: &Extensions<GroupContext>) -> bool {
459    extensions
460        .app_data_dictionary()
461        .map(|ext| {
462            ext.dictionary()
463                .get(&super::app_data::component_id::ComponentId::COMPONENT_REGISTRY.as_u16())
464                .is_some()
465        })
466        .unwrap_or(false)
467}
468
469/// Overlay the AppData dictionary's metadata components onto `base` —
470/// the dict→legacy direction of the capability-aware read paths.
471///
472/// **Ungated**: callers decide migration state before calling (the
473/// `xmtp_mls` wrapper gates on its test-override-aware
474/// `is_migrated_extensions`; the archive exporter gates on
475/// [`extensions_are_migrated`]). No-op when the extensions carry no
476/// AppData dictionary.
477///
478/// Value translation per component family:
479/// - `MESSAGE_DISAPPEAR_*`: 8-byte BE `i64` on the wire → base-10
480///   string for the legacy reader.
481/// - `COMMIT_LOG_SIGNER`: raw 32 key bytes → hex string.
482/// - Every other metadata attribute: UTF-8 passthrough.
483/// - `ADMIN_LIST` / `SUPER_ADMIN_LIST`: `TlsSet<InboxId>` → hex-string
484///   lists (dict is authoritative on migrated groups).
485pub fn merge_dict_into_mutable_metadata(
486    base: &mut GroupMutableMetadata,
487    extensions: &Extensions<GroupContext>,
488) -> Result<(), GroupMutableMetadataError> {
489    use super::app_data::component_id::ComponentId;
490
491    let Some(ext) = extensions.app_data_dictionary() else {
492        return Ok(());
493    };
494    let dict = ext.dictionary();
495
496    for (field, id) in METADATA_FIELD_COMPONENT_MAP {
497        if let Some(bytes) = dict.get(&id.as_u16()) {
498            let legacy_value = decode_metadata_component(*id, bytes)?;
499            base.attributes
500                .insert(field.as_str().to_string(), legacy_value);
501        }
502    }
503
504    for (component_id, list) in [
505        (ComponentId::ADMIN_LIST, &mut base.admin_list),
506        (ComponentId::SUPER_ADMIN_LIST, &mut base.super_admin_list),
507    ] {
508        if let Some(bytes) = dict.get(&component_id.as_u16()) {
509            *list = decode_inbox_id_list(component_id, bytes)?;
510        }
511    }
512    Ok(())
513}
514
515/// Best-effort variant of [`merge_dict_into_mutable_metadata`] that
516/// degrades per-field instead of failing per-group: every component
517/// that decodes is applied to `base`, every component that doesn't is
518/// skipped, and the errors are returned so the caller can log them
519/// (empty vec = clean merge).
520///
521/// Exists for the archive exporter, where one malformed component must
522/// not drop the whole group from a backup — the group's messages are
523/// exported unconditionally, so a missing group row orphans them and
524/// aborts the entire restore on a foreign-key violation. Non-export
525/// callers that want fail-fast semantics keep using the strict variant
526/// above.
527pub fn merge_dict_into_mutable_metadata_lossy(
528    base: &mut GroupMutableMetadata,
529    extensions: &Extensions<GroupContext>,
530) -> Vec<GroupMutableMetadataError> {
531    use super::app_data::component_id::ComponentId;
532
533    let Some(ext) = extensions.app_data_dictionary() else {
534        return Vec::new();
535    };
536    let dict = ext.dictionary();
537    let mut errors = Vec::new();
538
539    for (field, id) in METADATA_FIELD_COMPONENT_MAP {
540        if let Some(bytes) = dict.get(&id.as_u16()) {
541            match decode_metadata_component(*id, bytes) {
542                Ok(legacy_value) => {
543                    base.attributes
544                        .insert(field.as_str().to_string(), legacy_value);
545                }
546                Err(e) => errors.push(e),
547            }
548        }
549    }
550
551    for (component_id, list) in [
552        (ComponentId::ADMIN_LIST, &mut base.admin_list),
553        (ComponentId::SUPER_ADMIN_LIST, &mut base.super_admin_list),
554    ] {
555        if let Some(bytes) = dict.get(&component_id.as_u16()) {
556            match decode_inbox_id_list(component_id, bytes) {
557                Ok(ids) => *list = ids,
558                Err(e) => errors.push(e),
559            }
560        }
561    }
562    errors
563}
564
565/// Decode one Bytes/String-family component's wire bytes into its
566/// legacy string value, per the translation rules documented on
567/// [`merge_dict_into_mutable_metadata`]. Shared by the strict and
568/// lossy merge variants so both apply identical translations.
569fn decode_metadata_component(
570    id: super::app_data::component_id::ComponentId,
571    bytes: &[u8],
572) -> Result<String, GroupMutableMetadataError> {
573    use super::app_data::component_id::ComponentId;
574
575    match id {
576        ComponentId::MESSAGE_DISAPPEAR_FROM_NS | ComponentId::MESSAGE_DISAPPEAR_IN_NS => {
577            let arr: [u8; 8] =
578                bytes
579                    .try_into()
580                    .map_err(|_| GroupMutableMetadataError::MalformedComponent {
581                        component_id: Some(id),
582                        reason: format!("expected 8 bytes (BE i64), got {}", bytes.len()),
583                    })?;
584            Ok(i64::from_be_bytes(arr).to_string())
585        }
586        ComponentId::COMMIT_LOG_SIGNER => Ok(hex::encode(bytes)),
587        _ => Ok(std::str::from_utf8(bytes)
588            .map_err(|e| GroupMutableMetadataError::MalformedComponent {
589                component_id: Some(id),
590                reason: format!("non-UTF-8 bytes: {e}"),
591            })?
592            .to_string()),
593    }
594}
595
596/// Decode an `ADMIN_LIST` / `SUPER_ADMIN_LIST` component's wire bytes
597/// (`TlsSet<InboxId>`) into the legacy hex-string list form. Shared by
598/// the strict and lossy merge variants.
599fn decode_inbox_id_list(
600    component_id: super::app_data::component_id::ComponentId,
601    bytes: &[u8],
602) -> Result<Vec<String>, GroupMutableMetadataError> {
603    use super::inbox_id::InboxId;
604    use super::tls_set::TlsSet;
605    use tls_codec::Deserialize as _;
606
607    let set = TlsSet::<InboxId>::tls_deserialize_exact(bytes).map_err(|e| {
608        GroupMutableMetadataError::MalformedComponent {
609            component_id: Some(component_id),
610            reason: format!("invalid TlsSet<InboxId>: {e}"),
611        }
612    })?;
613    Ok(set.iter().map(|id| id.to_hex()).collect())
614}
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619    use std::collections::HashMap;
620
621    #[test]
622    fn test_commit_log_signer_utility_method() {
623        // Test with valid hex-encoded signer
624        let test_secret_bytes = vec![1u8; 32];
625        let test_secret_hex = hex::encode(&test_secret_bytes);
626
627        let mut attributes = HashMap::new();
628        attributes.insert(
629            MetadataField::CommitLogSigner.to_string(),
630            test_secret_hex.clone(),
631        );
632
633        let metadata = GroupMutableMetadata::new(attributes, vec![], vec![]);
634
635        let retrieved_secret = metadata.commit_log_signer().unwrap();
636        assert_eq!(retrieved_secret.as_slice(), &test_secret_bytes);
637
638        // Test with missing signer
639        let empty_metadata = GroupMutableMetadata::new(HashMap::new(), vec![], vec![]);
640        assert!(empty_metadata.commit_log_signer().is_none());
641
642        // Test with invalid hex
643        let mut bad_attributes = HashMap::new();
644        bad_attributes.insert(
645            MetadataField::CommitLogSigner.to_string(),
646            "invalid_hex".to_string(),
647        );
648
649        let bad_metadata = GroupMutableMetadata::new(bad_attributes, vec![], vec![]);
650        assert!(bad_metadata.commit_log_signer().is_none());
651    }
652
653    #[xmtp_common::test]
654    fn test_lossy_merge_applies_good_fields_and_reports_bad_ones() {
655        use super::super::app_data::component_id::ComponentId;
656        use openmls::extensions::{AppDataDictionary, AppDataDictionaryExtension};
657        use openmls::group::GroupContext;
658
659        // One valid component (GROUP_NAME), two malformed ones
660        // (MESSAGE_DISAPPEAR_FROM_NS with the wrong byte width,
661        // ADMIN_LIST with bytes that aren't a TlsSet<InboxId>).
662        let mut dict = AppDataDictionary::new();
663        let _ = dict.insert(ComponentId::GROUP_NAME.as_u16(), b"Good Name".to_vec());
664        let _ = dict.insert(
665            ComponentId::MESSAGE_DISAPPEAR_FROM_NS.as_u16(),
666            vec![0x01; 3],
667        );
668        let _ = dict.insert(ComponentId::ADMIN_LIST.as_u16(), vec![0xff, 0xff, 0xff]);
669        let extensions: Extensions<GroupContext> =
670            Extensions::from_vec(vec![Extension::AppDataDictionary(
671                AppDataDictionaryExtension::new(dict),
672            )])
673            .unwrap();
674
675        // The strict variant fails on the first malformed component.
676        let mut strict_base = GroupMutableMetadata::new(HashMap::new(), vec![], vec![]);
677        assert!(merge_dict_into_mutable_metadata(&mut strict_base, &extensions).is_err());
678
679        // The lossy variant applies the good field, leaves the bad
680        // ones untouched, and reports both errors with their ids.
681        let mut base = GroupMutableMetadata::new(HashMap::new(), vec![], vec![]);
682        let errors = merge_dict_into_mutable_metadata_lossy(&mut base, &extensions);
683
684        assert_eq!(
685            base.attributes
686                .get(MetadataField::GroupName.as_str())
687                .map(String::as_str),
688            Some("Good Name"),
689        );
690        assert!(
691            !base
692                .attributes
693                .contains_key(MetadataField::MessageDisappearFromNS.as_str())
694        );
695        assert!(base.admin_list.is_empty());
696
697        let error_ids: Vec<_> = errors
698            .iter()
699            .map(|e| match e {
700                GroupMutableMetadataError::MalformedComponent { component_id, .. } => {
701                    component_id.unwrap()
702                }
703                other => panic!("expected MalformedComponent, got: {other:?}"),
704            })
705            .collect();
706        assert_eq!(
707            error_ids,
708            vec![
709                ComponentId::MESSAGE_DISAPPEAR_FROM_NS,
710                ComponentId::ADMIN_LIST
711            ]
712        );
713    }
714}