1use std::{borrow::BorrowMut, marker::PhantomData};
5
6use openmls_traits::{
7 crypto::OpenMlsCrypto, random::OpenMlsRand, signatures::Signer, storage::StorageProvider as _,
8};
9use tls_codec::Serialize as _;
10
11use crate::{
12 binary_tree::LeafNodeIndex,
13 ciphersuite::{signable::Signable as _, Secret},
14 framing::{FramingParameters, WireFormat},
15 group::{
16 diff::compute_path::{CommitType, PathComputationResult},
17 CommitBuilderStageError, CreateCommitError, Extension, Extensions, ExternalPubExtension,
18 ProposalQueue, ProposalQueueError, QueuedProposal, RatchetTreeExtension, StagedCommit,
19 WireFormatPolicy,
20 },
21 key_packages::KeyPackage,
22 messages::{
23 group_info::{GroupInfo, GroupInfoTBS},
24 Commit, Welcome,
25 },
26 prelude::{CredentialWithKey, LeafNodeParameters, LibraryError, NewSignerBundle},
27 schedule::{
28 psk::{load_psks, PskSecret},
29 JoinerSecret, KeySchedule, PreSharedKeyId,
30 },
31 storage::{OpenMlsProvider, StorageProvider},
32 versions::ProtocolVersion,
33};
34
35pub(crate) mod external_commits;
36
37pub use external_commits::{ExternalCommitBuilder, ExternalCommitBuilderError};
38
39#[cfg(doc)]
40use super::MlsGroupJoinConfig;
41
42use super::{
43 mls_auth_content::AuthenticatedContent,
44 staged_commit::{MemberStagedCommitState, StagedCommitState},
45 AddProposal, CreateCommitResult, GroupContextExtensionProposal, MlsGroup, MlsGroupState,
46 MlsMessageOut, PendingCommitState, Proposal, RemoveProposal, Sender,
47};
48
49#[derive(Debug)]
50struct ExternalCommitInfo {
51 aad: Vec<u8>,
52 credential: CredentialWithKey,
53 wire_format_policy: WireFormatPolicy,
54}
55
56#[derive(Debug)]
58pub struct Initial {
59 own_proposals: Vec<Proposal>,
60 force_self_update: bool,
61 leaf_node_parameters: LeafNodeParameters,
62 create_group_info: bool,
63 external_commit_info: Option<ExternalCommitInfo>,
64
65 consume_proposal_store: bool,
68}
69
70impl Default for Initial {
71 fn default() -> Self {
72 Initial {
73 consume_proposal_store: true,
74 force_self_update: false,
75 leaf_node_parameters: LeafNodeParameters::default(),
76 create_group_info: false,
77 own_proposals: vec![],
78 external_commit_info: None,
79 }
80 }
81}
82
83pub struct LoadedPsks {
85 own_proposals: Vec<Proposal>,
86 force_self_update: bool,
87 leaf_node_parameters: LeafNodeParameters,
88 create_group_info: bool,
89 external_commit_info: Option<ExternalCommitInfo>,
90
91 consume_proposal_store: bool,
94 psks: Vec<(PreSharedKeyId, Secret)>,
95}
96
97pub struct Complete {
99 result: CreateCommitResult,
100 original_wire_format_policy: Option<WireFormatPolicy>,
102}
103
104#[derive(Debug)]
142pub struct CommitBuilder<'a, T, G: BorrowMut<MlsGroup> = &'a mut MlsGroup> {
143 group: G,
146
147 stage: T,
149
150 pd: PhantomData<&'a ()>,
151}
152
153impl<'a, T, G: BorrowMut<MlsGroup>> CommitBuilder<'a, T, G> {
154 pub(crate) fn replace_stage<NextStage>(
155 self,
156 next_stage: NextStage,
157 ) -> (T, CommitBuilder<'a, NextStage, G>) {
158 self.map_stage(|prev_stage| (prev_stage, next_stage))
159 }
160
161 pub(crate) fn into_stage<NextStage>(
162 self,
163 next_stage: NextStage,
164 ) -> CommitBuilder<'a, NextStage, G> {
165 self.replace_stage(next_stage).1
166 }
167
168 fn take_stage(self) -> (T, CommitBuilder<'a, (), G>) {
169 self.replace_stage(())
170 }
171
172 fn map_stage<NextStage, Aux, F: FnOnce(T) -> (Aux, NextStage)>(
173 self,
174 f: F,
175 ) -> (Aux, CommitBuilder<'a, NextStage, G>) {
176 let Self {
177 group,
178 stage,
179 pd: PhantomData,
180 } = self;
181
182 let (aux, stage) = f(stage);
183
184 (
185 aux,
186 CommitBuilder {
187 group,
188 stage,
189 pd: PhantomData,
190 },
191 )
192 }
193
194 #[cfg(feature = "fork-resolution")]
195 pub(crate) fn stage(&self) -> &T {
196 &self.stage
197 }
198}
199
200impl MlsGroup {
201 pub fn commit_builder(&mut self) -> CommitBuilder<'_, Initial> {
203 CommitBuilder::<'_, Initial, &mut MlsGroup>::new(self)
204 }
205}
206
207impl<'a> CommitBuilder<'a, Initial, &mut MlsGroup> {
209 pub fn consume_proposal_store(mut self, consume_proposal_store: bool) -> Self {
212 self.stage.consume_proposal_store = consume_proposal_store;
213 self
214 }
215
216 pub fn force_self_update(mut self, force_self_update: bool) -> Self {
218 self.stage.force_self_update = force_self_update;
219 self
220 }
221
222 pub fn propose_adds(mut self, key_packages: impl IntoIterator<Item = KeyPackage>) -> Self {
225 self.stage.own_proposals.extend(
226 key_packages
227 .into_iter()
228 .map(|key_package| Proposal::Add(AddProposal { key_package })),
229 );
230 self
231 }
232
233 pub fn propose_removals(mut self, removed: impl IntoIterator<Item = LeafNodeIndex>) -> Self {
236 self.stage.own_proposals.extend(
237 removed
238 .into_iter()
239 .map(|removed| Proposal::Remove(RemoveProposal { removed })),
240 );
241 self
242 }
243
244 pub fn propose_group_context_extensions(mut self, extensions: Extensions) -> Self {
247 self.stage
248 .own_proposals
249 .push(Proposal::GroupContextExtensions(
250 GroupContextExtensionProposal::new(extensions),
251 ));
252 self
253 }
254
255 pub fn add_proposal(mut self, proposal: Proposal) -> Self {
258 self.stage.own_proposals.push(proposal);
259 self
260 }
261
262 pub fn add_proposals(mut self, proposals: impl IntoIterator<Item = Proposal>) -> Self {
264 self.stage.own_proposals.extend(proposals);
265 self
266 }
267}
268
269impl<'a, G: BorrowMut<MlsGroup>> CommitBuilder<'a, Initial, G> {
271 pub fn new(group: G) -> CommitBuilder<'a, Initial, G> {
273 let stage = Initial {
274 create_group_info: group.borrow().configuration().use_ratchet_tree_extension,
275 ..Default::default()
276 };
277 CommitBuilder {
278 group,
279 stage,
280 pd: PhantomData,
281 }
282 }
283
284 pub fn create_group_info(mut self, create_group_info: bool) -> Self {
287 self.stage.create_group_info = create_group_info;
288 self
289 }
290
291 pub fn leaf_node_parameters(mut self, leaf_node_parameters: LeafNodeParameters) -> Self {
294 self.stage.leaf_node_parameters = leaf_node_parameters;
295 self
296 }
297
298 pub fn load_psks<Storage: StorageProvider>(
300 self,
301 storage: &'a Storage,
302 ) -> Result<CommitBuilder<'a, LoadedPsks, G>, CreateCommitError> {
303 let psk_ids: Vec<_> = self
304 .stage
305 .own_proposals
306 .iter()
307 .chain(
308 self.group
309 .borrow()
310 .proposal_store()
311 .proposals()
312 .map(|queued_proposal| queued_proposal.proposal()),
313 )
314 .filter_map(|proposal| match proposal {
315 Proposal::PreSharedKey(psk_proposal) => Some(psk_proposal.clone().into_psk_id()),
316 _ => None,
317 })
318 .collect();
319
320 let psks = load_psks(storage, &self.group.borrow().resumption_psk_store, &psk_ids)?
322 .into_iter()
323 .map(|(psk_id_ref, key)| (psk_id_ref.clone(), key))
324 .collect();
325
326 Ok(self
327 .map_stage(|stage| {
328 (
329 (),
330 LoadedPsks {
331 own_proposals: stage.own_proposals,
332 psks,
333 force_self_update: stage.force_self_update,
334 leaf_node_parameters: stage.leaf_node_parameters,
335 consume_proposal_store: stage.consume_proposal_store,
336 create_group_info: stage.create_group_info,
337 external_commit_info: stage.external_commit_info,
338 },
339 )
340 })
341 .1)
342 }
343}
344
345impl<'a, G: BorrowMut<MlsGroup>> CommitBuilder<'a, LoadedPsks, G> {
346 pub fn build<S: Signer>(
350 self,
351 rand: &impl OpenMlsRand,
352 crypto: &impl OpenMlsCrypto,
353 signer: &S,
354 f: impl FnMut(&QueuedProposal) -> bool,
355 ) -> Result<CommitBuilder<'a, Complete, G>, CreateCommitError> {
356 self.build_internal(rand, crypto, signer, None::<NewSignerBundle<'_, S>>, f)
357 }
358
359 pub fn build_with_new_signer<S: Signer>(
367 self,
368 rand: &impl OpenMlsRand,
369 crypto: &impl OpenMlsCrypto,
370 old_signer: &impl Signer,
371 new_signer: NewSignerBundle<'_, S>,
372 f: impl FnMut(&QueuedProposal) -> bool,
373 ) -> Result<CommitBuilder<'a, Complete, G>, CreateCommitError> {
374 self.build_internal(rand, crypto, old_signer, Some(new_signer), f)
375 }
376
377 fn build_internal<S: Signer>(
378 self,
379 rand: &impl OpenMlsRand,
380 crypto: &impl OpenMlsCrypto,
381 old_signer: &impl Signer,
382 new_signer: Option<NewSignerBundle<'_, S>>,
383 f: impl FnMut(&QueuedProposal) -> bool,
384 ) -> Result<CommitBuilder<'a, Complete, G>, CreateCommitError> {
385 let (mut cur_stage, builder) = self.take_stage();
386 let group = builder.group.borrow();
387 let ciphersuite = group.ciphersuite();
388 let own_leaf_index = group.own_leaf_index();
389 let (sender, is_external_commit) = match cur_stage.external_commit_info {
390 None => (Sender::build_member(own_leaf_index), false),
391 Some(_) => (Sender::NewMemberCommit, true),
392 };
393 let psks = cur_stage.psks;
394
395 let own_proposals: Vec<_> = cur_stage
398 .own_proposals
399 .into_iter()
400 .map(|proposal| {
401 QueuedProposal::from_proposal_and_sender(ciphersuite, crypto, proposal, &sender)
402 })
403 .collect::<Result<_, _>>()?;
404
405 let group_proposal_store_queue = group
408 .pending_proposals()
409 .filter(|_| cur_stage.consume_proposal_store)
410 .cloned();
411
412 let proposal_queue = group_proposal_store_queue.chain(own_proposals).filter(f);
416
417 let (proposal_queue, contains_own_updates) =
418 ProposalQueue::filter_proposals(proposal_queue, group.own_leaf_index).map_err(|e| {
419 match e {
420 ProposalQueueError::LibraryError(e) => e.into(),
421 ProposalQueueError::ProposalNotFound => CreateCommitError::MissingProposal,
422 ProposalQueueError::UpdateFromExternalSender
423 | ProposalQueueError::SelfRemoveFromNonMember => {
424 CreateCommitError::WrongProposalSenderType
425 }
426 }
427 })?;
428
429 group
434 .public_group
435 .validate_proposal_type_support(&proposal_queue)?;
436 group
441 .public_group
442 .validate_key_uniqueness(&proposal_queue, None)?;
443 group.public_group.validate_add_proposals(&proposal_queue)?;
445 group.public_group.validate_capabilities(&proposal_queue)?;
448 group
451 .public_group
452 .validate_remove_proposals(&proposal_queue)?;
453 group
454 .public_group
455 .validate_pre_shared_key_proposals(&proposal_queue)?;
456 group
461 .public_group
462 .validate_update_proposals(&proposal_queue, own_leaf_index)?;
463
464 group
467 .public_group
468 .validate_group_context_extensions_proposal(&proposal_queue)?;
469
470 if is_external_commit {
471 group
472 .public_group
473 .validate_external_commit(&proposal_queue)?;
474 }
475
476 let proposal_reference_list = proposal_queue.commit_list();
477
478 let mut diff = group.public_group.empty_diff();
480
481 let apply_proposals_values = diff.apply_proposals(&proposal_queue, own_leaf_index)?;
483 if apply_proposals_values.self_removed && !is_external_commit {
484 return Err(CreateCommitError::CannotRemoveSelf);
485 }
486
487 let path_computation_result =
488 if apply_proposals_values.path_required
490 || contains_own_updates
491 || cur_stage.force_self_update
492 || !cur_stage.leaf_node_parameters.is_empty()
493 {
494 let commit_type = match &cur_stage.external_commit_info {
495 Some(ExternalCommitInfo { credential , ..}) => {
496 CommitType::External(credential.clone())
497 }
498 None => CommitType::Member,
499 };
500 if let Some(new_signer) = new_signer {
504 if let Some(credential_with_key) =
505 cur_stage.leaf_node_parameters.credential_with_key()
506 {
507 if credential_with_key != &new_signer.credential_with_key {
508 return Err(CreateCommitError::InvalidLeafNodeParameters);
509 }
510 }
511 cur_stage.leaf_node_parameters.set_credential_with_key(
512 new_signer.credential_with_key,
513 );
514 diff.compute_path(
515 rand,
516 crypto,
517 own_leaf_index,
518 apply_proposals_values.exclusion_list(),
519 &commit_type,
520 &cur_stage.leaf_node_parameters,
521 new_signer.signer,
522 apply_proposals_values.extensions.clone()
523 )?
524 } else {
525 diff.compute_path(
526 rand,
527 crypto,
528 own_leaf_index,
529 apply_proposals_values.exclusion_list(),
530 &commit_type,
531 &cur_stage.leaf_node_parameters,
532 old_signer,
533 apply_proposals_values.extensions.clone()
534 )?
535 }
536 } else {
537 diff.update_group_context(crypto, apply_proposals_values.extensions.clone())?;
540 PathComputationResult::default()
541 };
542
543 let update_path_leaf_node = path_computation_result
544 .encrypted_path
545 .as_ref()
546 .map(|path| path.leaf_node().clone());
547
548 let commit = Commit {
550 proposals: proposal_reference_list,
551 path: path_computation_result.encrypted_path,
552 };
553
554 let framing_parameters =
555 if let Some(ExternalCommitInfo { aad, .. }) = &cur_stage.external_commit_info {
556 FramingParameters::new(aad, WireFormat::PublicMessage)
557 } else {
558 group.framing_parameters()
559 };
560
561 let mut authenticated_content = AuthenticatedContent::commit(
563 framing_parameters,
564 sender,
565 commit,
566 group.public_group.group_context(),
567 old_signer,
568 )?;
569
570 diff.update_confirmed_transcript_hash(crypto, &authenticated_content)?;
572
573 let serialized_provisional_group_context = diff
574 .group_context()
575 .tls_serialize_detached()
576 .map_err(LibraryError::missing_bound_check)?;
577
578 let joiner_secret = JoinerSecret::new(
579 crypto,
580 ciphersuite,
581 path_computation_result.commit_secret,
582 group.group_epoch_secrets().init_secret(),
583 &serialized_provisional_group_context,
584 )
585 .map_err(LibraryError::unexpected_crypto_error)?;
586
587 let psk_secret = { PskSecret::new(crypto, ciphersuite, psks)? };
589
590 let mut key_schedule = KeySchedule::init(ciphersuite, crypto, &joiner_secret, psk_secret)?;
592
593 let serialized_provisional_group_context = diff
594 .group_context()
595 .tls_serialize_detached()
596 .map_err(LibraryError::missing_bound_check)?;
597
598 let welcome_secret = key_schedule
599 .welcome(crypto, ciphersuite)
600 .map_err(|_| LibraryError::custom("Using the key schedule in the wrong state"))?;
601 key_schedule
602 .add_context(crypto, &serialized_provisional_group_context)
603 .map_err(|_| LibraryError::custom("Using the key schedule in the wrong state"))?;
604 let provisional_epoch_secrets = key_schedule
605 .epoch_secrets(crypto, ciphersuite)
606 .map_err(|_| LibraryError::custom("Using the key schedule in the wrong state"))?;
607
608 let confirmation_tag = provisional_epoch_secrets
610 .confirmation_key()
611 .tag(
612 crypto,
613 ciphersuite,
614 diff.group_context().confirmed_transcript_hash(),
615 )
616 .map_err(LibraryError::unexpected_crypto_error)?;
617
618 authenticated_content.set_confirmation_tag(confirmation_tag.clone());
620
621 diff.update_interim_transcript_hash(ciphersuite, crypto, confirmation_tag.clone())?;
622
623 let needs_welcome = !apply_proposals_values.invitation_list.is_empty();
625
626 let needs_group_info = needs_welcome || cur_stage.create_group_info;
630
631 let group_info = if !needs_group_info {
632 None
633 } else {
634 let external_pub = provisional_epoch_secrets
636 .external_secret()
637 .derive_external_keypair(crypto, ciphersuite)
638 .map_err(LibraryError::unexpected_crypto_error)?
639 .public;
640 let external_pub_extension =
641 Extension::ExternalPub(ExternalPubExtension::new(external_pub.into()));
642
643 let extensions: Extensions = if group.configuration().use_ratchet_tree_extension {
645 Extensions::from_vec(vec![
646 Extension::RatchetTree(RatchetTreeExtension::new(diff.export_ratchet_tree())),
647 external_pub_extension,
648 ])?
649 } else {
650 Extensions::single(external_pub_extension)
651 };
652
653 let group_info_tbs = {
655 GroupInfoTBS::new(
656 diff.group_context().clone(),
657 extensions,
658 confirmation_tag,
659 own_leaf_index,
660 )
661 };
662 Some(group_info_tbs.sign(old_signer)?)
664 };
665
666 let welcome_option = if !needs_welcome {
667 None
668 } else {
669 let (welcome_key, welcome_nonce) = welcome_secret
671 .derive_welcome_key_nonce(crypto, ciphersuite)
672 .map_err(LibraryError::unexpected_crypto_error)?;
673 let encrypted_group_info = welcome_key
674 .aead_seal(
675 crypto,
676 group_info
677 .as_ref()
678 .ok_or_else(|| LibraryError::custom("GroupInfo was not computed"))?
679 .tls_serialize_detached()
680 .map_err(LibraryError::missing_bound_check)?
681 .as_slice(),
682 &[],
683 &welcome_nonce,
684 )
685 .map_err(LibraryError::unexpected_crypto_error)?;
686
687 let encrypted_secrets = diff.encrypt_group_secrets(
690 &joiner_secret,
691 apply_proposals_values.invitation_list,
692 path_computation_result.plain_path.as_deref(),
693 &apply_proposals_values.presharedkeys,
694 &encrypted_group_info,
695 crypto,
696 own_leaf_index,
697 )?;
698
699 let welcome = Welcome::new(ciphersuite, encrypted_secrets, encrypted_group_info);
701 Some(welcome)
702 };
703
704 let (provisional_group_epoch_secrets, provisional_message_secrets) =
705 provisional_epoch_secrets.split_secrets(
706 serialized_provisional_group_context,
707 diff.tree_size(),
708 own_leaf_index,
709 );
710
711 let staged_commit_state = MemberStagedCommitState::new(
712 provisional_group_epoch_secrets,
713 provisional_message_secrets,
714 diff.into_staged_diff(crypto, ciphersuite)?,
715 path_computation_result.new_keypairs,
716 None,
719 update_path_leaf_node,
720 );
721 let staged_commit = StagedCommit::new(
722 proposal_queue,
723 StagedCommitState::GroupMember(Box::new(staged_commit_state)),
724 );
725
726 Ok(builder.into_stage(Complete {
727 result: CreateCommitResult {
728 commit: authenticated_content,
729 welcome_option,
730 staged_commit,
731 group_info: group_info.filter(|_| cur_stage.create_group_info),
732 },
733 original_wire_format_policy: cur_stage
734 .external_commit_info
735 .as_ref()
736 .map(|info| info.wire_format_policy),
737 }))
738 }
739}
740
741impl CommitBuilder<'_, Complete, &mut MlsGroup> {
743 #[cfg(test)]
744 pub(crate) fn commit_result(self) -> CreateCommitResult {
745 self.stage.result
746 }
747
748 pub fn stage_commit<Provider: OpenMlsProvider>(
750 self,
751 provider: &Provider,
752 ) -> Result<CommitMessageBundle, CommitBuilderStageError<Provider::StorageError>> {
753 let Self {
754 group,
755 stage:
756 Complete {
757 result: create_commit_result,
758 original_wire_format_policy: _,
759 },
760 ..
761 } = self;
762
763 group.group_state = MlsGroupState::PendingCommit(Box::new(PendingCommitState::Member(
766 create_commit_result.staged_commit,
767 )));
768
769 provider
770 .storage()
771 .write_group_state(group.group_id(), &group.group_state)
772 .map_err(CommitBuilderStageError::KeyStoreError)?;
773
774 group.reset_aad();
775
776 let mls_message = group.content_to_mls_message(create_commit_result.commit, provider)?;
782
783 Ok(CommitMessageBundle {
784 version: group.version(),
785 commit: mls_message,
786 welcome: create_commit_result.welcome_option,
787 group_info: create_commit_result.group_info,
788 })
789 }
790}
791
792#[derive(Debug, Clone)]
795pub struct CommitMessageBundle {
796 version: ProtocolVersion,
797 commit: MlsMessageOut,
798 welcome: Option<Welcome>,
799 group_info: Option<GroupInfo>,
800}
801
802#[cfg(test)]
803impl CommitMessageBundle {
804 pub fn new(
805 version: ProtocolVersion,
806 commit: MlsMessageOut,
807 welcome: Option<Welcome>,
808 group_info: Option<GroupInfo>,
809 ) -> Self {
810 Self {
811 version,
812 commit,
813 welcome,
814 group_info,
815 }
816 }
817}
818
819impl CommitMessageBundle {
820 pub fn commit(&self) -> &MlsMessageOut {
824 &self.commit
825 }
826
827 pub fn welcome(&self) -> Option<&Welcome> {
830 self.welcome.as_ref()
831 }
832
833 pub fn to_welcome_msg(&self) -> Option<MlsMessageOut> {
836 self.welcome
837 .as_ref()
838 .map(|welcome| MlsMessageOut::from_welcome(welcome.clone(), self.version))
839 }
840
841 pub fn group_info(&self) -> Option<&GroupInfo> {
845 self.group_info.as_ref()
846 }
847
848 pub fn contents(&self) -> (&MlsMessageOut, Option<&Welcome>, Option<&GroupInfo>) {
851 (
852 &self.commit,
853 self.welcome.as_ref(),
854 self.group_info.as_ref(),
855 )
856 }
857
858 pub fn into_commit(self) -> MlsMessageOut {
862 self.commit
863 }
864
865 pub fn into_welcome(self) -> Option<Welcome> {
869 self.welcome
870 }
871
872 pub fn into_welcome_msg(self) -> Option<MlsMessageOut> {
875 self.welcome
876 .map(|welcome| MlsMessageOut::from_welcome(welcome, self.version))
877 }
878
879 pub fn into_group_info(self) -> Option<GroupInfo> {
884 self.group_info
885 }
886
887 pub fn into_group_info_msg(self) -> Option<MlsMessageOut> {
889 self.group_info.map(|group_info| group_info.into())
890 }
891
892 pub fn into_contents(self) -> (MlsMessageOut, Option<Welcome>, Option<GroupInfo>) {
895 (self.commit, self.welcome, self.group_info)
896 }
897
898 pub fn into_messages(self) -> (MlsMessageOut, Option<MlsMessageOut>, Option<MlsMessageOut>) {
901 (
902 self.commit,
903 self.welcome
904 .map(|welcome| MlsMessageOut::from_welcome(welcome, self.version)),
905 self.group_info.map(|group_info| group_info.into()),
906 )
907 }
908}
909
910impl IntoIterator for CommitMessageBundle {
911 type Item = MlsMessageOut;
912
913 type IntoIter = core::iter::Chain<
914 core::iter::Chain<
915 core::option::IntoIter<MlsMessageOut>,
916 core::option::IntoIter<MlsMessageOut>,
917 >,
918 core::option::IntoIter<MlsMessageOut>,
919 >;
920
921 fn into_iter(self) -> Self::IntoIter {
922 let welcome = self.to_welcome_msg();
923 let group_info = self.group_info.map(|group_info| group_info.into());
924
925 Some(self.commit)
926 .into_iter()
927 .chain(welcome)
928 .chain(group_info)
929 }
930}