Skip to main content

aven_core/sync/client/
engine.rs

1//! End-to-end encrypted sync operations over the isolated encrypted transports.
2//!
3//! A database takes part in sync once it holds a seed genesis or enrollment
4//! pin. Its server is the locator bound into protected enrollment identity;
5//! configuration never redirects it. Other databases stay local until they are
6//! set up or joined.
7use std::collections::HashSet;
8use std::path::Path;
9use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
10
11use anyhow::{Context, Result, bail, ensure};
12use serde::Serialize;
13use zeroize::Zeroizing;
14
15use super::errors::{code, has_code, is_access_refusal};
16use super::exchange::Link;
17use super::host::{ClientHost, key_store};
18use super::keys::peer::InvitationProgress;
19use super::keys::{
20    EnrollmentReadiness, ProtectedLocalKeyStore, ProtectedLocalKeyStoreError,
21    ProtectedLocalKeyStoreErrorKind,
22};
23use super::tail::{ImageTransfer, Round};
24use super::{DeviceInvitation, SetupInvitation, bootstrap, coordination, enrollment, tail};
25use crate::db::Database;
26use crate::sync::seed_claim::{ClaimAuthentication, membership::Invitation};
27
28mod devices;
29pub use devices::{
30    Device, DeviceListing, Removal, finish_removal, load_devices, remove_other_device,
31};
32mod reset;
33pub use reset::{Reset, reset};
34
35/// Upper bound on bounded rounds in one interactive drain. A round may append
36/// a bounded run of ordinary records, but still pulls one page and transfers at
37/// most one image, so this primarily bounds catch-up and image work.
38pub const ROUND_LIMIT: usize = 1000;
39/// Consecutive failed or unavailable image rounds before a drain stops. Each
40/// such round still pulls, but a failed local head cannot advance.
41const IMAGE_RETRY_ROUNDS: usize = 16;
42#[cfg(not(any(test, feature = "test-support")))]
43fn invitation_seconds() -> u64 {
44    600
45}
46// CLI test workers declare short-lived invitations to exercise expiry.
47#[cfg(any(test, feature = "test-support"))]
48fn invitation_seconds() -> u64 {
49    std::env::var("AVEN_TEST_INVITATION_SECONDS")
50        .ok()
51        .and_then(|value| value.parse().ok())
52        .unwrap_or(600)
53}
54#[cfg(not(any(test, feature = "test-support")))]
55const INVITER_POLL_INTERVAL: Duration = Duration::from_secs(1);
56#[cfg(any(test, feature = "test-support"))]
57const INVITER_POLL_INTERVAL: Duration = Duration::from_millis(100);
58// Two half-second waits keep the joiner responsive across the inviter's
59// one-second cadence before backing off to the steady interval.
60#[cfg(not(any(test, feature = "test-support")))]
61const JOIN_POLL_INTERVALS: [Duration; 5] = [
62    Duration::from_millis(250),
63    Duration::from_millis(500),
64    Duration::from_millis(500),
65    Duration::from_secs(1),
66    Duration::from_secs(2),
67];
68#[cfg(any(test, feature = "test-support"))]
69const JOIN_POLL_INTERVALS: [Duration; 4] = [
70    Duration::from_millis(25),
71    Duration::from_millis(50),
72    Duration::from_millis(75),
73    Duration::from_millis(100),
74];
75
76fn join_poll_interval(attempt: usize) -> Duration {
77    jittered(JOIN_POLL_INTERVALS[attempt.min(JOIN_POLL_INTERVALS.len() - 1)])
78}
79
80#[cfg(not(any(test, feature = "test-support")))]
81fn jittered(interval: Duration) -> Duration {
82    let millis = u64::try_from(interval.as_millis()).unwrap_or(u64::MAX);
83    let spread = millis / 10;
84    let mut random = [0_u8; 2];
85    if getrandom::fill(&mut random).is_err() {
86        return interval;
87    }
88    let offset = u64::from(u16::from_le_bytes(random)) % (spread * 2 + 1);
89    Duration::from_millis(millis - spread + offset)
90}
91
92#[cfg(any(test, feature = "test-support"))]
93fn jittered(interval: Duration) -> Duration {
94    interval
95}
96
97pub const NOT_SET_UP: &str = "error sync-not-set-up hint=\"run `aven sync setup` with an invitation from `aven server setup`, or `aven sync join` on a new database\"";
98
99/// True when this database takes part in sync, including an interrupted
100/// setup or join.
101pub async fn is_set_up(database: &Database) -> Result<bool> {
102    Ok(database.enrollment_pin().await?.is_some() || has_seed_setup(database).await?)
103}
104
105/// A seed pin whose claim the server refused is kept only for an exact retry;
106/// it doesn't make this database take part in sync.
107async fn has_seed_setup(database: &Database) -> Result<bool> {
108    Ok(database.local_seed_genesis_commitment().await?.is_some()
109        && !database.local_seed_claim_refused().await?)
110}
111
112/// Where this database stands in sync, from database facts alone. Reads no
113/// protected keys and takes no lock, so it describes rather than authorizes.
114#[derive(Clone, Copy, Debug, PartialEq, Eq)]
115pub enum LocalPhase {
116    NotSetUp,
117    /// Setup started from this database and has not bound the server yet.
118    SetupIncomplete,
119    /// Joining started and the synced data has not been installed yet.
120    JoinIncomplete,
121    SetUp,
122}
123
124pub async fn local_phase(database: &Database) -> Result<LocalPhase> {
125    Ok(match database.enrollment_pin().await? {
126        Some((_, _, role)) if role == "peer" => {
127            if database.meta("e2ee_association").await?.is_some() {
128                LocalPhase::SetUp
129            } else {
130                LocalPhase::JoinIncomplete
131            }
132        }
133        Some(_) => LocalPhase::SetUp,
134        None if has_seed_setup(database).await? => LocalPhase::SetupIncomplete,
135        None => LocalPhase::NotSetUp,
136    })
137}
138
139/// Setup and joining progress that a caller may present: the current stage
140/// and, for stages that measure it, the work completed so far in this attempt.
141/// Amounts count only work this attempt performed, never item contents.
142#[derive(Clone, Copy, Debug, PartialEq, Eq)]
143pub struct Progress {
144    pub stage: Stage,
145    pub amount: Option<Amount>,
146}
147
148impl From<Stage> for Progress {
149    fn from(stage: Stage) -> Self {
150        Self {
151            stage,
152            amount: None,
153        }
154    }
155}
156
157/// Completed work within one stage. Counts never decrease within a stage.
158#[derive(Clone, Copy, Debug, PartialEq, Eq)]
159pub enum Amount {
160    /// Encrypted bytes transferred. `total` is exact once known and does not
161    /// change afterwards.
162    Bytes { done: u64, total: Option<u64> },
163    /// Metadata changes applied; the number still on the server is unknown.
164    Changes { done: u64 },
165    /// Images installed and images still waiting. Newly synced images can
166    /// raise `remaining`, so it is not a fixed total.
167    Images { done: u64, remaining: u64 },
168}
169
170/// Where setup or joining is. Stages report position, not how much remains;
171/// [`Progress::amount`] carries measured work.
172#[derive(Clone, Copy, Debug, PartialEq, Eq)]
173pub enum Stage {
174    PreparingData,
175    UploadingData,
176    FinishingSetup,
177    WaitingForInviter,
178    DownloadingTasks,
179    /// Synced tasks are installed; changes made after the snapshot are applied.
180    CatchingUp,
181    /// Tasks are current; images are still transferring.
182    DownloadingImages,
183}
184
185pub fn unix_now() -> Result<u64> {
186    Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs())
187}
188
189/// What setup would publish from this database.
190#[derive(Clone, Debug, PartialEq, Eq)]
191pub struct SetupPreview {
192    pub workspaces: usize,
193    pub tasks: i64,
194    /// Synced images whose bytes are missing here; they sync as unavailable.
195    pub missing_images: u64,
196    /// The database still names a server from earlier unencrypted sync.
197    pub leaves_unencrypted_server: bool,
198}
199
200pub async fn setup_preview(database: &Database, host: &dyn ClientHost) -> Result<SetupPreview> {
201    let workspaces = database.list_workspaces().await?;
202    let blob_dir = host.blob_dir(database)?;
203    let mut tasks = 0;
204    for workspace in &workspaces {
205        tasks += database.workspace_task_counts(&workspace.id).await?.visible;
206    }
207    Ok(SetupPreview {
208        workspaces: workspaces.len(),
209        tasks,
210        missing_images: database
211            .missing_setup_attachment_counts(&blob_dir)
212            .await?
213            .count,
214        leaves_unencrypted_server: database.meta("sync_server_url").await?.is_some(),
215    })
216}
217
218/// Refuses setup when sync is disabled or this database already takes part.
219pub async fn ensure_setup_available(database: &Database, host: &dyn ClientHost) -> Result<()> {
220    host.ensure_sync_allowed()?;
221    ensure!(
222        database.enrollment_pin().await?.is_none(),
223        "error sync-already-set-up hint=\"run `aven sync` or `aven sync status`\""
224    );
225    Ok(())
226}
227
228fn explain_seed_claim_error(error: anyhow::Error) -> anyhow::Error {
229    let setup_mismatch = error.chain().any(|cause| {
230        cause
231            .downcast_ref::<ProtectedLocalKeyStoreError>()
232            .is_some_and(|error| error.kind() == ProtectedLocalKeyStoreErrorKind::SetupMismatch)
233    });
234    if setup_mismatch {
235        error.context(
236            "error sync-setup-invitation-mismatch hint=\"resume with the invitation that started setup\"",
237        )
238    } else {
239        error
240    }
241}
242
243/// Sets up sync from this database, or resumes the setup it started. The
244/// caller confirms a fresh setup first; a resumed one continues the original
245/// capture and never recaptures.
246pub async fn run_setup(
247    link: Link,
248    database: &Database,
249    host: &dyn ClientHost,
250    invitation: &SetupInvitation,
251    progress: &(dyn Fn(Progress) + Sync),
252) -> Result<Outcome> {
253    let _total = bootstrap::StageTimer::start("setup_total");
254    ensure_setup_available(database, host).await?;
255    let blob_dir = host.blob_dir(database)?;
256    let store = {
257        let _timer = bootstrap::StageTimer::start("key_store");
258        key_store(host, database).await?
259    };
260    let _guard = coordination::acquire(database).await?;
261    store.bind_seed_origin(database, &invitation.server).await?;
262    let bootstrap = bootstrap::Client::new(&invitation.server, link.clone())?;
263    let mut proof = None;
264    // A sealed publication intent means the claim and capture are complete.
265    if database.seed_publication_intent_bytes().await?.is_none() {
266        progress(Stage::PreparingData.into());
267        let timer = bootstrap::StageTimer::start("prepare_claim");
268        let seed = store
269            .prepare_seed_claim(database, invitation.setup_id)
270            .await
271            .map_err(explain_seed_claim_error)?;
272        database.clear_local_seed_claim_refused().await?;
273        drop(timer);
274        let timer = bootstrap::StageTimer::start("claim");
275        let setup = ClaimAuthentication::SetupSecret(&invitation.secret);
276        let claim = match bootstrap.claim(seed.genesis(), setup).await {
277            Ok(()) => Ok(()),
278            Err(_) => {
279                // A response may have been lost after admission. The pinned
280                // seed bearer proves an exact retry without the invitation.
281                let bearer = ClaimAuthentication::SeedBearer(seed.bearer());
282                bootstrap.claim(seed.genesis(), bearer).await
283            }
284        };
285        drop(timer);
286        if let Err(error) = claim {
287            // Refusals are unauthenticated and may hide a committed claim, so
288            // the seed authority stays for an exact retry and nothing is fenced.
289            if setup_refusal(&error) {
290                if database.seed_source_pin().await?.is_none() {
291                    database
292                        .mark_local_seed_claim_refused(error.to_string().as_str())
293                        .await?;
294                    return Err(explain_setup_refusal(error));
295                }
296                return Err(explain_fenced_setup_refusal(error));
297            }
298            // An unknown outcome may already have admitted this exact claim.
299            // Fence and freeze the same local snapshot so retrying is safe.
300            store.prepare_seed_source(database).await?;
301            database
302                .capture_local_shared_state_for_setup(&blob_dir)
303                .await?;
304            store
305                .package_seed_capture_validated(database, &blob_dir, invitation.setup_id, None)
306                .await?;
307            return Err(error.context(
308                "error sync-setup-outcome-unknown hint=\"the server claim couldn't be confirmed; resume continues the same setup\"",
309            ));
310        }
311        {
312            let _timer = bootstrap::StageTimer::start("prepare_source");
313            store.prepare_seed_source(database).await?;
314        }
315        let capture = {
316            let _timer = bootstrap::StageTimer::start("capture");
317            database
318                .capture_local_shared_state_for_setup(&blob_dir)
319                .await?
320        };
321        let _timer = bootstrap::StageTimer::start("package");
322        proof = Some(
323            store
324                .package_seed_capture_validated(
325                    database,
326                    &blob_dir,
327                    invitation.setup_id,
328                    Some(capture),
329                )
330                .await?,
331        );
332    }
333    progress(Stage::UploadingData.into());
334    bootstrap
335        .resume_validated(&store, database, proof, &|done, total| {
336            progress(Progress {
337                stage: Stage::UploadingData,
338                amount: Some(Amount::Bytes {
339                    done,
340                    total: Some(total),
341                }),
342            })
343        })
344        .await
345        .map_err(explain_fenced_setup_refusal)?;
346    progress(Stage::FinishingSetup.into());
347    // Binds this installation's enrollment identity to the server.
348    {
349        let _timer = bootstrap::StageTimer::start("enrollment");
350        enrollment::Client::new(&invitation.server, link.clone())?
351            .refresh(&store, database)
352            .await?;
353    }
354    let _timer = bootstrap::StageTimer::start("drain");
355    let client = tail::Client::new(&invitation.server, link.clone())?;
356    drain(&client, &store, database, host, &blob_dir, ROUND_LIMIT).await
357}
358
359/// A created device invitation whose inviting device waits for admission.
360pub struct PendingInvitation {
361    server: String,
362    text: Zeroizing<String>,
363    handle: [u8; 32],
364    vault: [u8; 32],
365    deadline: Instant,
366    expires_at: u64,
367    resumed: bool,
368}
369
370#[derive(Clone, Copy, Debug, PartialEq, Eq)]
371pub struct InvitationStatus {
372    pub expires_at: u64,
373    pub keys_may_have_been_sent: bool,
374}
375
376#[derive(Clone, Copy, Debug, PartialEq, Eq)]
377pub enum Cancellation {
378    None,
379    Cancelled,
380    KeysMayHaveBeenSent { expires_at: u64 },
381}
382
383impl PendingInvitation {
384    pub fn text(&self) -> &str {
385        &self.text
386    }
387
388    pub fn expires_at(&self) -> u64 {
389        self.expires_at
390    }
391
392    pub fn resumed(&self) -> bool {
393        self.resumed
394    }
395
396    /// The server that admits the invited device.
397    pub fn server(&self) -> &str {
398        &self.server
399    }
400
401    #[cfg(any(test, feature = "test-support"))]
402    pub fn for_test(server: &str, text: &str, expires_at: u64) -> Self {
403        Self {
404            server: server.to_string(),
405            text: Zeroizing::new(text.to_string()),
406            handle: [0; 32],
407            vault: [0; 32],
408            deadline: Instant::now(),
409            expires_at,
410            resumed: false,
411        }
412    }
413}
414
415impl std::fmt::Debug for PendingInvitation {
416    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
417        f.write_str("PendingInvitation([REDACTED])")
418    }
419}
420
421/// Creates a device invitation, or resumes the pending one. Sync keeps
422/// running while it is open.
423pub async fn create_invitation(
424    link: Link,
425    database: &Database,
426    host: &dyn ClientHost,
427) -> Result<PendingInvitation> {
428    host.ensure_sync_allowed()?;
429    ensure!(is_set_up(database).await?, NOT_SET_UP);
430    let store = key_store(host, database).await?;
431    let _guard = coordination::acquire(database).await?;
432    let Some((_, server)) = store.association(database).await? else {
433        bail!("error sync-setup-incomplete hint=\"rerun `aven sync setup`\"");
434    };
435    // Checked before registering, so an origin the invitation text can't
436    // carry never leaves an open invitation behind.
437    ensure!(
438        server.len() <= super::MAX_SERVER_BYTES,
439        "error sync-server-url-too-long hint=\"invitations need a server origin of at most 255 bytes\""
440    );
441    let now = unix_now()?;
442    let created = enrollment::Client::new(&server, link.clone())?
443        .invite_with_status(&store, database, now + invitation_seconds())
444        .await
445        .map_err(|error| match code(&error).as_deref() {
446            Some("withdrawal-required-unsupported") => error.context(
447                "error sync-invitation-unresolved hint=\"keys may already have been sent with the previous invitation; invite again after that device joins, or after the invitation expires and the next `aven sync` changes keys\"",
448            ),
449            _ => explain_change_limit(error),
450        });
451    let created = track_access_result(database, created).await?;
452    ensure!(
453        created.state.expires_at > now,
454        "error sync-invitation-unused hint=\"the open invitation has expired; run `aven sync` before creating another invitation\""
455    );
456    let text = DeviceInvitation {
457        server: server.clone(),
458        invitation: created.invitation,
459    }
460    .encode()?;
461    Ok(PendingInvitation {
462        server,
463        text,
464        handle: created.state.handle,
465        vault: created.vault,
466        deadline: Instant::now() + Duration::from_secs(created.state.expires_at - now),
467        expires_at: created.state.expires_at,
468        resumed: created.resumed,
469    })
470}
471
472/// The sync server, verified member count, and open invitation.
473/// Local observation only; contacts no server.
474#[derive(Debug, Default)]
475pub struct AssociationStatus {
476    pub server: Option<String>,
477    pub devices: Option<usize>,
478    pub invitation: Option<InvitationStatus>,
479}
480
481pub async fn association_status(
482    database: &Database,
483    host: &dyn ClientHost,
484) -> Result<AssociationStatus> {
485    if !is_set_up(database).await? {
486        return Ok(AssociationStatus::default());
487    }
488    let store = key_store(host, database).await?;
489    let _guard = coordination::acquire(database).await?;
490    read_association(database, &store).await
491}
492
493/// Like [`association_status`], but `None` without waiting while another
494/// sync operation holds this database, so periodic observers stay responsive.
495pub async fn try_association_status(
496    database: &Database,
497    host: &dyn ClientHost,
498) -> Result<Option<AssociationStatus>> {
499    if !is_set_up(database).await? {
500        return Ok(Some(AssociationStatus::default()));
501    }
502    let Some(_guard) = coordination::try_acquire(database)? else {
503        return Ok(None);
504    };
505    let store = key_store(host, database).await?;
506    read_association(database, &store).await.map(Some)
507}
508
509async fn read_association(
510    database: &Database,
511    store: &ProtectedLocalKeyStore,
512) -> Result<AssociationStatus> {
513    let Some((_, server)) = store.association(database).await? else {
514        return Ok(AssociationStatus::default());
515    };
516    let inputs = store.active_inputs(database, &server).await?;
517    let invitation = store
518        .open_invitation(database, &inputs)
519        .await?
520        .map(|state| InvitationStatus {
521            expires_at: state.expires_at,
522            keys_may_have_been_sent: state.keys_may_have_been_sent,
523        });
524    Ok(AssociationStatus {
525        server: Some(server),
526        devices: Some(inputs.membership.device_count()),
527        invitation,
528    })
529}
530
531pub async fn cancel_invitation(
532    link: Link,
533    database: &Database,
534    host: &dyn ClientHost,
535) -> Result<Cancellation> {
536    host.ensure_sync_allowed()?;
537    ensure!(is_set_up(database).await?, NOT_SET_UP);
538    let store = key_store(host, database).await?;
539    let _guard = coordination::acquire(database).await?;
540    let Some((_, server)) = store.association(database).await? else {
541        bail!("error sync-setup-incomplete hint=\"rerun `aven sync setup`\"");
542    };
543    let client = enrollment::Client::new(&server, link.clone())?;
544    let mut inputs = store.active_inputs(database, &server).await?;
545    let (journal, state) = store.retire_open_invitation(database, &inputs).await?;
546    let Some(state) = state else {
547        return Ok(Cancellation::None);
548    };
549    let Some(journal) = journal else {
550        return Ok(Cancellation::KeysMayHaveBeenSent {
551            expires_at: state.expires_at,
552        });
553    };
554    // Local retirement is the safety boundary. Server cancellation shortens
555    // the joiner's wait, but failure to deliver it cannot permit admission.
556    let _ = client
557        .cancel(&store, database, &mut inputs, journal.handle)
558        .await;
559    Ok(Cancellation::Cancelled)
560}
561
562/// How a wait for the invited device ended.
563#[derive(Clone, Copy, Debug, PartialEq, Eq)]
564pub enum Admission {
565    Admitted,
566    /// Another command retired or withdrew the invitation.
567    Cancelled,
568}
569
570const INVITATION_UNUSED: &str = "error sync-invitation-unused hint=\"the invitation expired unused; if keys may have been sent with it, the next `aven sync` changes keys before uploading new changes\"";
571
572enum AdmissionPoll {
573    Waiting,
574    RequestPending,
575    Complete(Admission),
576}
577
578/// Checks this invitation's local state, then the server mailbox, and admits
579/// only once a join request is waiting, so an idle wait stays cheap.
580async fn poll_admission(
581    database: &Database,
582    host: &dyn ClientHost,
583    client: &enrollment::Client,
584    invitation: &PendingInvitation,
585) -> Result<AdmissionPoll> {
586    let store = key_store(host, database).await?;
587    let _guard = coordination::acquire(database).await?;
588    match store
589        .invitation_progress(database, &invitation.handle)
590        .await?
591    {
592        InvitationProgress::Admitted => {
593            return Ok(AdmissionPoll::Complete(Admission::Admitted));
594        }
595        InvitationProgress::Closed => {
596            return Ok(AdmissionPoll::Complete(Admission::Cancelled));
597        }
598        InvitationProgress::Open => {}
599    }
600    match client
601        .join_requested(invitation.vault, invitation.handle)
602        .await
603    {
604        Ok(true) => {}
605        Ok(false) => return Ok(AdmissionPoll::Waiting),
606        Err(error) if busy(&error) => return Ok(AdmissionPoll::Waiting),
607        Err(error) => return Err(error),
608    }
609    match client
610        .admit_handle(&store, database, Some(invitation.handle))
611        .await
612    {
613        Err(error) if busy(&error) => Ok(AdmissionPoll::RequestPending),
614        result => Ok(if track_access_result(database, result).await? {
615            AdmissionPoll::Complete(Admission::Admitted)
616        } else {
617            AdmissionPoll::RequestPending
618        }),
619    }
620}
621
622/// Polls admission until the invited device joins, the invitation is
623/// cancelled, or it expires.
624pub async fn await_admission(
625    link: Link,
626    host: &dyn ClientHost,
627    database: &Database,
628    invitation: &PendingInvitation,
629) -> Result<Admission> {
630    let client = enrollment::Client::new(&invitation.server, link.clone())?;
631    let mut pending_attempt = None;
632    while Instant::now() < invitation.deadline {
633        match poll_admission(database, host, &client, invitation).await? {
634            AdmissionPoll::Complete(admission) => return Ok(admission),
635            AdmissionPoll::RequestPending => {
636                pending_attempt.get_or_insert(0);
637            }
638            AdmissionPoll::Waiting => {}
639        }
640        let delay = match pending_attempt.as_mut() {
641            Some(attempt) => {
642                let delay = join_poll_interval(*attempt);
643                *attempt = attempt.saturating_add(1);
644                delay
645            }
646            None => jittered(INVITER_POLL_INTERVAL),
647        };
648        link.wait(delay).await;
649    }
650    bail!(INVITATION_UNUSED)
651}
652
653/// Refuses joining unless this database is fresh or already joining. The
654/// fresh check reads without changing the database.
655pub async fn ensure_join_available(database: &Database, host: &dyn ClientHost) -> Result<()> {
656    host.ensure_sync_allowed()?;
657    match local_phase(database).await? {
658        LocalPhase::JoinIncomplete => Ok(()),
659        LocalPhase::NotSetUp => {
660            database
661                .peer_target_preflight()
662                .await
663                .context(JOIN_REQUIRES_EMPTY)?;
664            Ok(())
665        }
666        LocalPhase::SetupIncomplete | LocalPhase::SetUp => {
667            bail!(ALREADY_SET_UP)
668        }
669    }
670}
671
672/// A timeout proves neither expiry nor non-admission, and expiry alone does
673/// not stop an admission committed before it, so the hint is conditional on
674/// expiry and a replacement keeps the earlier invitation's admission usable.
675const JOIN_TIMEOUT: &str = "error sync-join-timeout hint=\"the other device did not add this device in time; keep `aven sync invite` running there and rerun `aven sync join`. If the invitation expired, run `aven sync invite` again on the same device and pass the new invitation to `aven sync join --new-invitation`; an admission from the earlier invitation still completes the join\"";
676
677const ALREADY_SET_UP: &str =
678    "error sync-already-set-up hint=\"add devices with `aven sync invite` on this database\"";
679const JOIN_REQUIRES_EMPTY: &str = "error sync-join-requires-empty-database hint=\"join with a new database, for example `aven --db PATH sync join`\"";
680
681/// Joins sync on a fresh database, or resumes the join it started, and
682/// returns the server. `invitation` is asked for only while enrollment is
683/// unfinished; `None` resumes the request this database already made. With
684/// `replace`, an invitation this join has not used becomes a new attempt
685/// from the same device keys; earlier attempts stay able to complete.
686pub async fn run_join(
687    link: Link,
688    database: &Database,
689    host: &dyn ClientHost,
690    invitation: impl FnOnce() -> Result<Option<DeviceInvitation>>,
691    replace: bool,
692    progress: &(dyn Fn(Progress) + Sync),
693) -> Result<(String, Outcome)> {
694    host.ensure_sync_allowed()?;
695    let store = key_store(host, database).await?;
696    let blob_dir = host.blob_dir(database)?;
697    let _guard = coordination::acquire(database).await?;
698    let server = match store.association(database).await? {
699        Some((true, server)) => Some(server),
700        Some((false, _)) => bail!(ALREADY_SET_UP),
701        None => {
702            database
703                .peer_target_preflight()
704                .await
705                .context(JOIN_REQUIRES_EMPTY)?;
706            None
707        }
708    };
709    let server = if matches!(
710        store.enrollment_readiness(database).await?,
711        EnrollmentReadiness::Enrolled { .. }
712    ) {
713        server.context("error sync-join-incomplete")?
714    } else {
715        let invitation = invitation()?;
716        ensure!(
717            server
718                .as_ref()
719                .zip(invitation.as_ref())
720                .is_none_or(|(server, invitation)| *server == invitation.server),
721            "error sync-join-server-mismatch hint=\"this database started joining with another server; use an invitation for that server\""
722        );
723        let (server, invitation) = match invitation {
724            Some(invitation) => (invitation.server, Some(invitation.invitation)),
725            None => (server.context("error sync-join-invitation-required")?, None),
726        };
727        let client = enrollment::Client::new(&server, link.clone())?;
728        let deadline = Instant::now() + Duration::from_secs(invitation_seconds());
729        await_join(
730            &client, &store, database, invitation, replace, deadline, progress,
731        )
732        .await?;
733        server
734    };
735    progress(Stage::DownloadingTasks.into());
736    enrollment::Client::new(&server, link.clone())?
737        .install_reporting(&store, database, &|done, total| {
738            progress(Progress {
739                stage: Stage::DownloadingTasks,
740                amount: Some(Amount::Bytes { done, total }),
741            })
742        })
743        .await?;
744    progress(Stage::CatchingUp.into());
745    let client = tail::Client::new(&server, link.clone())?;
746    let outcome = drain_reporting(
747        &client,
748        &store,
749        database,
750        host,
751        &blob_dir,
752        ROUND_LIMIT,
753        Some(progress),
754    )
755    .await?;
756    Ok((server, outcome))
757}
758
759/// Explains why a join request could not use this invitation. Every refusal
760/// leaves the database, its device keys and earlier invitations unchanged.
761fn explain_join_refusal(error: anyhow::Error) -> anyhow::Error {
762    let hint = match code(&error).as_deref() {
763        Some("enrollment-invitation-conflict") => {
764            "error sync-join-invitation-conflict hint=\"this database started joining with another invitation; rerun with that invitation to resume, or pass a new invitation from the same inviting device with `aven sync join --new-invitation`\""
765        }
766        Some("enrollment-retry-context") => {
767            "error sync-join-new-invitation-mismatch hint=\"a new invitation must come from the device that created the first one; run `aven sync invite` there\""
768        }
769        Some("enrollment-retry-unavailable") => {
770            "error sync-join-new-invitation-unavailable hint=\"joining already got past admission, so a new invitation cannot be used; rerun `aven sync join` with an invitation this database already used\""
771        }
772        Some("enrollment-retry-limit") => {
773            "error sync-join-new-invitation-limit hint=\"this database has reached its invitation limit; rerun `aven sync join` with an invitation it already used to finish if the other device accepted it, otherwise keep this database unchanged and join from a new empty database, for example `aven --db PATH sync join`\""
774        }
775        Some("shared-state-install") => {
776            "error sync-join-target-not-empty hint=\"data was added to this database while joining, so it cannot finish joining; keep it unchanged and join from a new empty database, for example `aven --db PATH sync join`\""
777        }
778        _ => return error,
779    };
780    error.context(hint)
781}
782
783/// Requests admission and waits until a retained attempt completes or the
784/// deadline passes. Local preparation failures are final. A busy post is
785/// retried; a refused one ends waiting only when no earlier attempt could
786/// still complete, since a refusal proves nothing about the others.
787pub async fn await_join(
788    client: &enrollment::Client,
789    store: &ProtectedLocalKeyStore,
790    database: &Database,
791    invitation: Option<Invitation>,
792    replace: bool,
793    deadline: Instant,
794    progress: &(dyn Fn(Progress) + Sync),
795) -> Result<()> {
796    let peer = client
797        .prepare(store, database, invitation, replace)
798        .await
799        .map_err(explain_join_refusal)?;
800    let mut posted = client.post(&peer).await;
801    progress(Stage::WaitingForInviter.into());
802    let others = client.has_other_attempts(store, database).await?;
803    let mut poll_attempt = 0_usize;
804    loop {
805        if posted.as_ref().is_err_and(busy) {
806            posted = client.post(&peer).await;
807        }
808        match client.complete(store, database).await {
809            Ok(true) => return Ok(()),
810            Err(error) if !busy(&error) => return Err(error),
811            _ => {}
812        }
813        let refused = posted.as_ref().is_err_and(|error| !busy(error));
814        if (refused && !others) || Instant::now() >= deadline {
815            return Err(match posted {
816                Err(error) if refused && !others => error,
817                Err(error) => error.context(JOIN_TIMEOUT),
818                Ok(()) => anyhow::anyhow!(JOIN_TIMEOUT),
819            });
820        }
821        client.link().wait(join_poll_interval(poll_attempt)).await;
822        poll_attempt = poll_attempt.saturating_add(1);
823    }
824}
825
826fn setup_refusal(error: &anyhow::Error) -> bool {
827    matches!(
828        code(error).as_deref(),
829        Some(
830            "bootstrap-storage-already-claimed"
831                | "bootstrap-setup-invitation-rejected"
832                | "bootstrap-setup-invitation-expired"
833        )
834    )
835}
836
837fn explain_fenced_setup_refusal(error: anyhow::Error) -> anyhow::Error {
838    match code(&error).as_deref() {
839        Some("bootstrap-setup-invitation-rejected" | "bootstrap-setup-invitation-expired") => error.context(
840            "error sync-setup-fenced-invitation-rejected hint=\"this setup is already frozen; resume with the invitation that started setup or the newest invitation for that same server storage; if neither is available, back up this database and restore it to a new path for a local-only copy; local editing and export still work\"",
841        ),
842        Some("bootstrap-storage-already-claimed") => error.context(
843            "error sync-setup-fenced-storage-claimed hint=\"the server reported that this storage belongs to another sync; this setup is frozen and resuming retries it; if it keeps failing, back up this database and restore it to a new path for a local-only copy; local editing and export still work\"",
844        ),
845        _ => error,
846    }
847}
848
849fn explain_setup_refusal(error: anyhow::Error) -> anyhow::Error {
850    match code(&error).as_deref() {
851        Some("bootstrap-storage-already-claimed") => error.context(
852            "error sync-setup-storage-already-claimed hint=\"this server already belongs to another sync; nothing here was changed; to use that sync, join it from an empty database\"",
853        ),
854        Some("bootstrap-setup-invitation-rejected") => error.context(
855            "error sync-setup-invitation-rejected hint=\"this setup invitation expired, was replaced, or is for different storage; nothing here was changed; run `aven server setup` for this unclaimed storage and try its current invitation\"",
856        ),
857        Some("bootstrap-setup-invitation-expired") => error.context(
858            "error sync-setup-invitation-expired hint=\"this setup invitation expired; nothing here was changed; run `aven server setup` on the server again for a new invitation\"",
859        ),
860        _ => error,
861    }
862}
863
864/// The enrollment server serves one exchange at a time; pollers retry.
865fn busy(error: &anyhow::Error) -> bool {
866    has_code(error, "enrollment-busy")
867}
868
869async fn associated_server(store: &ProtectedLocalKeyStore, database: &Database) -> Result<String> {
870    match store.association(database).await? {
871        Some((peer, server)) => {
872            ensure!(
873                !peer
874                    || matches!(
875                        store.enrollment_readiness(database).await?,
876                        EnrollmentReadiness::Enrolled { .. }
877                    ),
878                "error sync-join-incomplete hint=\"rerun `aven sync join`\""
879            );
880            Ok(server)
881        }
882        None => bail!("error sync-setup-incomplete hint=\"rerun `aven sync setup`\""),
883    }
884}
885
886/// The vault's lifetime budget of signed membership changes is spent.
887const CHANGE_LIMIT: &str = "error sync-device-change-limit hint=\"this sync has reached its limit on device changes, so devices can no longer be added or removed; start a new sync to keep changing devices, see https://aventasks.dev/sync/#recover-from-device-loss\"";
888
889fn explain_change_limit(error: anyhow::Error) -> anyhow::Error {
890    if has_code(&error, "membership-change-limit") {
891        error.context(CHANGE_LIMIT)
892    } else {
893        error
894    }
895}
896
897/// A refusal alone proves neither a server failure nor removal of this device.
898const REFUSED: &str = "error sync-server-refused hint=\"the server refused this request; it may have failed, or another device may have removed this device from sync, which leaves local tasks and images available here; retry later, and check `aven sync device list` on another device\"";
899
900/// Explains engine refusals that ordinary rounds report while a join is
901/// unfinished or new changes wait for a key change.
902fn explain_round_error(error: anyhow::Error) -> anyhow::Error {
903    match code(&error).as_deref() {
904        Some("snapshot-not-installed" | "enrollment-unresolved") => {
905            error.context("error sync-join-incomplete hint=\"rerun `aven sync join`\"")
906        }
907        Some("enrollment-unauthorized") => error.context(REFUSED),
908        Some("withdrawal-rotation-required") => error.context(KEY_CHANGE_REQUIRED),
909        _ => error,
910    }
911}
912
913/// Changes from the server were downloaded; local changes stay queued.
914const KEY_CHANGE_REQUIRED: &str = "error sync-key-change-required hint=\"an invitation expired after keys may have been sent to a device that never joined; this device downloads changes but uploads new ones only after sync changes keys; check the connection and run `aven sync` again\"";
915
916async fn remember_access_refusal(database: &Database, error: &anyhow::Error) {
917    if is_access_refusal(error)
918        && let Err(state_error) = database.record_sync_access_refusal().await
919    {
920        tracing::warn!(
921            error = %state_error,
922            "could not persist sync access refusal"
923        );
924    }
925}
926
927/// Any authenticated server success proves current access.
928async fn track_access_result<T>(database: &Database, result: Result<T>) -> Result<T> {
929    match result {
930        Ok(value) => {
931            database.clear_sync_access_refusal().await?;
932            Ok(value)
933        }
934        Err(error) => {
935            remember_access_refusal(database, &error).await;
936            Err(error)
937        }
938    }
939}
940
941/// Drains up to `round_limit` rounds with the server bound during setup or
942/// join. The caller holds the sync coordination lock.
943async fn drain_associated(
944    link: Link,
945    database: &Database,
946    host: &dyn ClientHost,
947    round_limit: usize,
948) -> Result<Outcome> {
949    let store = key_store(host, database).await?;
950    let blob_dir = host.blob_dir(database)?;
951    let server = associated_server(&store, database).await?;
952    let client = tail::Client::new(&server, link.clone())?;
953    let result = drain(&client, &store, database, host, &blob_dir, round_limit)
954        .await
955        .map_err(explain_round_error);
956    match result {
957        Ok(outcome) => {
958            database.clear_sync_access_refusal().await?;
959            Ok(outcome)
960        }
961        Err(error) => {
962            remember_access_refusal(database, &error).await;
963            Err(error)
964        }
965    }
966}
967
968/// Runs one interactive drain, waiting briefly for another sync to finish.
969pub async fn run_to_completion(
970    link: Link,
971    database: &Database,
972    host: &dyn ClientHost,
973) -> Result<Outcome> {
974    host.ensure_sync_allowed()?;
975    ensure!(is_set_up(database).await?, NOT_SET_UP);
976    let _guard = coordination::acquire(database).await?;
977    drain_associated(link, database, host, ROUND_LIMIT).await
978}
979
980pub enum DaemonRound {
981    Completed(Outcome),
982    /// Another process holds the sync coordination lock.
983    Deferred,
984    /// The database has not been set up or joined; it stays local.
985    NotSetUp,
986}
987
988/// One bounded daemon round that never waits for another sync.
989pub async fn daemon_round(
990    link: Link,
991    database: &Database,
992    host: &dyn ClientHost,
993    round_limit: usize,
994) -> Result<DaemonRound> {
995    host.ensure_sync_allowed()?;
996    if !is_set_up(database).await? {
997        return Ok(DaemonRound::NotSetUp);
998    }
999    let Some(_guard) = coordination::try_acquire(database)? else {
1000        return Ok(DaemonRound::Deferred);
1001    };
1002    drain_associated(link, database, host, round_limit)
1003        .await
1004        .map(DaemonRound::Completed)
1005}
1006
1007#[derive(Debug, Serialize)]
1008pub struct Outcome {
1009    pub version: u32,
1010    pub rounds: usize,
1011    pub metadata_caught_up: bool,
1012    pub images: ImageTransfer,
1013    pub sent_changes: usize,
1014    pub received_changes: usize,
1015    pub conflicts: usize,
1016    pub new_conflicts: usize,
1017}
1018
1019impl Outcome {
1020    /// True when another round would likely make progress now: images are
1021    /// still transferring, or metadata remains while images are not blocked.
1022    pub fn more_work_ready(&self) -> bool {
1023        self.images == ImageTransfer::Pending
1024            || (!self.metadata_caught_up && self.images == ImageTransfer::Complete)
1025    }
1026}
1027
1028/// Repeats bounded rounds until metadata is current and image work settles,
1029/// or until `round_limit` or the image retry bound stops it.
1030pub async fn drain(
1031    client: &tail::Client,
1032    store: &ProtectedLocalKeyStore,
1033    database: &Database,
1034    host: &dyn ClientHost,
1035    blob_dir: &Path,
1036    round_limit: usize,
1037) -> Result<Outcome> {
1038    drain_reporting(client, store, database, host, blob_dir, round_limit, None).await
1039}
1040
1041async fn drain_reporting(
1042    client: &tail::Client,
1043    store: &ProtectedLocalKeyStore,
1044    database: &Database,
1045    host: &dyn ClientHost,
1046    blob_dir: &Path,
1047    round_limit: usize,
1048    progress: Option<&(dyn Fn(Progress) + Sync)>,
1049) -> Result<Outcome> {
1050    let conflicts_before = conflict_identities(database).await?;
1051    super::device_label::publish_if_missing(database, store, host).await?;
1052    let mut last = None;
1053    let mut rounds = 0;
1054    let mut image_retries = 0;
1055    let mut sent_changes = 0;
1056    let mut received_changes = 0;
1057    let mut images_progress = None;
1058    let lifecycle_policy = host.attachment_lifecycle_policy();
1059    let mut drain = Box::pin(client.start_drain(store, database)).await?;
1060    while rounds < round_limit {
1061        let round: Round = Box::pin(client.round_in_drain(
1062            store,
1063            database,
1064            blob_dir,
1065            lifecycle_policy,
1066            &mut drain,
1067        ))
1068        .await?;
1069        rounds += 1;
1070        sent_changes += round.sent_changes;
1071        received_changes += round.received_changes;
1072        if let Some(progress) = progress {
1073            images_progress = report_round(
1074                database,
1075                &round,
1076                received_changes,
1077                images_progress,
1078                progress,
1079            )
1080            .await?;
1081        }
1082        last = Some(round);
1083        match round.images {
1084            ImageTransfer::Complete if round.metadata_caught_up => break,
1085            ImageTransfer::Complete | ImageTransfer::Pending => image_retries = 0,
1086            ImageTransfer::Failed | ImageTransfer::Unavailable => {
1087                image_retries += 1;
1088                if image_retries >= IMAGE_RETRY_ROUNDS {
1089                    break;
1090                }
1091            }
1092        }
1093    }
1094    let last = last.context("error sync-no-rounds")?;
1095    if last.publishing_blocked {
1096        return Err(drain.publishing_blocked_error());
1097    }
1098    let conflicts_after = conflict_identities(database).await?;
1099    Ok(Outcome {
1100        version: 1,
1101        rounds,
1102        metadata_caught_up: last.metadata_caught_up,
1103        images: last.images,
1104        sent_changes,
1105        received_changes,
1106        conflicts: conflicts_after.len(),
1107        new_conflicts: conflicts_after.difference(&conflicts_before).count(),
1108    })
1109}
1110
1111/// Reports catch-up or image progress after a round. Images count as done
1112/// when the number waiting drops, so a newly synced image never lowers the
1113/// count, and a failed transfer leaves it unchanged. Counting waiting images
1114/// costs one local query, made only while images are outstanding.
1115async fn report_round(
1116    database: &Database,
1117    round: &Round,
1118    received_changes: usize,
1119    images: Option<(u64, u64)>,
1120    progress: &(dyn Fn(Progress) + Sync),
1121) -> Result<Option<(u64, u64)>> {
1122    if !round.metadata_caught_up {
1123        progress(Progress {
1124            stage: Stage::CatchingUp,
1125            amount: Some(Amount::Changes {
1126                done: received_changes as u64,
1127            }),
1128        });
1129        return Ok(images);
1130    }
1131    if round.images == ImageTransfer::Complete {
1132        return Ok(images);
1133    }
1134    let remaining = database.encrypted_image_downloads_remaining().await?;
1135    let done = images_done(images, remaining);
1136    progress(Progress {
1137        stage: Stage::DownloadingImages,
1138        amount: Some(Amount::Images { done, remaining }),
1139    });
1140    Ok(Some((done, remaining)))
1141}
1142
1143/// Images done after a round, given the previous `(done, remaining)` report.
1144fn images_done(previous: Option<(u64, u64)>, remaining: u64) -> u64 {
1145    previous.map_or(0, |(done, before)| done + before.saturating_sub(remaining))
1146}
1147
1148async fn conflict_identities(database: &Database) -> Result<HashSet<String>> {
1149    let mut identities = HashSet::new();
1150    for workspace in database.list_workspaces().await? {
1151        for conflict in database.list_conflicts(&workspace, None, None).await? {
1152            let (first, second) = if conflict.variant_a <= conflict.variant_b {
1153                (conflict.variant_a, conflict.variant_b)
1154            } else {
1155                (conflict.variant_b, conflict.variant_a)
1156            };
1157            identities.insert(format!(
1158                "{}:{}:{}:{}:{}:{}",
1159                workspace.id,
1160                conflict.recurrence_series,
1161                conflict.task_id,
1162                conflict.field,
1163                first,
1164                second
1165            ));
1166        }
1167    }
1168    Ok(identities)
1169}
1170
1171#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1172#[serde(rename_all = "kebab-case")]
1173pub enum SyncState {
1174    NotSetUp,
1175    SetupIncomplete,
1176    JoinIncomplete,
1177    KeyChangePending,
1178    Ready,
1179    AccessRefused,
1180}
1181
1182#[derive(Serialize)]
1183pub struct StatusReport {
1184    pub version: u32,
1185    /// Member count from local verified membership, or unknown before enrollment.
1186    pub devices: Option<usize>,
1187    pub server: Option<String>,
1188    pub state: SyncState,
1189    pub local_changes_pending: Option<bool>,
1190    pub server_position: Option<i64>,
1191    pub initial_download_pending: Option<bool>,
1192    pub image_uploads_pending: Option<bool>,
1193    pub image_downloads_pending: Option<bool>,
1194    pub images_unavailable: Option<bool>,
1195    pub conflicts: i64,
1196    pub invitation: &'static str,
1197    pub invitation_expires_at: Option<u64>,
1198    pub access_refused_at: Option<String>,
1199}
1200
1201/// Local observation only; contacts no server. Waits briefly for a running
1202/// sync or invitation exchange, which holds the installation exclusively.
1203pub async fn status_report(database: &Database, host: &dyn ClientHost) -> Result<StatusReport> {
1204    let mut report = StatusReport {
1205        version: 1,
1206        devices: None,
1207        server: None,
1208        state: SyncState::NotSetUp,
1209        local_changes_pending: None,
1210        server_position: None,
1211        initial_download_pending: None,
1212        image_uploads_pending: None,
1213        image_downloads_pending: None,
1214        images_unavailable: None,
1215        conflicts: database.unresolved_conflict_count().await?,
1216        invitation: "none",
1217        invitation_expires_at: None,
1218        access_refused_at: database
1219            .sync_access_refusal()
1220            .await?
1221            .map(|refusal| refusal.at),
1222    };
1223    if !is_set_up(database).await? {
1224        return Ok(report);
1225    }
1226    report.state = SyncState::SetupIncomplete;
1227    let store = key_store(host, database).await?;
1228    let _guard = coordination::acquire(database).await?;
1229    if let Some((peer, server)) = store.association(database).await? {
1230        report.server = Some(server.clone());
1231        let enrollment_ready = !peer
1232            || matches!(
1233                store.enrollment_readiness(database).await?,
1234                EnrollmentReadiness::Enrolled { .. }
1235            );
1236        if enrollment_ready {
1237            let invitation_inputs = store.active_inputs(database, &server).await?;
1238            report.devices = Some(invitation_inputs.membership.device_count());
1239            if let Some(invitation) = store.open_invitation(database, &invitation_inputs).await? {
1240                report.invitation = "open";
1241                report.invitation_expires_at = Some(invitation.expires_at);
1242            }
1243        }
1244        report.state = if !enrollment_ready {
1245            SyncState::JoinIncomplete
1246        } else {
1247            match store.tail_inputs(database, &server).await {
1248                Ok(inputs) => {
1249                    let state = database.encrypted_round_state(&inputs.authority).await?;
1250                    report.local_changes_pending = Some(!state.idle);
1251                    report.server_position = Some(state.cursor);
1252                    report.initial_download_pending = Some(state.downloads.is_none());
1253                    report.image_uploads_pending = Some(state.upload_pending);
1254                    report.image_downloads_pending = state.downloads.map(|d| d.pending);
1255                    report.images_unavailable = state.downloads.map(|d| d.unavailable);
1256                    if inputs.publishing_blocked()? {
1257                        SyncState::KeyChangePending
1258                    } else {
1259                        SyncState::Ready
1260                    }
1261                }
1262                Err(error) if peer && has_code(&error, "snapshot-not-installed") => {
1263                    SyncState::JoinIncomplete
1264                }
1265                Err(error) => return Err(error),
1266            }
1267        };
1268    }
1269    if report.access_refused_at.is_some()
1270        && !matches!(
1271            report.state,
1272            SyncState::SetupIncomplete | SyncState::JoinIncomplete
1273        )
1274    {
1275        report.state = SyncState::AccessRefused;
1276    }
1277    Ok(report)
1278}
1279
1280#[cfg(test)]
1281mod tests {
1282    use super::*;
1283    use crate::sync::client::errors::has_code;
1284
1285    #[test]
1286    fn enrollment_polling_backs_off_to_a_bounded_steady_interval() {
1287        assert_eq!(join_poll_interval(0), Duration::from_millis(25));
1288        assert_eq!(join_poll_interval(1), Duration::from_millis(50));
1289        assert_eq!(join_poll_interval(2), Duration::from_millis(75));
1290        assert_eq!(join_poll_interval(3), Duration::from_millis(100));
1291        assert_eq!(join_poll_interval(usize::MAX), Duration::from_millis(100));
1292        assert_eq!(jittered(INVITER_POLL_INTERVAL), Duration::from_millis(100));
1293    }
1294
1295    #[test]
1296    fn images_done_counts_only_drops_in_remaining_images() {
1297        assert_eq!(images_done(None, 10), 0);
1298        assert_eq!(images_done(Some((0, 10)), 9), 1);
1299        // A failed transfer leaves the count unchanged.
1300        assert_eq!(images_done(Some((1, 9)), 9), 1);
1301        // Newly synced images raise what remains, never lower what is done.
1302        assert_eq!(images_done(Some((1, 9)), 12), 1);
1303        assert_eq!(images_done(Some((1, 12)), 11), 2);
1304    }
1305
1306    #[test]
1307    fn seed_claim_only_labels_a_real_setup_mismatch_as_invitation_mismatch() {
1308        let mismatch = anyhow::Error::new(ProtectedLocalKeyStoreError::new(
1309            ProtectedLocalKeyStoreErrorKind::SetupMismatch,
1310        ));
1311        let mismatch = explain_seed_claim_error(mismatch);
1312        assert!(
1313            has_code(&mismatch, "sync-setup-invitation-mismatch"),
1314            "{mismatch:#}"
1315        );
1316        for kind in [
1317            ProtectedLocalKeyStoreErrorKind::Unavailable,
1318            ProtectedLocalKeyStoreErrorKind::Corrupt,
1319        ] {
1320            let error = explain_seed_claim_error(anyhow::Error::new(
1321                ProtectedLocalKeyStoreError::new(kind),
1322            ));
1323            assert!(
1324                !has_code(&error, "sync-setup-invitation-mismatch"),
1325                "{error:#}"
1326            );
1327        }
1328    }
1329
1330    #[test]
1331    fn join_timeout_hint_covers_expiry_without_suggesting_discarding_data() {
1332        let hint = JOIN_TIMEOUT;
1333        assert!(hint.starts_with("error sync-join-timeout hint="));
1334        assert!(hint.contains("rerun `aven sync join`"));
1335        assert!(hint.contains("`aven sync join --new-invitation`"));
1336        for word in ["delete", "reset", "disposable", "is empty"] {
1337            assert!(!hint.contains(word), "{hint}");
1338        }
1339    }
1340
1341    #[test]
1342    fn device_change_limit_hint_points_to_starting_a_new_sync() {
1343        let error = explain_change_limit(anyhow::anyhow!("error membership-change-limit"));
1344        let hint = error.to_string();
1345        assert!(
1346            hint.starts_with("error sync-device-change-limit hint="),
1347            "{hint}"
1348        );
1349        assert!(hint.contains("#recover-from-device-loss"), "{hint}");
1350        let other = explain_change_limit(anyhow::anyhow!("error membership-invalid"));
1351        assert_eq!(other.to_string(), "error membership-invalid");
1352    }
1353}