Skip to main content

xmtp_mls/worker/device_sync/
archive.rs

1use super::DeviceSyncError;
2use crate::groups::deletion::{
3    ArchivedSelfDeletions, DiscardEvents, Source, apply_deletion, apply_stored_records,
4    import_record,
5};
6use crate::{
7    context::XmtpSharedContext, groups::MlsGroup, state_tx::state_write,
8    subscriptions::internal::InternalEvent, worker::NeedsDbReconnect,
9};
10use futures::{Stream, StreamExt};
11use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
12pub use xmtp_archive::*;
13use xmtp_configuration::LEGACY_GROUP_ID_BYTES;
14use xmtp_db::{
15    StorageError,
16    TransactionOutcome::Continue,
17    consent_record::{ConsentKey, ConsentWrite, Owed, parse_consent_save, write_consent},
18    group::ConversationType,
19    group_message::StoredGroupMessage,
20    message_deletion::StoredMessageDeletion,
21    prelude::*,
22};
23use xmtp_proto::types::GroupId;
24use xmtp_proto::xmtp::device_sync::{
25    BackupElement, backup_element::Element, consent_backup::ConsentSave,
26};
27
28use xmtp_events::{ArchiveRestored, ClientEvent, EventWriter};
29#[derive(Default)]
30struct ImportContext {
31    changed: bool,
32    /// The import stored a deletion record or marked a row. It is not a
33    /// change other installations are owed, but the app hears of
34    /// it once through `archive.restored`.
35    deletions_stored: bool,
36    /// The archive's usable consent records, applied after all its elements.
37    /// Archive frames are ciphertext and do not compress, so these grow with
38    /// the archive's own size.
39    consent: Vec<ConsentSave>,
40    /// The archive's 16-byte DM groups, whatever their restore did.
41    dm_groups: HashSet<GroupId>,
42    /// Whether the client holds each group the archive's messages name as a
43    /// sync or one-shot conversation.
44    internal: HashMap<GroupId, bool>,
45    /// The archive's deletion messages, which delete a message this import
46    /// creates when its own sender sent them.
47    self_deletions: ArchivedSelfDeletions,
48}
49
50impl ImportContext {
51    fn is_internal(&mut self, db: &impl DbQuery, group_id: GroupId) -> Result<bool, StorageError> {
52        if let Some(internal) = self.internal.get(&group_id) {
53            return Ok(*internal);
54        }
55        let internal = db
56            .find_group(&group_id)?
57            .is_some_and(|group| group.conversation_type.is_virtual());
58        self.internal.insert(group_id, internal);
59        Ok(internal)
60    }
61}
62
63/// Import an archive read once, for tests. A pre-8.0 self-deletion is
64/// restored only when the archive lists it before its target; imports use
65/// [`insert_archive`], which reads the archive twice and restores it in
66/// either order.
67#[cfg(test)]
68pub(crate) async fn insert_importer(
69    importer: &mut ArchiveImporter,
70    context: &impl XmtpSharedContext,
71) -> Result<(), DeviceSyncError> {
72    insert_elements(importer, context, ArchivedSelfDeletions::default()).await
73}
74
75/// Import an archive. `first` reads it once for its deletion messages;
76/// `reopen` then reads it again for the import, so a pre-8.0 self-deletion
77/// is restored whichever comes first, it or its target.
78pub async fn insert_archive<F, Fut>(
79    first: &mut ArchiveImporter,
80    reopen: F,
81    context: &impl XmtpSharedContext,
82) -> Result<(), DeviceSyncError>
83where
84    F: FnOnce() -> Fut,
85    Fut: std::future::Future<Output = Result<ArchiveImporter, ArchiveError>>,
86{
87    let self_deletions = collect_self_deletions(first).await?;
88    let mut importer = reopen().await?;
89    insert_elements(&mut importer, context, self_deletions).await
90}
91
92/// The archive's deletion messages. A read failure fails the scan, so the
93/// import never runs with some of them missing; an element that reads but
94/// does not convert is left to the import, which reports it.
95async fn collect_self_deletions<S, E>(elements: &mut S) -> Result<ArchivedSelfDeletions, E>
96where
97    S: Stream<Item = Result<BackupElement, E>> + Unpin,
98{
99    let mut found = ArchivedSelfDeletions::default();
100    while let Some(element) = elements.next().await {
101        if let Some(Element::GroupMessage(save)) = element?.element
102            && let Ok(message) = StoredGroupMessage::try_from(save)
103        {
104            found.note(&message);
105        }
106    }
107    Ok(found)
108}
109
110/// Import archive elements already read, as an archive holding them imports.
111#[cfg(any(test, feature = "test-utils"))]
112#[doc(hidden)]
113pub async fn insert_elements_for_test<S, E>(
114    elements: &mut S,
115    context: &impl XmtpSharedContext,
116) -> Result<(), DeviceSyncError>
117where
118    S: Stream<Item = Result<BackupElement, E>> + Unpin,
119    DeviceSyncError: From<E>,
120{
121    insert_elements(elements, context, ArchivedSelfDeletions::default()).await
122}
123
124// implements: ARCH-027
125async fn insert_elements<S, E>(
126    elements: &mut S,
127    context: &impl XmtpSharedContext,
128    self_deletions: ArchivedSelfDeletions,
129) -> Result<(), DeviceSyncError>
130where
131    S: Stream<Item = Result<BackupElement, E>> + Unpin,
132    DeviceSyncError: From<E>,
133{
134    let mut import = ImportContext {
135        self_deletions,
136        ..Default::default()
137    };
138    let mut first_error = None;
139    while let Some(element) = elements.next().await {
140        let element = match element {
141            Ok(element) => element,
142            // A broken stream ends the import.
143            Err(e) => {
144                first_error.get_or_insert(DeviceSyncError::from(e));
145                break;
146            }
147        };
148        // A failed element does not stop the rest: a pre-8.0 archive lists
149        // consent and later groups after a message whose group it left out.
150        if let Err(e) = insert(element, context, &mut import) {
151            if e.needs_db_reconnect() {
152                return Err(e);
153            }
154            first_error.get_or_insert(e);
155        }
156    }
157    // Consent after groups, even after a failed element.
158    let consent = state_write(context.mls_storage(), |tx| {
159        let storage = tx.storage();
160        let db = storage.db();
161        let changed = import_consent(
162            &db,
163            context.inbox_id(),
164            &import,
165            xmtp_common::time::now_ns(),
166        )?;
167        Ok::<_, StorageError>(Continue(changed))
168    });
169    match consent {
170        Ok(changed) => import.changed |= changed.into_continued(),
171        Err(e) if e.db_needs_connection() => return Err(e.into()),
172        Err(e) => {
173            first_error.get_or_insert(e.into());
174        }
175    }
176    let result = first_error.map_or(Ok(()), Err);
177    if import.changed {
178        context.task_channels().mark_notification_changed();
179        context.events().emit(
180            Some(ClientEvent::ArchiveRestored(ArchiveRestored {
181                complete: result.is_ok(),
182            })),
183            // Imported records are owed to the other installations.
184            Some(InternalEvent::OutboxChanged),
185        );
186    } else if import.deletions_stored {
187        context.events().emit(
188            Some(ClientEvent::ArchiveRestored(ArchiveRestored {
189                complete: result.is_ok(),
190            })),
191            None,
192        );
193    }
194    result
195}
196
197fn insert(
198    element: BackupElement,
199    context: &impl XmtpSharedContext,
200    import: &mut ImportContext,
201) -> Result<(), DeviceSyncError> {
202    let Some(element) = element.element else {
203        return Ok(());
204    };
205
206    match element {
207        // An unusable record is dropped here, so each held record is small.
208        Element::Consent(save) => {
209            if parse_consent_save(&save).is_some() {
210                import.consent.push(save);
211            }
212        }
213        // The exporter writes no sync or one-shot conversation, so an import
214        // makes none and adds no message to one: device sync applies only
215        // what the backend delivered.
216        Element::Group(save)
217            if ConversationType::try_from(save.conversation_type())
218                .is_ok_and(|kind| kind.is_virtual()) => {}
219        Element::Group(save) => {
220            // Registered before the restore, so a re-import that finds the
221            // group held combines the same rows.
222            if save.dm_id.is_some()
223                && save.id.len() == LEGACY_GROUP_ID_BYTES
224                && let Ok(id) = GroupId::try_from(save.id.as_slice())
225            {
226                import.dm_groups.insert(id);
227            }
228            import.changed |= MlsGroup::restore_from_archive(context, &save)?;
229        }
230        // implements: ARCH-028
231        Element::MessageDeletion(save) => {
232            let record: StoredMessageDeletion = save.try_into()?;
233            if !import.is_internal(&context.db(), record.group_id)? {
234                import.deletions_stored |= state_write(context.mls_storage(), |tx| {
235                    let stored = import_record(&tx.storage().db(), &DiscardEvents, &record)?;
236                    Ok::<_, StorageError>(Continue(stored))
237                })?
238                .into_continued();
239            }
240        }
241        // implements: ARCH-013, ARCH-028
242        Element::GroupMessage(message) => {
243            // Field 19 makes a new row marked and empty. A known row keeps
244            // its state: archive data changes a row only in the insert that
245            // creates it.
246            let message: StoredGroupMessage = message.try_into()?;
247            if import.is_internal(&context.db(), message.group_id)? {
248                return Ok(());
249            }
250            import.self_deletions.note(&message);
251            let self_deletions = &import.self_deletions;
252            let (inserted, deletions) = state_write(context.mls_storage(), |tx| {
253                let storage = tx.storage();
254                let db = storage.db();
255                let inserted = message.store_or_ignore_changed(&db)?;
256                // A pre-8.0 self-deletion of the row this insert created.
257                let mut deletions = inserted && self_deletions.apply_to_inserted(&db, &message)?;
258                // The evidence is the incoming copy: a stored `Unpublished`
259                // copy of the same id must not hide a later `Published` one.
260                // The stored row itself stays unchanged.
261                deletions |= apply_deletion(&db, &DiscardEvents, &message, Source::Archive)?;
262                if let Some(row) = db.get_group_message(&message.id)? {
263                    deletions |= apply_stored_records(&db, &DiscardEvents, &row)?;
264                }
265                Ok::<_, StorageError>(Continue((inserted, deletions)))
266            })?
267            .into_continued();
268            import.changed |= inserted;
269            import.deletions_stored |= deletions;
270        }
271        _ => {}
272    }
273
274    Ok(())
275}
276
277/// Apply one archive's consent records, after its groups. When the archive
278/// carries a 16-byte DM with a peer, its records for every 16-byte DM held
279/// with that peer and its inbox record for the peer become one record. For
280/// any other peer, a record naming a 16-byte DM held from elsewhere moves to
281/// the peer and merges by time. Every other record merges as named. Returns
282/// whether anything changed.
283// implements: CONS-051, CONS-054, CONS-061
284fn import_consent(
285    db: &impl DbQuery,
286    own_inbox: &str,
287    import: &ImportContext,
288    now_ns: i64,
289) -> Result<bool, StorageError> {
290    // The peers of the 16-byte DMs this archive carries.
291    let mut carried = BTreeSet::new();
292    for id in &import.dm_groups {
293        if let Some((peer, _)) = ConsentKey::group(id.as_ref()).pre8_dm(db, own_inbox)? {
294            carried.insert(peer);
295        }
296    }
297    let mut plain: BTreeMap<ConsentKey, Vec<ConsentSave>> = BTreeMap::new();
298    let mut pre8: BTreeMap<ConsentKey, Vec<ConsentSave>> = BTreeMap::new();
299    for save in &import.consent {
300        // An unusable record is skipped.
301        let Some((key, _)) = parse_consent_save(save) else {
302            continue;
303        };
304        // A record naming a held 16-byte DM is its peer's.
305        let key = key.pre8_dm(db, own_inbox)?.map_or(key, |(peer, _)| peer);
306        let rows = if carried.contains(&key) {
307            pre8.entry(key)
308        } else {
309            plain.entry(key)
310        };
311        rows.or_default().push(save.clone());
312    }
313    for (peer, rows) in pre8 {
314        let combined = pre8::pre8_peer_consent(&peer.entity, &rows);
315        plain.insert(peer, combined.into_iter().collect());
316    }
317    let mut changed = false;
318    for (key, saves) in plain {
319        for save in saves {
320            let Some((_, value)) = parse_consent_save(&save) else {
321                continue;
322            };
323            let write = ConsentWrite::from_archive(value, now_ns);
324            changed |= write_consent(db, &key, write, Owed::Mark)?.is_some();
325        }
326    }
327    Ok(changed)
328}
329
330#[cfg(test)]
331mod tests {
332    #![allow(unused)]
333    use super::*;
334    use crate::groups::send_message_opts::SendMessageOpts;
335    use crate::prelude::*;
336    use crate::tester;
337    use crate::utils::{LocalTester, Tester};
338    use crate::worker::device_sync::{ArchiveOptions, BackupElementSelection};
339    use crate::{builder::ClientBuilder, utils::test::wait_for_min_intents};
340    use diesel::prelude::*;
341    use futures::AsyncReadExt;
342    use futures::io::{BufReader, Cursor};
343    use std::{path::Path, sync::Arc};
344    use xmtp_archive::exporter;
345    use xmtp_cryptography::utils::generate_local_wallet;
346    use xmtp_db::group_message::MsgQueryArgs;
347    use xmtp_db::{ConnectionExt, group::GroupMembershipState};
348    use xmtp_db::{
349        consent_record::StoredConsentRecord,
350        group::StoredGroup,
351        group_message::StoredGroupMessage,
352        schema::{consent_records, group_messages, groups},
353    };
354    use xmtp_mls_group_state::{
355        group::{DMMetadataOptions, GroupMetadataOptions},
356        group_mutable_metadata::MessageDisappearingSettings,
357    };
358    use xmtp_proto::types::GroupId;
359
360    /// Runs `on_first` once, when the export first writes past the archive
361    /// header, which happens while the snapshot's read transaction is open.
362    struct WriteDuringExport<F: FnMut()> {
363        archive: Vec<u8>,
364        on_first: Option<F>,
365    }
366
367    impl<F: FnMut()> std::io::Write for WriteDuringExport<F> {
368        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
369            if self.archive.len() >= 2 + xmtp_archive::NONCE_SIZE {
370                self.on_first.take().into_iter().for_each(|mut f| f());
371            }
372            self.archive.write(buf)
373        }
374
375        fn flush(&mut self) -> std::io::Result<()> {
376            Ok(())
377        }
378    }
379
380    /// A selected group whose mutable metadata cannot be decoded fails the
381    /// export, rather than exporting the group without that value. The
382    /// malformed admin list is committed locally through openmls, bypassing
383    /// the commit validation that keeps it out of real groups.
384    // verifies: ARCH-017
385    #[xmtp_common::test(unwrap_try = true)]
386    async fn archive_export_fails_on_undecodable_group_metadata() {
387        use openmls::{
388            component::ComponentData,
389            group::MlsGroup,
390            messages::proposals::{AppDataUpdateProposal, Proposal},
391        };
392        use openmls_traits::OpenMlsProvider;
393        use xmtp_archive::{ArchiveError, UnreadableGroup};
394        use xmtp_mls_group_state::app_data::component_id::ComponentId;
395
396        tester!(alix, disable_workers);
397        let conversation = alix.create_group(None, None)?;
398        let provider = alix.context.mls_provider();
399        let signer = &alix.context.identity().installation_keys;
400        let mut group = MlsGroup::load(
401            alix.context.mls_storage(),
402            &conversation.group_id.to_openmls(),
403        )??;
404        let malformed = vec![0xff; 3];
405        let mut stage = crate::groups::mls_ext::commit_without_group_info(
406            &mut group,
407            provider.storage(),
408            |builder| {
409                builder.add_proposal(Proposal::AppDataUpdate(Box::new(
410                    AppDataUpdateProposal::update(
411                        ComponentId::ADMIN_LIST.as_u16(),
412                        malformed.clone(),
413                    ),
414                )))
415            },
416        )?;
417        let mut updater = stage.app_data_dictionary_updater();
418        updater.set(ComponentData::from_parts(
419            ComponentId::ADMIN_LIST.as_u16(),
420            malformed.into(),
421        ));
422        stage.with_app_data_dictionary_updates(updater.changes());
423        stage
424            .build(provider.rand(), provider.crypto(), signer, |_| true)?
425            .stage_commit(&provider)?;
426        group.merge_pending_commit(&provider)?;
427
428        let opts = ArchiveOptions {
429            start_ns: None,
430            end_ns: None,
431            elements: vec![BackupElementSelection::Messages],
432            exclude_disappearing_messages: false,
433        };
434        let key = xmtp_common::rand_vec::<32>();
435        let failure = exporter::export(opts, alix.db(), &key, Vec::new());
436        assert!(
437            matches!(
438                failure,
439                Err(ArchiveError::UnreadableGroup {
440                    group_id,
441                    source: UnreadableGroup::MutableMetadata(_),
442                }) if group_id == conversation.group_id
443            ),
444            "export dropped an undecodable metadata value: {failure:?}"
445        );
446    }
447
448    /// An archive holds no group key. A keyed group's root never reaches it,
449    /// and a commit-log signer that a legacy group's dictionary still holds
450    /// is dropped from the exported attributes. The signer is committed
451    /// locally through openmls, because new groups never write one.
452    // verifies: GMOD-042
453    #[xmtp_common::test(unwrap_try = true)]
454    async fn archive_export_holds_no_group_keys() {
455        use openmls::{
456            component::ComponentData,
457            group::MlsGroup,
458            messages::proposals::{AppDataUpdateProposal, Proposal},
459        };
460        use openmls_traits::OpenMlsProvider;
461        use prost::Message;
462        use xmtp_mls_group_state::app_data::{
463            component_id::ComponentId, group_root::read_group_root,
464        };
465
466        tester!(alix, disable_workers);
467        let conversation = alix.create_group(None, None)?;
468        conversation
469            .send_message(b"archived", Default::default())
470            .await?;
471        let root = conversation
472            .with_group_snapshot(|mls_group| Ok(read_group_root(mls_group.extensions())))???;
473        let provider = alix.context.mls_provider();
474        let signer = &alix.context.identity().installation_keys;
475        let mut group = MlsGroup::load(
476            alix.context.mls_storage(),
477            &conversation.group_id.to_openmls(),
478        )??;
479        let commit_log_signer = xmtp_common::rand_vec::<32>();
480        let mut stage = crate::groups::mls_ext::commit_without_group_info(
481            &mut group,
482            provider.storage(),
483            |builder| {
484                builder.add_proposal(Proposal::AppDataUpdate(Box::new(
485                    AppDataUpdateProposal::update(
486                        ComponentId::COMMIT_LOG_SIGNER.as_u16(),
487                        commit_log_signer.clone(),
488                    ),
489                )))
490            },
491        )?;
492        let mut updater = stage.app_data_dictionary_updater();
493        updater.set(ComponentData::from_parts(
494            ComponentId::COMMIT_LOG_SIGNER.as_u16(),
495            commit_log_signer.clone().into(),
496        ));
497        stage.with_app_data_dictionary_updates(updater.changes());
498        stage
499            .build(provider.rand(), provider.crypto(), signer, |_| true)?
500            .stage_commit(&provider)?;
501        group.merge_pending_commit(&provider)?;
502
503        let opts = ArchiveOptions {
504            start_ns: None,
505            end_ns: None,
506            elements: vec![
507                BackupElementSelection::Messages,
508                BackupElementSelection::Consent,
509            ],
510            exclude_disappearing_messages: false,
511        };
512        let key = xmtp_common::rand_vec::<32>();
513        let mut archive = Vec::new();
514        exporter::export(opts, alix.db(), &key, &mut archive)?;
515        let reader = Box::pin(BufReader::new(Cursor::new(archive)));
516        let elements: Vec<_> = ArchiveImporter::load(reader, &key)
517            .await?
518            .collect::<Vec<_>>()
519            .await
520            .into_iter()
521            .collect::<Result<_, _>>()?;
522
523        let mut saved = None;
524        let mut plaintext = Vec::new();
525        for element in &elements {
526            plaintext.extend(element.encode_to_vec());
527            if let Some(Element::Group(group)) = &element.element
528                && group.id == conversation.group_id.to_vec()
529            {
530                saved = Some(group.clone());
531            }
532        }
533        let attributes = saved?.mutable_metadata?.attributes;
534        assert!(
535            attributes.contains_key("group_name"),
536            "the archive lost the group's attributes: {attributes:?}"
537        );
538        assert!(!attributes.contains_key("_commit_log_signer"));
539        let contains = |needle: &[u8]| plaintext.windows(needle.len()).any(|w| w == needle);
540        for (secret, encoded) in crate::utils::test::group_secret_encodings(&root) {
541            assert!(!contains(&encoded), "the archive holds the {secret}");
542        }
543        assert!(
544            !contains(&commit_log_signer),
545            "the archive holds the signer"
546        );
547        assert!(
548            !contains(hex::encode(&commit_log_signer).as_bytes()),
549            "the archive holds the signer as hex"
550        );
551        assert!(!contains(b"_commit_log_signer"));
552    }
553
554    /// With MESSAGES selected, a restore into a new installation holds every
555    /// eligible conversation whatever its creation time or messages: an empty
556    /// group, an empty DM, a group whose messages all precede the window, and
557    /// an old group with its in-window message. Consent edits and insertions
558    /// committed on another connection while the export is streaming are not
559    /// in the archive: the export is one snapshot. On wasm, whose single
560    /// connection the export holds, such writes are refused instead.
561    // verifies: ARCH-007, ARCH-017
562    // verifies: EVENT-004
563    #[xmtp_common::test(unwrap_try = true)]
564    async fn archive_snapshot_is_complete() {
565        use xmtp_db::consent_record::{ConsentState, ConsentType};
566        use xmtp_proto::xmtp::device_sync::{
567            backup_element::Element, consent_backup::ConsentStateSave,
568        };
569
570        tester!(alix, persistent_db, disable_workers);
571        tester!(bo, disable_workers);
572        let empty_group = alix.create_group(None, None)?;
573        let empty_dm = alix.find_or_create_dm(bo.inbox_id(), None).await?;
574        let outside = alix.create_group(None, None)?;
575        let outside_message = outside.send_message_optimistic(b"before", Default::default())?;
576        let old = alix.create_group(None, None)?;
577        let start_ns = xmtp_common::time::now_ns();
578        // The window's start is exclusive, and wasm clocks tick in whole
579        // milliseconds, so the in-window message must be sent a tick later.
580        xmtp_common::time::sleep(std::time::Duration::from_millis(2)).await;
581        // Large enough that the archive reaches the sink before consent is read.
582        let in_window = old.send_message_optimistic(&[7; 512 * 1024], Default::default())?;
583        let consent = |entity: &str, state| {
584            StoredConsentRecord::new(ConsentType::InboxId, state, entity.to_string())
585        };
586        consent("carol", ConsentState::Allowed).merge_into(&alix.db())?;
587
588        let opts = ArchiveOptions {
589            elements: vec![
590                BackupElementSelection::Messages,
591                BackupElementSelection::Consent,
592            ],
593            start_ns: Some(start_ns),
594            end_ns: None,
595            exclude_disappearing_messages: false,
596        };
597        let key = vec![7; 32];
598        let writer = alix.db();
599        let mut sink = WriteDuringExport {
600            archive: vec![],
601            on_first: Some(|| {
602                let written = consent("carol", ConsentState::Denied)
603                    .merge_into(&writer)
604                    .and_then(|_| consent("dave", ConsentState::Allowed).merge_into(&writer));
605                // Native writers on other connections commit during the export;
606                // wasm's single connection is held by the export and refuses
607                // them, so neither can reach the snapshot.
608                #[cfg(not(target_arch = "wasm32"))]
609                written.unwrap();
610                #[cfg(target_arch = "wasm32")]
611                assert!(written.is_err(), "wasm wrote during the export");
612            }),
613        };
614        exporter::export(opts, alix.db(), &key, &mut sink)?;
615        assert!(
616            sink.on_first.is_none(),
617            "the export never wrote mid-snapshot"
618        );
619        let archive = sink.archive;
620
621        let reader = Box::pin(BufReader::new(Cursor::new(archive.clone())));
622        let consent: Vec<_> = ArchiveImporter::load(reader, &key)
623            .await?
624            .filter_map(|e| async move {
625                match e.ok()?.element? {
626                    Element::Consent(c) if c.entity == "carol" || c.entity == "dave" => Some(c),
627                    _ => None,
628                }
629            })
630            .collect()
631            .await;
632        assert!(
633            matches!(
634                consent.as_slice(),
635                [c] if c.entity == "carol" && c.state == ConsentStateSave::Allowed as i32
636            ),
637            "the export saw writes committed after its snapshot: {consent:?}"
638        );
639
640        tester!(alix2, from: alix, disable_workers);
641        let events = alix2.context.events().subscribe(
642            xmtp_events::EventFilter::new([
643                xmtp_events::EventKind::ArchiveRestored,
644                xmtp_events::EventKind::ConversationJoined,
645                xmtp_events::EventKind::MessageReceived,
646                xmtp_events::EventKind::ConsentChanged,
647            ]),
648            Some(64),
649        );
650        let reader = Box::pin(BufReader::new(Cursor::new(archive)));
651        let mut importer = ArchiveImporter::load(reader, &key).await?;
652        insert_importer(&mut importer, &alix2.context).await?;
653
654        let db = alix2.db();
655        for group in [&empty_group, &empty_dm, &outside, &old] {
656            assert_eq!(
657                db.find_group(&group.group_id)??.membership_state,
658                GroupMembershipState::Restored
659            );
660        }
661        assert!(db.get_group_message(&in_window)?.is_some());
662        assert!(db.get_group_message(&outside_message)?.is_none());
663        assert!(
664            matches!(events.drain().as_slice(), [xmtp_events::EventEnvelope { client: Some(ClientEvent::ArchiveRestored(restored)), .. }] if restored.complete)
665        );
666    }
667
668    // verifies: EVENT-001, EVENT-017
669    #[xmtp_common::test(unwrap_try = true)]
670    async fn partial_import_reports_incomplete_after_a_stored_change() {
671        tester!(alix, disable_workers);
672        let events = alix.context.events().subscribe(
673            xmtp_events::EventFilter::new([xmtp_events::EventKind::ArchiveRestored]),
674            Some(4),
675        );
676        let record = StoredConsentRecord::new(
677            xmtp_db::consent_record::ConsentType::InboxId,
678            xmtp_db::consent_record::ConsentState::Allowed,
679            "failed-import".into(),
680        );
681        let element = BackupElement {
682            element: Some(Element::Consent(record.into())),
683        };
684        let mut elements =
685            futures::stream::iter([Ok(element), Err(std::io::Error::other("archive cut"))]);
686        assert!(
687            insert_elements(&mut elements, &alix.context, Default::default())
688                .await
689                .is_err()
690        );
691        assert!(
692            alix.db()
693                .consent_row(&xmtp_db::consent_record::ConsentKey::inbox("failed-import"))?
694                .is_some()
695        );
696        assert!(matches!(
697            events.drain().as_slice(),
698            [xmtp_events::EventEnvelope {
699                client: Some(ClientEvent::ArchiveRestored(restored)), ..
700            }] if !restored.complete
701        ));
702    }
703
704    // verifies: EVENT-004
705    #[xmtp_common::test(unwrap_try = true)]
706    async fn failed_import_suppresses_events_for_all_stored_elements() {
707        tester!(alix, disable_workers);
708        tester!(bo, disable_workers);
709        let group = alix.create_group(None, None)?;
710        let message_id = group.send_message_optimistic(b"archive message", Default::default())?;
711        StoredConsentRecord::new(
712            xmtp_db::consent_record::ConsentType::InboxId,
713            xmtp_db::consent_record::ConsentState::Denied,
714            bo.inbox_id().into(),
715        )
716        .merge_into(&alix.db())?;
717        let key = vec![9; 32];
718        let mut archive = vec![];
719        exporter::export(
720            ArchiveOptions {
721                elements: vec![
722                    BackupElementSelection::Messages,
723                    BackupElementSelection::Consent,
724                ],
725                start_ns: None,
726                end_ns: None,
727                exclude_disappearing_messages: false,
728            },
729            alix.db(),
730            &key,
731            &mut archive,
732        )?;
733        let importer =
734            ArchiveImporter::load(Box::pin(BufReader::new(Cursor::new(archive))), &key).await?;
735        let mut elements = importer
736            .map(|item| item.map_err(DeviceSyncError::from))
737            .chain(futures::stream::iter([Err(DeviceSyncError::from(
738                std::io::Error::other("archive cut"),
739            ))]));
740        tester!(restored, from: alix, disable_workers);
741        let events = restored.context.events().subscribe(
742            xmtp_events::EventFilter::new([
743                xmtp_events::EventKind::ArchiveRestored,
744                xmtp_events::EventKind::ConversationJoined,
745                xmtp_events::EventKind::MessageReceived,
746                xmtp_events::EventKind::ConsentChanged,
747            ]),
748            Some(64),
749        );
750        assert!(
751            insert_elements(&mut elements, &restored.context, Default::default())
752                .await
753                .is_err()
754        );
755        assert!(restored.db().find_group(&group.group_id)?.is_some());
756        assert!(restored.db().get_group_message(&message_id)?.is_some());
757        assert!(
758            restored
759                .db()
760                .consent_row(&xmtp_db::consent_record::ConsentKey::inbox(bo.inbox_id()))?
761                .is_some()
762        );
763        assert!(
764            matches!(events.drain().as_slice(), [xmtp_events::EventEnvelope { client: Some(ClientEvent::ArchiveRestored(restored)), .. }] if !restored.complete)
765        );
766    }
767    /// The greater record wins, and a time more than a day past the import
768    /// is stored at that limit.
769    // verifies: CONS-053, CONS-054
770    #[xmtp_common::test(unwrap_try = true)]
771    async fn archived_consent_keeps_the_latest_record() {
772        tester!(alix, disable_workers);
773        let soon = xmtp_common::time::now_ns() + xmtp_common::NS_IN_HOUR;
774        let records = [
775            (xmtp_db::consent_record::ConsentState::Denied, i64::MAX),
776            (xmtp_db::consent_record::ConsentState::Allowed, 10),
777            (xmtp_db::consent_record::ConsentState::Allowed, soon),
778            (xmtp_db::consent_record::ConsentState::Denied, 20),
779        ];
780        let mut elements = futures::stream::iter(records.map(|(state, consented_at_ns)| {
781            let record = StoredConsentRecord {
782                entity_type: xmtp_db::consent_record::ConsentType::InboxId,
783                state,
784                entity: "archived-inbox".into(),
785                consented_at_ns,
786                owed_seq: 0,
787            };
788            Ok::<_, std::io::Error>(BackupElement {
789                element: Some(Element::Consent(record.into())),
790            })
791        }));
792        insert_elements(&mut elements, &alix.context, Default::default()).await?;
793        let limit = xmtp_common::time::now_ns() + xmtp_common::NS_IN_DAY;
794        let stored = alix
795            .db()
796            .consent_row(&xmtp_db::consent_record::ConsentKey::inbox(
797                "archived-inbox",
798            ))?
799            .unwrap();
800        assert_eq!(stored.state, xmtp_db::consent_record::ConsentState::Denied);
801        assert!(stored.consented_at_ns > soon && stored.consented_at_ns <= limit);
802    }
803
804    /// An import that only moves a stored record's time still stored an
805    /// element, so it reports a restore.
806    // verifies: EVENT-001
807    #[xmtp_common::test(unwrap_try = true)]
808    async fn archived_consent_time_move_reports_a_restore() {
809        tester!(alix, disable_workers);
810        let events = alix.context.events().subscribe(
811            xmtp_events::EventFilter::new([xmtp_events::EventKind::ArchiveRestored]),
812            Some(4),
813        );
814        let record = |consented_at_ns| StoredConsentRecord {
815            entity_type: xmtp_db::consent_record::ConsentType::InboxId,
816            state: xmtp_db::consent_record::ConsentState::Allowed,
817            entity: "time-move".into(),
818            consented_at_ns,
819            owed_seq: 0,
820        };
821        assert!(record(10).merge_into(&alix.db())?.is_some());
822        let mut elements = futures::stream::iter([Ok::<_, std::io::Error>(BackupElement {
823            element: Some(Element::Consent(record(30).into())),
824        })]);
825        insert_elements(&mut elements, &alix.context, Default::default()).await?;
826        let stored = alix
827            .db()
828            .consent_row(&xmtp_db::consent_record::ConsentKey::inbox("time-move"))?
829            .unwrap();
830        assert_eq!(stored.consented_at_ns, 30);
831        assert!(matches!(
832            events.drain().as_slice(),
833            [xmtp_events::EventEnvelope {
834                client: Some(ClientEvent::ArchiveRestored(restored)), ..
835            }] if restored.complete
836        ));
837    }
838
839    #[xmtp_common::test(unwrap_try = true)]
840    async fn archive_timestamp_keeps_a_message_received_during_import() {
841        tester!(alix, disable_workers);
842        let group = alix.create_group(None, None)?;
843        let save = xmtp_proto::xmtp::device_sync::group_backup::GroupSave {
844            id: group.group_id.to_vec(),
845            last_message_ns: Some(0),
846            ..Default::default()
847        };
848
849        group.send_message_optimistic(b"message during import", Default::default())?;
850        let current = alix.db().find_group(&group.group_id)??.last_message_ns;
851        assert!(current > Some(0));
852        MlsGroup::restore_from_archive(&alix.context, &save)?;
853        assert_eq!(
854            alix.db().find_group(&group.group_id)??.last_message_ns,
855            current
856        );
857    }
858
859    #[xmtp_common::test(unwrap_try = true)]
860    async fn test_archive_timestamps() {
861        tester!(alix, disable_workers);
862        tester!(alix2, from: alix);
863        tester!(bo, disable_workers);
864
865        let alix_group = alix
866            .create_group_with_members(&[bo.inbox_id()], None, None)
867            .await?;
868        alix_group.send_message(b"hi", Default::default()).await?;
869
870        alix2.sync_welcomes().await?;
871        bo.sync_welcomes().await?;
872
873        let alix2_group = alix2.group(&alix_group.group_id)?;
874        let bo_group = bo.group(&alix_group.group_id)?;
875
876        alix2_group.sync().await?;
877        bo_group.sync().await?;
878
879        // We want to send this message so that alix's group timestamp gets ahead of alix2.
880        alix_group
881            .send_message(b"Hello again", Default::default())
882            .await?;
883
884        let key = vec![7; 32];
885        let opts = ArchiveOptions {
886            start_ns: None,
887            end_ns: None,
888            elements: vec![
889                BackupElementSelection::Messages,
890                BackupElementSelection::Consent,
891            ],
892            exclude_disappearing_messages: false,
893        };
894        let export = {
895            let mut file = vec![];
896            exporter::export(opts, alix.db(), &key, &mut file)?;
897            file
898        };
899
900        tester!(alix3, from: alix);
901
902        // Now we will have alix2 and alix3 import the archives.
903        // One installation has the group already, one does not.
904        let reader = Box::pin(BufReader::new(Cursor::new(export.clone())));
905        let mut importer = ArchiveImporter::load(reader, &key).await?;
906        insert_importer(&mut importer, &alix2.context).await?;
907
908        let reader = Box::pin(BufReader::new(Cursor::new(export)));
909        let mut importer = ArchiveImporter::load(reader, &key).await?;
910        insert_importer(&mut importer, &alix3.context).await?;
911
912        let alix_timestamp = alix
913            .db()
914            .find_group(&alix_group.group_id)??
915            .last_message_ns?;
916        let alix2_timestamp = alix2
917            .db()
918            .find_group(&alix_group.group_id)??
919            .last_message_ns?;
920        let alix3_timestamp = alix3
921            .db()
922            .find_group(&alix_group.group_id)??
923            .last_message_ns?;
924
925        // Alix2's older timestamp on the existing group should be updated.
926        assert_eq!(alix2_timestamp, alix_timestamp);
927        // Alix3's timestamp should equal alix's timestamp.
928        assert_eq!(alix3_timestamp, alix_timestamp);
929    }
930
931    #[xmtp_common::test(unwrap_try = true)]
932    async fn test_dm_archive() {
933        tester!(alix, disable_workers);
934        tester!(bo, disable_workers);
935
936        let alix_bo_dm = alix.find_or_create_dm(bo.inbox_id(), None).await?;
937        let archived_message_id = alix_bo_dm
938            .send_message(b"old group", Default::default())
939            .await?;
940
941        let timestamp = alix
942            .db()
943            .find_group(&alix_bo_dm.group_id)??
944            .last_message_ns?;
945
946        let key = vec![7; 32];
947        let opts = ArchiveOptions {
948            start_ns: None,
949            end_ns: None,
950            elements: vec![
951                BackupElementSelection::Messages,
952                BackupElementSelection::Consent,
953            ],
954            exclude_disappearing_messages: false,
955        };
956        let export = {
957            let mut file = vec![];
958
959            exporter::export(opts, alix.db(), &key, &mut file)?;
960            file
961        };
962
963        tester!(alix2, from: alix);
964        let reader = Box::pin(BufReader::new(Cursor::new(export)));
965        let mut importer = ArchiveImporter::load(reader, &key).await?;
966        insert_importer(&mut importer, &alix2.context).await?;
967
968        // Imported history has no joined MLS state for this installation.
969        // Receipt may retain the old prefix, but processing must wait for a Welcome.
970        let restored = alix2.group(&alix_bo_dm.group_id)?;
971        let restored_topic = xmtp_db::incoming_envelope::StreamTopic::group(alix_bo_dm.group_id);
972        let before_join = alix2.db().topic_progress(&restored_topic)?;
973        crate::mls_store::MlsStore::new(alix2.context.clone())
974            .receive_topics_once(
975                &[xmtp_proto::types::Topic::new_group_message(
976                    alix_bo_dm.group_id,
977                )],
978                alix2
979                    .context
980                    .incoming_runtime()
981                    .policy()
982                    .incoming_limits(xmtp_db::incoming_envelope::NetworkEntityKind::Group),
983            )
984            .await?;
985        let received_before_join = alix2.db().topic_progress(&restored_topic)?;
986        assert!(received_before_join.received > before_join.processed);
987        assert!(matches!(
988            restored.process_pending_group_head(None)?,
989            crate::groups::mls_sync::GroupHeadOutcome::Inactive
990        ));
991        assert_eq!(
992            alix2.db().topic_progress(&restored_topic)?.processed,
993            before_join.processed
994        );
995        assert!(
996            !alix2
997                .db()
998                .pending_states_through(&restored_topic, received_before_join.received)?
999                .is_empty()
1000        );
1001
1002        let alix2_bo_dm = alix2.find_or_create_dm(bo.inbox_id(), None).await?;
1003        assert_ne!(alix_bo_dm.group_id, alix2_bo_dm.group_id);
1004        let mut msgs = alix2_bo_dm.find_messages(&MsgQueryArgs::default())?;
1005        assert_eq!(msgs.len(), 2);
1006        assert!(
1007            msgs.iter()
1008                .any(|m| m.decrypted_message_bytes == b"old group")
1009        );
1010
1011        // assert_eq!(alix2_bo_dm.test_last_message_bytes().await??, b"old group");
1012
1013        let timestamp2 = alix2
1014            .db()
1015            .find_group(&alix_bo_dm.group_id)??
1016            .last_message_ns?;
1017        assert_eq!(timestamp, timestamp2);
1018
1019        let live_message_id = alix2_bo_dm
1020            .send_message(b"hi bo", Default::default())
1021            .await?;
1022
1023        bo.sync_all_welcomes_and_groups(None).await?;
1024        let bo_alix2_dm = bo.group(&alix2_bo_dm.group_id)?;
1025        assert_eq!(bo_alix2_dm.test_last_message_bytes().await??, b"hi bo");
1026
1027        // Ordinary sync must add the new installation to the original DM too.
1028        alix2.sync_all_welcomes_and_groups(None).await?;
1029        let rejoined_original = alix2.group(&alix_bo_dm.group_id)?;
1030        assert!(rejoined_original.is_active()?);
1031        let stitched = alix2_bo_dm.find_messages(&MsgQueryArgs::default())?;
1032        assert_eq!(stitched.len(), 4);
1033        let application_ids: Vec<_> = stitched
1034            .iter()
1035            .filter(|message| message.kind == xmtp_db::group_message::GroupMessageKind::Application)
1036            .map(|message| message.id.clone())
1037            .collect();
1038        assert_eq!(application_ids, vec![archived_message_id, live_message_id]);
1039        assert_eq!(alix2.find_groups(Default::default())?.len(), 1);
1040        let bo_original = bo.group(&alix_bo_dm.group_id)?;
1041        rejoined_original.test_can_talk_with(&bo_original).await?;
1042        bo_original.test_can_talk_with(&rejoined_original).await?;
1043    }
1044
1045    // verifies: ARCH-015, ARCH-020
1046    #[xmtp_common::test(unwrap_try = true)]
1047    async fn foreign_archive_preserves_dm_pair_and_history() {
1048        use crate::groups::GroupError;
1049        use xmtp_db::{
1050            consent_record::{ConsentState, ConsentType},
1051            group::{DmIdExt, QueryGroup},
1052            readd_status::ReaddStatus,
1053        };
1054        use xmtp_proto::api::HasStats;
1055
1056        tester!(alix, disable_workers);
1057        tester!(bo, disable_workers);
1058        tester!(charlie, disable_workers);
1059        tester!(dana, disable_workers);
1060        let source = alix.find_or_create_dm(bo.inbox_id(), None).await?;
1061        let alix_id = source
1062            .send_message(b"from alix", Default::default())
1063            .await?;
1064        bo.sync_welcomes().await?;
1065        let bo_group = bo.group(&source.group_id)?;
1066        let bo_id = bo_group
1067            .send_message(b"from bo", Default::default())
1068            .await?;
1069        source.sync().await?;
1070        let archived_dm_id = alix
1071            .db()
1072            .find_group(&source.group_id)?
1073            .expect("source DM")
1074            .dm_id
1075            .expect("DM pair");
1076        assert!(!archived_dm_id.contains(charlie.inbox_id()));
1077
1078        let key = vec![0x38; 32];
1079        let opts = ArchiveOptions {
1080            start_ns: None,
1081            end_ns: None,
1082            elements: vec![BackupElementSelection::Messages],
1083            exclude_disappearing_messages: false,
1084        };
1085        let mut export = vec![];
1086        exporter::export(opts.clone(), alix.db(), &key, &mut export)?;
1087        let reader = Box::pin(BufReader::new(Cursor::new(export)));
1088        let mut importer = ArchiveImporter::load(reader, &key).await?;
1089        insert_importer(&mut importer, &charlie.context).await?;
1090
1091        let stored = charlie
1092            .db()
1093            .find_group(&source.group_id)?
1094            .expect("Restored DM");
1095        assert_eq!(stored.membership_state, GroupMembershipState::Restored);
1096        assert_eq!(stored.dm_id.as_deref(), Some(archived_dm_id.as_str()));
1097        assert_eq!(archived_dm_id.other_inbox_id(charlie.inbox_id()), None);
1098        let restored = charlie.group(&source.group_id)?;
1099        assert_eq!(
1100            restored
1101                .metadata()
1102                .await?
1103                .dm_members
1104                .expect("stub pair")
1105                .to_string(),
1106            archived_dm_id
1107        );
1108        assert!(!restored.is_active()?);
1109        let stats = charlie.context.api().api_client.mls_stats();
1110        stats.clear();
1111        assert!(matches!(
1112            restored.send_message(b"inactive", Default::default()).await,
1113            Err(GroupError::GroupInactive)
1114        ));
1115        assert!(matches!(
1116            restored.sync().await,
1117            Err(GroupError::GroupInactive)
1118        ));
1119        let Err(sync_summary) = restored.sync_with_conn().await else {
1120            panic!("Restored sync_with_conn must reject the request");
1121        };
1122        assert!(matches!(
1123            sync_summary.other.as_deref(),
1124            Some(GroupError::GroupInactive)
1125        ));
1126        assert!(matches!(
1127            restored.receive().await,
1128            Err(GroupError::GroupInactive)
1129        ));
1130        assert!(matches!(
1131            charlie.client.sync_all_groups(vec![restored.clone()]).await,
1132            Err(GroupError::GroupInactive)
1133        ));
1134        assert_eq!(stats.publish.get_count(), 0);
1135        let joined = charlie.create_group(None, None)?;
1136        joined.add_members(&[dana.inbox_id()]).await?;
1137        dana.sync_welcomes().await?;
1138        let dana_group = dana.group(&joined.group_id)?;
1139        let incoming_id = dana_group
1140            .send_message(b"joined group", Default::default())
1141            .await?;
1142        let Err(GroupError::Sync(summary)) = charlie
1143            .client
1144            .sync_all_groups(vec![restored.clone(), joined])
1145            .await
1146        else {
1147            panic!("mixed explicit sync must report the inactive group");
1148        };
1149        assert!(matches!(
1150            summary.other.as_deref(),
1151            Some(GroupError::GroupInactive)
1152        ));
1153        assert!(charlie.db().get_group_message(&incoming_id)?.is_some());
1154        let sweep = charlie.client.sync_all_welcomes_and_groups(None).await?;
1155        assert_eq!(sweep.num_eligible, 1);
1156        assert_eq!(sweep.num_synced, 1);
1157        assert!(!restored.is_active()?);
1158        let db = charlie.db();
1159        // Even an allowed archived DM has no authority for remote recovery.
1160        StoredConsentRecord::new(
1161            ConsentType::ConversationId,
1162            ConsentState::Allowed,
1163            hex::encode(source.group_id),
1164        )
1165        .merge_into(&db)?;
1166        let excludes_publish = db
1167            .get_conversation_ids_for_remote_log_publish()?
1168            .iter()
1169            .all(|group| group.id != source.group_id);
1170        let excludes_download = db
1171            .get_conversation_ids_for_remote_log_download()?
1172            .iter()
1173            .all(|group| group.id != source.group_id);
1174        let excludes_fork_check = db
1175            .get_conversation_ids_for_fork_check()?
1176            .iter()
1177            .all(|id| id.as_slice() != source.group_id.as_ref());
1178        db.set_group_commit_log_forked_status(&source.group_id, Some(true))?;
1179        let excludes_requesting_readds = db
1180            .get_conversation_ids_for_requesting_readds()?
1181            .iter()
1182            .all(|group| group.group_id != source.group_id);
1183        ReaddStatus {
1184            group_id: source.group_id,
1185            installation_id: vec![0x42; 32],
1186            requested_at_sequence_id: Some(1),
1187            responded_at_sequence_id: None,
1188        }
1189        .store(&db)?;
1190        let excludes_responding_readds = db
1191            .get_conversation_ids_for_responding_readds()?
1192            .iter()
1193            .all(|group| group.group_id != source.group_id);
1194        assert_eq!(
1195            [
1196                excludes_publish,
1197                excludes_download,
1198                excludes_fork_check,
1199                excludes_requesting_readds,
1200                excludes_responding_readds,
1201            ],
1202            [true; 5],
1203            "Restored DM must be absent from every recovery selector"
1204        );
1205        for (id, sender, bytes) in [
1206            (&alix_id, alix.inbox_id(), b"from alix".as_slice()),
1207            (&bo_id, bo.inbox_id(), b"from bo".as_slice()),
1208        ] {
1209            let message = charlie.db().get_group_message(id)?.expect("pair history");
1210            assert_eq!(message.sender_inbox_id, sender);
1211            assert_eq!(message.decrypted_message_bytes, bytes);
1212        }
1213
1214        let mut reexport = vec![];
1215        exporter::export(opts, charlie.db(), &key, &mut reexport)?;
1216        let reader = Box::pin(BufReader::new(Cursor::new(reexport)));
1217        let mut exported = ArchiveImporter::load(reader, &key).await?;
1218        while let Some(element) = exported.next().await {
1219            match element?.element {
1220                Some(Element::Group(group)) => assert_ne!(group.id, source.group_id.as_ref()),
1221                Some(Element::GroupMessage(message)) => {
1222                    assert_ne!(message.group_id, source.group_id.as_ref());
1223                }
1224                _ => {}
1225            }
1226        }
1227
1228        let reopened = crate::builder::ClientBuilder::from_client(charlie.client.clone())
1229            .with_disable_workers(true)
1230            .build()
1231            .await?;
1232        let reopened_group = reopened.group(&source.group_id)?;
1233        assert_eq!(
1234            reopened_group.dm_id.as_deref(),
1235            Some(archived_dm_id.as_str())
1236        );
1237        assert!(!reopened_group.is_active()?);
1238        assert!(reopened.db().get_group_message(&alix_id)?.is_some());
1239        assert!(reopened.db().get_group_message(&bo_id)?.is_some());
1240    }
1241
1242    // verifies: ARCH-013
1243    #[xmtp_common::test(unwrap_try = true)]
1244    async fn archive_import_leaves_a_known_message_unchanged() {
1245        tester!(alix, disable_workers);
1246        let group = alix.create_group(None, None)?;
1247        let known_id = group.send_message(b"known", Default::default()).await?;
1248        let known = alix
1249            .db()
1250            .get_group_message(&known_id)?
1251            .expect("known message");
1252        let mut save: xmtp_proto::xmtp::device_sync::message_backup::GroupMessageSave =
1253            known.clone().into();
1254        save.sender_inbox_id = hex::encode([0x43; 32]);
1255        save.decrypted_message_bytes = b"ignored replacement".to_vec();
1256        let mut duplicate = futures::stream::iter([Ok::<_, std::io::Error>(BackupElement {
1257            element: Some(Element::GroupMessage(save)),
1258        })]);
1259        insert_elements(&mut duplicate, &alix.context, Default::default()).await?;
1260        assert_eq!(alix.db().get_group_message(&known_id)?, Some(known));
1261    }
1262
1263    // verifies: ARCH-019, ARCH-009
1264    #[xmtp_common::test(unwrap_try = true)]
1265    async fn imported_message_keeps_expiry_and_exclusion() {
1266        tester!(alix, disable_workers);
1267        tester!(bo, disable_workers);
1268        let source = alix.create_group(
1269            None,
1270            Some(GroupMetadataOptions {
1271                message_disappearing_settings: Some(MessageDisappearingSettings::new(
1272                    1,
1273                    xmtp_common::NS_IN_HOUR,
1274                )),
1275                ..Default::default()
1276            }),
1277        )?;
1278        let message_id = source
1279            .send_message(b"disappearing archive content", Default::default())
1280            .await?;
1281        let source_message = alix
1282            .db()
1283            .get_group_message(&message_id)?
1284            .expect("sent message");
1285        assert!(
1286            source_message
1287                .expire_at_ns
1288                .is_some_and(|deadline| deadline > xmtp_common::time::now_ns()),
1289            "the source message must still be eligible for export"
1290        );
1291
1292        source
1293            .remove_conversation_message_disappearing_settings()
1294            .await?;
1295        let permanent_id = source
1296            .send_message(b"permanent archive content", Default::default())
1297            .await?;
1298        let permanent = alix
1299            .db()
1300            .get_group_message(&permanent_id)?
1301            .expect("permanent source");
1302        assert_eq!(
1303            (permanent.expire_at_ns, permanent.expiry_unknown),
1304            (None, false)
1305        );
1306
1307        let key = [0x4a; 32];
1308        let options = ArchiveOptions {
1309            elements: vec![BackupElementSelection::Messages],
1310            start_ns: None,
1311            end_ns: None,
1312            exclude_disappearing_messages: false,
1313        };
1314        let mut first_archive = Vec::new();
1315        exporter::export(options.clone(), alix.db(), &key, &mut first_archive)?;
1316        let reader = Box::pin(BufReader::new(Cursor::new(first_archive)));
1317        let mut importer = ArchiveImporter::load(reader, &key).await?;
1318        insert_importer(&mut importer, &bo.context).await?;
1319
1320        let imported = bo
1321            .db()
1322            .get_group_message(&message_id)?
1323            .expect("imported message");
1324        assert_eq!(
1325            imported.decrypted_message_bytes,
1326            source_message.decrypted_message_bytes
1327        );
1328        assert_eq!(imported.expire_at_ns, source_message.expire_at_ns);
1329        assert!(!imported.expiry_unknown);
1330        let permanent_copy = bo
1331            .db()
1332            .get_group_message(&permanent_id)?
1333            .expect("permanent copy");
1334        assert_eq!(
1335            (permanent_copy.expire_at_ns, permanent_copy.expiry_unknown),
1336            (None, false)
1337        );
1338        assert_eq!(
1339            bo.db()
1340                .find_group(&source.group_id)?
1341                .expect("imported group")
1342                .membership_state,
1343            GroupMembershipState::Restored
1344        );
1345
1346        source.add_members(&[bo.inbox_id()]).await?;
1347        bo.sync_welcomes().await?;
1348        assert!(bo.group(&source.group_id)?.is_active()?);
1349
1350        // An older archive lacks expiry_known, even if it carries a stray deadline.
1351        let unknown_id = vec![0x5b; 32];
1352        let mut old =
1353            xmtp_proto::xmtp::device_sync::message_backup::GroupMessageSave::from(permanent);
1354        old.id = unknown_id.clone();
1355        old.expiry_known = None;
1356        old.expires_at_ns = source_message.expire_at_ns;
1357        let mut old_elements = futures::stream::iter([Ok::<_, std::io::Error>(BackupElement {
1358            element: Some(Element::GroupMessage(old)),
1359        })]);
1360        insert_elements(&mut old_elements, &bo.context, Default::default()).await?;
1361        let unknown = bo
1362            .db()
1363            .get_group_message(&unknown_id)?
1364            .expect("old archive row");
1365        assert_eq!((unknown.expire_at_ns, unknown.expiry_unknown), (None, true));
1366
1367        let mut excluded = options.clone();
1368        excluded.exclude_disappearing_messages = true;
1369        let mut second_archive = Vec::new();
1370        exporter::export(excluded, bo.db(), &key, &mut second_archive)?;
1371        let reader = Box::pin(BufReader::new(Cursor::new(second_archive)));
1372        let messages: Vec<_> = ArchiveImporter::load(reader, &key)
1373            .await?
1374            .collect::<Vec<_>>()
1375            .await
1376            .into_iter()
1377            .collect::<Result<_, _>>()?;
1378        let saved: Vec<_> = messages
1379            .into_iter()
1380            .filter_map(|element| match element.element {
1381                Some(Element::GroupMessage(saved)) => Some(saved),
1382                _ => None,
1383            })
1384            .collect();
1385        assert_eq!(
1386            saved.len(),
1387            1,
1388            "only known non-disappearing history is exported"
1389        );
1390        assert_eq!(saved[0].id, permanent_id);
1391        assert_eq!(
1392            (saved[0].expiry_known, saved[0].expires_at_ns),
1393            (Some(true), None)
1394        );
1395
1396        let mut default_export = Vec::new();
1397        exporter::export(options, bo.db(), &key, &mut default_export)?;
1398        let reader = Box::pin(BufReader::new(Cursor::new(default_export)));
1399        let exported = ArchiveImporter::load(reader, &key)
1400            .await?
1401            .collect::<Vec<_>>()
1402            .await
1403            .into_iter()
1404            .collect::<Result<Vec<_>, _>>()?;
1405        let saved = exported
1406            .into_iter()
1407            .find_map(|element| match element.element {
1408                Some(Element::GroupMessage(saved)) if saved.id == unknown_id => Some(saved),
1409                _ => None,
1410            })
1411            .expect("default export retains unknown history");
1412        assert_eq!(
1413            (saved.expiry_known, saved.expires_at_ns),
1414            (Some(false), None)
1415        );
1416    }
1417
1418    /// A DM restored before a failing element is a completed element. Its
1419    /// archived activity must survive the failed import, even when no
1420    /// retained message carries that timestamp.
1421    // verifies: ARCH-027
1422    #[xmtp_common::test(unwrap_try = true)]
1423    async fn failed_import_keeps_archived_activity_of_restored_prefix() {
1424        use xmtp_mls_group_state::group_metadata::DmMembers;
1425        use xmtp_proto::xmtp::device_sync::{
1426            group_backup::GroupSave,
1427            message_backup::{GroupMessageKindSave, GroupMessageSave},
1428        };
1429
1430        tester!(caro, disable_workers);
1431        let pair = DmMembers {
1432            member_one_inbox_id: hex::encode([0x41; 32]),
1433            member_two_inbox_id: hex::encode([0x42; 32]),
1434        };
1435        let group_id = vec![0x65; 16];
1436        let archived_activity = 1_234_567;
1437        let group = GroupSave {
1438            id: group_id.clone(),
1439            conversation_type: 2,
1440            dm_id: Some(pair.to_string()),
1441            last_message_ns: Some(archived_activity),
1442            ..Default::default()
1443        };
1444        // A recognized message element with an unspecified kind is malformed.
1445        let malformed = GroupMessageSave {
1446            id: vec![0x66; 32],
1447            group_id: group_id.clone(),
1448            kind: GroupMessageKindSave::Unspecified as i32,
1449            sender_inbox_id: pair.member_one_inbox_id.clone(),
1450            ..Default::default()
1451        };
1452
1453        let events = caro.context.events().subscribe(
1454            xmtp_events::EventFilter::new([xmtp_events::EventKind::ArchiveRestored]),
1455            Some(4),
1456        );
1457        let mut elements = futures::stream::iter([
1458            Ok::<_, std::io::Error>(BackupElement {
1459                element: Some(Element::Group(group)),
1460            }),
1461            Ok(BackupElement {
1462                element: Some(Element::GroupMessage(malformed.clone())),
1463            }),
1464        ]);
1465        let error = insert_elements(&mut elements, &caro.context, Default::default())
1466            .await
1467            .expect_err("a malformed element fails the import");
1468        assert!(
1469            matches!(
1470                error,
1471                DeviceSyncError::ProtoConversion(xmtp_proto::ConversionError::Unspecified(
1472                    "message_kind"
1473                ))
1474            ),
1475            "unexpected import error: {error:?}"
1476        );
1477
1478        let restored = caro
1479            .db()
1480            .find_group(&GroupId::try_from(group_id.as_slice())?)??;
1481        assert_eq!(restored.membership_state, GroupMembershipState::Restored);
1482        assert_eq!(restored.last_message_ns, Some(archived_activity));
1483        assert!(caro.db().get_group_message(&malformed.id)?.is_none());
1484        assert!(matches!(
1485            events.drain().as_slice(),
1486            [xmtp_events::EventEnvelope {
1487                client: Some(ClientEvent::ArchiveRestored(restored)), ..
1488            }] if !restored.complete
1489        ));
1490    }
1491
1492    // verifies: DMS-001, ARCH-027
1493    #[xmtp_common::test(unwrap_try = true)]
1494    async fn malformed_archived_dm_id_fails_before_group_insert() {
1495        tester!(alix, disable_workers);
1496        let group_id = vec![0x64; 16];
1497        let save = xmtp_proto::xmtp::device_sync::group_backup::GroupSave {
1498            id: group_id.clone(),
1499            conversation_type: 2,
1500            dm_id: Some("dm:malformed".into()),
1501            ..Default::default()
1502        };
1503        let mut elements = futures::stream::iter([Ok::<_, std::io::Error>(BackupElement {
1504            element: Some(Element::Group(save)),
1505        })]);
1506        assert!(
1507            insert_elements(&mut elements, &alix.context, Default::default())
1508                .await
1509                .is_err()
1510        );
1511        assert!(
1512            alix.db()
1513                .find_group(&GroupId::try_from(group_id.as_slice())?)?
1514                .is_none()
1515        );
1516    }
1517
1518    // verifies: EVENT-001, EVENT-017
1519    #[rstest::rstest]
1520    #[xmtp_common::test]
1521    async fn test_buffer_export_import() {
1522        use futures::io::BufReader;
1523        use futures_util::AsyncReadExt;
1524
1525        tester!(alix);
1526        tester!(bo);
1527
1528        let alix_group = alix.create_group(None, None).unwrap();
1529        alix_group.add_members(&[bo.inbox_id()]).await.unwrap();
1530        alix_group
1531            .send_message(b"hello there", SendMessageOpts::default())
1532            .await
1533            .unwrap();
1534
1535        let opts = ArchiveOptions {
1536            start_ns: None,
1537            end_ns: None,
1538            elements: vec![
1539                BackupElementSelection::Messages,
1540                BackupElementSelection::Consent,
1541            ],
1542            exclude_disappearing_messages: false,
1543        };
1544
1545        let key = vec![7; 32];
1546
1547        let file = {
1548            let mut file = Vec::new();
1549            exporter::export(opts, alix.db(), &key, &mut file).unwrap();
1550            file
1551        };
1552
1553        let alix2_wallet = generate_local_wallet();
1554        let alix2 = ClientBuilder::new_test_client(&alix2_wallet).await;
1555        let events = alix2.context.events().subscribe(
1556            xmtp_events::EventFilter::new([xmtp_events::EventKind::ArchiveRestored]),
1557            Some(4),
1558        );
1559
1560        // No messages
1561        let messages: Vec<StoredGroupMessage> = alix2
1562            .context
1563            .db()
1564            .raw_query(|conn| {
1565                group_messages::table
1566                    .select(StoredGroupMessage::as_select())
1567                    .load(conn)
1568            })
1569            .unwrap();
1570        assert_eq!(messages.len(), 0);
1571
1572        let reader = BufReader::new(Cursor::new(file.clone()));
1573        let reader = Box::pin(reader);
1574        let mut importer = ArchiveImporter::load(reader, &key).await.unwrap();
1575        insert_importer(&mut importer, &alix2.context)
1576            .await
1577            .unwrap();
1578        assert!(matches!(
1579            events.drain().as_slice(),
1580            [xmtp_events::EventEnvelope {
1581                client: Some(xmtp_events::ClientEvent::ArchiveRestored(restored)), ..
1582            }] if restored.complete
1583        ));
1584
1585        let reader = Box::pin(BufReader::new(Cursor::new(file)));
1586        let mut second = ArchiveImporter::load(reader, &key).await.unwrap();
1587        insert_importer(&mut second, &alix2.context).await.unwrap();
1588        assert!(events.drain().is_empty());
1589
1590        // One message.
1591        let messages: Vec<StoredGroupMessage> = alix2
1592            .context
1593            .db()
1594            .raw_query(|conn| {
1595                group_messages::table
1596                    .select(StoredGroupMessage::as_select())
1597                    .load(conn)
1598            })
1599            .unwrap();
1600        assert_eq!(messages.len(), 1);
1601    }
1602
1603    // verifies: JOIN-080
1604    #[xmtp_common::test(unwrap_try = true)]
1605    #[cfg(not(target_arch = "wasm32"))]
1606    async fn test_file_backup() {
1607        use crate::{groups::send_message_opts::SendMessageOpts, tester};
1608        use diesel::QueryDsl;
1609        use xmtp_db::group::{ConversationType, GroupQueryArgs};
1610
1611        tester!(alix, sync_worker, triggers);
1612        tester!(bo);
1613
1614        let alix_group = alix.create_group(None, None)?;
1615
1616        // wait for user preference update
1617        wait_for_min_intents(&alix.context.db(), 2).await?;
1618
1619        alix_group.add_members(&[bo.inbox_id()]).await?;
1620        alix_group.update_group_name("My group".to_string()).await?;
1621
1622        bo.sync_welcomes().await?;
1623        let bo_group = bo.group(&alix_group.group_id)?;
1624
1625        // wait for add member intent/commit
1626        wait_for_min_intents(&alix.context.db(), 1).await?;
1627
1628        alix_group
1629            .send_message(b"hello there", SendMessageOpts::default())
1630            .await?;
1631
1632        // wait for send message intent/commit publish
1633        // Wait for Consent state update
1634        wait_for_min_intents(&alix.context.db(), 4).await?;
1635
1636        let mut consent_records: Vec<StoredConsentRecord> = alix
1637            .context
1638            .db()
1639            .raw_query(|conn| consent_records::table.load(conn))?;
1640        assert_eq!(consent_records.len(), 1);
1641        let old_consent_record = consent_records.pop()?;
1642
1643        let mut groups: Vec<StoredGroup> = alix
1644            .context
1645            .db()
1646            .raw_query(|conn| groups::table.load(conn))?;
1647        assert_eq!(groups.len(), 2);
1648        let old_group = groups.pop()?;
1649
1650        // The sync group's messages vary with what the outbox owed; only the
1651        // conversation's messages are archived.
1652        let old_messages: Vec<StoredGroupMessage> = alix.context.db().raw_query(|conn| {
1653            group_messages::table
1654                .select(StoredGroupMessage::as_select())
1655                .filter(group_messages::group_id.eq(&alix_group.group_id))
1656                .load(conn)
1657        })?;
1658        assert!(!old_messages.is_empty());
1659
1660        let opts = ArchiveOptions {
1661            start_ns: None,
1662            end_ns: None,
1663            elements: vec![
1664                BackupElementSelection::Messages,
1665                BackupElementSelection::Consent,
1666            ],
1667            exclude_disappearing_messages: false,
1668        };
1669
1670        let key = xmtp_common::rand_vec::<32>();
1671        let path = Path::new("archive.xmtp");
1672        let _ = tokio::fs::remove_file(path).await;
1673        exporter::ArchiveExporter::export_to_file(opts, alix.db(), path, &key).await?;
1674
1675        tester!(alix2, sync_worker);
1676        alix2.device_sync_client().wait_for_sync_worker_init().await;
1677
1678        // No consent before
1679        let consent_records: Vec<StoredConsentRecord> = alix2
1680            .context
1681            .db()
1682            .raw_query(|conn| consent_records::table.load(conn))?;
1683        assert_eq!(consent_records.len(), 0);
1684
1685        let mut importer = ArchiveImporter::from_file(path, &key).await?;
1686        insert_importer(&mut importer, &alix2.context)
1687            .await
1688            .unwrap();
1689
1690        // Consent is there after the import
1691        let consent_records: Vec<StoredConsentRecord> = alix2
1692            .context
1693            .db()
1694            .raw_query(|conn| consent_records::table.load(conn))?;
1695        assert_eq!(consent_records.len(), 1);
1696        // It's the same consent record.
1697        assert_eq!(
1698            (consent_records[0].state, consent_records[0].consented_at_ns),
1699            (old_consent_record.state, old_consent_record.consented_at_ns)
1700        );
1701
1702        let groups: Vec<StoredGroup> = alix2.context.db().raw_query(|conn| {
1703            groups::table
1704                .filter(groups::conversation_type.ne_all(ConversationType::virtual_types()))
1705                .load(conn)
1706        })?;
1707        assert_eq!(groups.len(), 1);
1708        // It's the same group
1709        assert_eq!(groups[0].id, old_group.id);
1710
1711        let messages: Vec<StoredGroupMessage> = alix2.context.db().raw_query(|conn| {
1712            group_messages::table
1713                .select(StoredGroupMessage::as_select())
1714                .filter(group_messages::group_id.eq(&groups[0].id))
1715                .load(conn)
1716        })?;
1717        // Only the application messages should sync
1718        assert_eq!(messages.len(), 1);
1719        for msg in messages {
1720            let old_msg = old_messages.iter().find(|m| msg.id == m.id)?;
1721            assert_eq!(old_msg.authority_id, msg.authority_id);
1722            assert_eq!(old_msg.decrypted_message_bytes, msg.decrypted_message_bytes);
1723            assert_eq!(old_msg.sent_at_ns, msg.sent_at_ns);
1724            assert_eq!(old_msg.sender_installation_id, msg.sender_installation_id);
1725            assert_eq!(old_msg.sender_inbox_id, msg.sender_inbox_id);
1726            assert_eq!(old_msg.group_id, msg.group_id);
1727        }
1728
1729        let alix2_group = alix2.group(&old_group.id)?;
1730        // Loading all the groups works fine
1731        let _groups = alix2.find_groups(GroupQueryArgs::default())?;
1732        // Can fetch the group name no problem
1733        alix2_group.group_name()?;
1734        assert!(!alix2_group.is_active()?);
1735
1736        // Add the new inbox to the groups
1737        alix.group(&old_group.id)?
1738            .add_members(&[alix2.inbox_id()])
1739            .await?;
1740        alix2.sync_welcomes().await?;
1741
1742        // The group restores to being fully functional
1743        let alix2_group = alix2.group(&old_group.id)?;
1744        assert!(alix2_group.is_active()?);
1745
1746        // The old messages should be stitched in
1747        let msgs = alix2_group.find_messages(&MsgQueryArgs::default())?;
1748        let old_msg_exists = msgs
1749            .iter()
1750            .any(|msg| msg.decrypted_message_bytes == b"hello there");
1751        assert!(old_msg_exists);
1752
1753        // Bo should see the new message from alix2
1754        alix2_group
1755            .send_message(b"this should send", SendMessageOpts::default())
1756            .await?;
1757        bo_group.sync().await?;
1758        let msgs = bo_group.find_messages(&MsgQueryArgs::default())?;
1759        let new_msg_exists = msgs
1760            .iter()
1761            .any(|msg| msg.decrypted_message_bytes == b"this should send");
1762        assert!(new_msg_exists);
1763
1764        // cleanup
1765        let _ = tokio::fs::remove_file(path).await;
1766    }
1767
1768    #[xmtp_common::test(unwrap_try = true)]
1769    #[cfg(not(target_arch = "wasm32"))]
1770    async fn test_legacy_archive_import() {
1771        use std::path::PathBuf;
1772
1773        use crate::tester;
1774
1775        let key = vec![0; 32];
1776        let path = PathBuf::from("tests/assets/archive-legacy.xmtp");
1777        let mut importer = ArchiveImporter::from_file(path, &key).await?;
1778
1779        tester!(alix);
1780
1781        let result = insert_importer(&mut importer, &alix.context).await;
1782        assert!(result.is_ok());
1783    }
1784
1785    /// This archive was generated by the legacy creation build before groups
1786    /// were born with an AppData dictionary. Import must preserve the saved
1787    /// metadata while it creates dictionary-native backup stubs.
1788    // verifies: ARCH-022
1789    #[xmtp_common::test(unwrap_try = true)]
1790    #[cfg(not(target_arch = "wasm32"))]
1791    async fn test_import_current_legacy_archive_metadata() {
1792        use std::path::PathBuf;
1793
1794        use crate::groups::group_permissions::PolicySet;
1795        use xmtp_db::group::ConversationType;
1796
1797        let key = vec![9; 32];
1798        let path = PathBuf::from("tests/assets/archive-current-legacy-metadata.xmtp");
1799        let mut preview = ArchiveImporter::from_file(path.clone(), &key).await?;
1800        let mut archived_dm_id = None;
1801        while let Some(element) = preview.next().await {
1802            if let Some(Element::Group(group)) = element?.element {
1803                archived_dm_id = archived_dm_id.or(group.dm_id);
1804            }
1805        }
1806        let archived_dm_id = archived_dm_id.expect("legacy DM pair");
1807        let mut importer = ArchiveImporter::from_file(path, &key).await?;
1808        tester!(alix, disable_workers);
1809        let pair = crate::groups::parse_canonical_dm_id(Some(&archived_dm_id))?;
1810        assert_ne!(pair.member_one_inbox_id, alix.inbox_id());
1811        assert_ne!(pair.member_two_inbox_id, alix.inbox_id());
1812        insert_importer(&mut importer, &alix.context).await?;
1813
1814        let groups: Vec<StoredGroup> = alix
1815            .context
1816            .db()
1817            .raw_query(|conn| groups::table.load(conn))?;
1818        assert_eq!(groups.len(), 2);
1819        for record in &groups {
1820            let group = alix.group(&record.id)?;
1821            group.with_group_snapshot(|mls| {
1822                assert!(mls.extensions().app_data_dictionary().is_some());
1823                Ok(())
1824            })?;
1825        }
1826
1827        let restored_group_record = groups
1828            .iter()
1829            .find(|group| group.conversation_type == ConversationType::Group)
1830            .expect("legacy group archive entry");
1831        assert_eq!(restored_group_record.message_disappear_from_ns, Some(123));
1832        assert_eq!(restored_group_record.message_disappear_in_ns, Some(456));
1833        let restored_group = alix.group(&restored_group_record.id)?;
1834        assert_eq!(restored_group.group_name()?, "legacy archive group name");
1835        assert_eq!(
1836            restored_group.group_description()?,
1837            "legacy archive group description"
1838        );
1839        assert_eq!(
1840            restored_group.group_image_url_square()?,
1841            "https://example.com/legacy-archive.png"
1842        );
1843        assert_eq!(restored_group.app_data()?, "legacy archive app data");
1844        assert_eq!(restored_group.permissions()?.policies, PolicySet::default());
1845
1846        let restored_dm_record = groups
1847            .iter()
1848            .find(|group| group.conversation_type == ConversationType::Dm)
1849            .expect("legacy DM archive entry");
1850        assert_eq!(
1851            restored_dm_record.dm_id.as_deref(),
1852            Some(archived_dm_id.as_str())
1853        );
1854        assert_eq!(restored_dm_record.message_disappear_from_ns, Some(123));
1855        assert_eq!(restored_dm_record.message_disappear_in_ns, Some(456));
1856        let restored_dm = alix.group(&restored_dm_record.id)?;
1857        assert_eq!(restored_dm.group_name()?, "legacy archive dm name");
1858        assert_eq!(
1859            restored_dm.group_description()?,
1860            "legacy archive dm description"
1861        );
1862        assert_eq!(
1863            restored_dm.group_image_url_square()?,
1864            "https://example.com/legacy-dm-archive.png"
1865        );
1866        assert_eq!(restored_dm.app_data()?, "legacy archive dm app data");
1867    }
1868
1869    /// Dictionary metadata must survive archive export and import.
1870    #[xmtp_common::test(unwrap_try = true)]
1871    async fn test_archive_includes_dictionary_groups() {
1872        tester!(alix, disable_workers);
1873        tester!(bo, disable_workers);
1874
1875        let alix_group = alix
1876            .create_group_with_members(&[bo.inbox_id()], None, None)
1877            .await?;
1878        alix_group.send_message(b"hi", Default::default()).await?;
1879
1880        // Update metadata on the dictionary-native group so the exported
1881        // values come from the AppData dictionary.
1882
1883        alix_group
1884            .update_group_name("post-migration name".to_string())
1885            .await?;
1886        alix_group
1887            .update_group_description("post-migration description".to_string())
1888            .await?;
1889        alix_group
1890            .update_group_image_url_square("https://example.com/post-migration.png".to_string())
1891            .await?;
1892
1893        // A second group must restore alongside the first group.
1894        let second_group = alix
1895            .create_group_with_members(&[bo.inbox_id()], None, None)
1896            .await?;
1897        second_group
1898            .update_group_name("second group name".to_string())
1899            .await?;
1900
1901        let key = vec![7; 32];
1902        let opts = ArchiveOptions {
1903            start_ns: None,
1904            end_ns: None,
1905            elements: vec![
1906                BackupElementSelection::Messages,
1907                BackupElementSelection::Consent,
1908            ],
1909            exclude_disappearing_messages: false,
1910        };
1911        let export = {
1912            let mut file = vec![];
1913            exporter::export(opts, alix.db(), &key, &mut file)?;
1914            file
1915        };
1916
1917        // Fresh installation of the same inbox restores from the
1918        // archive only (workers disabled, no welcome sync).
1919        tester!(alix2, from: alix);
1920        let reader = Box::pin(BufReader::new(Cursor::new(export)));
1921        let mut importer = ArchiveImporter::load(reader, &key).await?;
1922        insert_importer(&mut importer, &alix2.context).await?;
1923
1924        let restored = alix2.db().find_group(&alix_group.group_id)?;
1925        assert!(
1926            restored.is_some(),
1927            "dictionary group missing from restored archive"
1928        );
1929
1930        // Presence is not enough: the metadata written before export
1931        // must round-trip through the archive, or per-field loss in
1932        // the exporter's dict read would go unnoticed.
1933        let restored_group = alix2.group(&alix_group.group_id)?;
1934        assert_eq!(restored_group.group_name()?, "post-migration name");
1935        assert_eq!(
1936            restored_group.group_description()?,
1937            "post-migration description"
1938        );
1939        assert_eq!(
1940            restored_group.group_image_url_square()?,
1941            "https://example.com/post-migration.png"
1942        );
1943
1944        let restored_second = alix2.group(&second_group.group_id)?;
1945        assert_eq!(restored_second.group_name()?, "second group name");
1946    }
1947}
1948
1949#[cfg(test)]
1950mod deletion_tests;
1951
1952#[cfg(test)]
1953mod pre8_tests;
1954
1955#[cfg(test)]
1956mod restored_tests;
1957
1958#[cfg(all(test, not(target_arch = "wasm32")))]
1959mod migration_tests;