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#[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 #[error("malformed app-data component {component_id:?}: {reason}")]
49 MalformedComponent {
50 component_id: Option<super::app_data::component_id::ComponentId>,
53 reason: String,
55 },
56}
57
58#[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 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 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#[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#[derive(Debug, Clone, PartialEq)]
123pub struct GroupMutableMetadata {
124 pub attributes: HashMap<String, String>,
127 pub admin_list: Vec<String>,
130 pub super_admin_list: Vec<String>,
133}
134
135impl GroupMutableMetadata {
136 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 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 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 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 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 pub fn is_admin(&self, inbox_id: &String) -> bool {
269 self.admin_list.contains(inbox_id)
270 }
271
272 pub fn is_super_admin(&self, inbox_id: &String) -> bool {
274 self.super_admin_list.contains(inbox_id)
275 }
276
277 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 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 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 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 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 fn try_from(group: &OpenMlsGroup) -> Result<Self, Self::Error> {
358 let extensions = group.extensions();
359 extensions.try_into()
360 }
361}
362
363pub 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
378pub 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
397pub 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
407pub 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
450pub 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
469pub 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
515pub 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
565fn 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
596fn 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 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 let empty_metadata = GroupMutableMetadata::new(HashMap::new(), vec![], vec![]);
640 assert!(empty_metadata.commit_log_signer().is_none());
641
642 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 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 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 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}