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, is_vault_deleted};
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 Cloud, your hosting provider or `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    Ok(setup(link, database, host, Some(invitation), progress)
254        .await?
255        .1)
256}
257
258/// The code of a [`resume_setup`] refusal that a setup invitation can
259/// continue: without the setup secret the server's refusal leaves open
260/// whether this claim can still be made.
261pub const SETUP_INVITATION_REQUIRED: &str = "sync-setup-resume-invitation-required";
262
263/// Resumes the setup this database started from its retained server origin,
264/// setup ID and seed authority, without the setup invitation, and returns
265/// the server. Only a claim the server already holds accepts the seed bearer;
266/// otherwise this fails with [`SETUP_INVITATION_REQUIRED`] and changes
267/// nothing.
268pub async fn resume_setup(
269    link: Link,
270    database: &Database,
271    host: &dyn ClientHost,
272    progress: &(dyn Fn(Progress) + Sync),
273) -> Result<(String, Outcome)> {
274    setup(link, database, host, None, progress).await
275}
276
277async fn setup(
278    link: Link,
279    database: &Database,
280    host: &dyn ClientHost,
281    invitation: Option<&SetupInvitation>,
282    progress: &(dyn Fn(Progress) + Sync),
283) -> Result<(String, Outcome)> {
284    let _total = bootstrap::StageTimer::start("setup_total");
285    ensure_setup_available(database, host).await?;
286    let blob_dir = host.blob_dir(database)?;
287    let store = {
288        let _timer = bootstrap::StageTimer::start("key_store");
289        key_store(host, database).await?
290    };
291    let _guard = coordination::acquire(database).await?;
292    let (server, setup_id, secret) = match invitation {
293        Some(invitation) => {
294            store.bind_seed_origin(database, &invitation.server).await?;
295            (
296                invitation.server.clone(),
297                invitation.setup_id,
298                Some(&invitation.secret),
299            )
300        }
301        None => {
302            ensure!(
303                local_phase(database).await? == LocalPhase::SetupIncomplete,
304                "error sync-setup-not-started hint=\"rerun `aven sync setup`\""
305            );
306            let (server, setup_id) = store.retained_seed_setup(database).await?;
307            (server, setup_id, None)
308        }
309    };
310    let bootstrap = bootstrap::Client::new(&server, link.clone())?;
311    let mut proof = None;
312    // A sealed publication intent means the claim and capture are complete.
313    if database.seed_publication_intent_bytes().await?.is_none() {
314        progress(Stage::PreparingData.into());
315        let timer = bootstrap::StageTimer::start("prepare_claim");
316        let seed = store
317            .prepare_seed_claim(database, setup_id)
318            .await
319            .map_err(explain_seed_claim_error)?;
320        database.clear_local_seed_claim_refused().await?;
321        drop(timer);
322        let timer = bootstrap::StageTimer::start("claim");
323        let bearer = ClaimAuthentication::SeedBearer(seed.bearer());
324        let claim = match secret {
325            Some(secret) => {
326                let setup = ClaimAuthentication::SetupSecret(secret);
327                match bootstrap.claim(seed.genesis(), setup).await {
328                    Ok(()) => Ok(()),
329                    // A response may have been lost after admission. The pinned
330                    // seed bearer proves an exact retry without the invitation.
331                    Err(_) => bootstrap.claim(seed.genesis(), bearer).await,
332                }
333            }
334            None => bootstrap.claim(seed.genesis(), bearer).await,
335        };
336        drop(timer);
337        if let Err(error) = claim {
338            // Refusals are unauthenticated and may hide a committed claim, so
339            // the seed authority stays for an exact retry and nothing is fenced.
340            if setup_refusal(&error) {
341                let fenced = database.seed_source_pin().await?.is_some();
342                // Without the setup secret, a refusal can't show that the
343                // claim is lost; the setup invitation may still make it.
344                if secret.is_none()
345                    && (!fenced || has_code(&error, "bootstrap-setup-invitation-rejected"))
346                {
347                    return Err(error.context(format!("error {SETUP_INVITATION_REQUIRED}")));
348                }
349                if !fenced {
350                    database
351                        .mark_local_seed_claim_refused(error.to_string().as_str())
352                        .await?;
353                    return Err(explain_setup_refusal(error));
354                }
355                return Err(explain_fenced_setup_refusal(error));
356            }
357            // An unknown outcome may already have admitted this exact claim.
358            // Fence and freeze the same local snapshot so retrying is safe.
359            store.prepare_seed_source(database).await?;
360            database
361                .capture_local_shared_state_for_setup(&blob_dir)
362                .await?;
363            store
364                .package_seed_capture_validated(database, &blob_dir, setup_id, None)
365                .await?;
366            return Err(error.context(
367                "error sync-setup-outcome-unknown hint=\"the server claim couldn't be confirmed; resume continues the same setup\"",
368            ));
369        }
370        {
371            let _timer = bootstrap::StageTimer::start("prepare_source");
372            store.prepare_seed_source(database).await?;
373        }
374        let capture = {
375            let _timer = bootstrap::StageTimer::start("capture");
376            database
377                .capture_local_shared_state_for_setup(&blob_dir)
378                .await?
379        };
380        let _timer = bootstrap::StageTimer::start("package");
381        proof = Some(
382            store
383                .package_seed_capture_validated(database, &blob_dir, setup_id, Some(capture))
384                .await?,
385        );
386    }
387    progress(Stage::UploadingData.into());
388    bootstrap
389        .resume_validated(&store, database, proof, &|done, total| {
390            progress(Progress {
391                stage: Stage::UploadingData,
392                amount: Some(Amount::Bytes {
393                    done,
394                    total: Some(total),
395                }),
396            })
397        })
398        .await
399        .map_err(explain_fenced_setup_refusal)?;
400    progress(Stage::FinishingSetup.into());
401    // Binds this installation's enrollment identity to the server.
402    {
403        let _timer = bootstrap::StageTimer::start("enrollment");
404        enrollment::Client::new(&server, link.clone())?
405            .refresh(&store, database)
406            .await?;
407    }
408    let _timer = bootstrap::StageTimer::start("drain");
409    let client = tail::Client::new(&server, link.clone())?;
410    let outcome = drain(&client, &store, database, host, &blob_dir, ROUND_LIMIT).await;
411    if let Err(error) = &outcome {
412        remember_vault_deletion(database, error).await;
413    }
414    Ok((server, outcome?))
415}
416
417/// A created device invitation whose inviting device waits for admission.
418pub struct PendingInvitation {
419    server: String,
420    text: Zeroizing<String>,
421    handle: [u8; 32],
422    vault: [u8; 32],
423    deadline: Instant,
424    expires_at: u64,
425    resumed: bool,
426}
427
428#[derive(Clone, Copy, Debug, PartialEq, Eq)]
429pub struct InvitationStatus {
430    pub expires_at: u64,
431    pub keys_may_have_been_sent: bool,
432}
433
434#[derive(Clone, Copy, Debug, PartialEq, Eq)]
435pub enum Cancellation {
436    None,
437    Cancelled,
438    KeysMayHaveBeenSent { expires_at: u64 },
439}
440
441impl PendingInvitation {
442    pub fn text(&self) -> &str {
443        &self.text
444    }
445
446    pub fn expires_at(&self) -> u64 {
447        self.expires_at
448    }
449
450    pub fn resumed(&self) -> bool {
451        self.resumed
452    }
453
454    /// The server that admits the invited device.
455    pub fn server(&self) -> &str {
456        &self.server
457    }
458
459    #[cfg(any(test, feature = "test-support"))]
460    pub fn for_test(server: &str, text: &str, expires_at: u64) -> Self {
461        Self {
462            server: server.to_string(),
463            text: Zeroizing::new(text.to_string()),
464            handle: [0; 32],
465            vault: [0; 32],
466            deadline: Instant::now(),
467            expires_at,
468            resumed: false,
469        }
470    }
471}
472
473impl std::fmt::Debug for PendingInvitation {
474    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
475        f.write_str("PendingInvitation([REDACTED])")
476    }
477}
478
479/// Creates a device invitation, or resumes the pending one. Sync keeps
480/// running while it is open.
481pub async fn create_invitation(
482    link: Link,
483    database: &Database,
484    host: &dyn ClientHost,
485) -> Result<PendingInvitation> {
486    host.ensure_sync_allowed()?;
487    ensure!(is_set_up(database).await?, NOT_SET_UP);
488    let store = key_store(host, database).await?;
489    let _guard = coordination::acquire(database).await?;
490    let Some((_, server)) = store.association(database).await? else {
491        bail!("error sync-setup-incomplete hint=\"rerun `aven sync setup`\"");
492    };
493    // Checked before registering, so an origin the invitation text can't
494    // carry never leaves an open invitation behind.
495    ensure!(
496        server.len() <= super::MAX_SERVER_BYTES,
497        "error sync-server-url-too-long hint=\"invitations need a server origin of at most 255 bytes\""
498    );
499    let now = unix_now()?;
500    let created = enrollment::Client::new(&server, link.clone())?
501        .invite_with_status(&store, database, now + invitation_seconds())
502        .await
503        .map_err(|error| match code(&error).as_deref() {
504            Some("withdrawal-required-unsupported") => error.context(
505                "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\"",
506            ),
507            _ => explain_change_limit(error),
508        });
509    let created = track_access_result(database, created).await?;
510    ensure!(
511        created.state.expires_at > now,
512        "error sync-invitation-unused hint=\"the open invitation has expired; run `aven sync` before creating another invitation\""
513    );
514    let text = DeviceInvitation {
515        server: server.clone(),
516        invitation: created.invitation,
517    }
518    .encode()?;
519    Ok(PendingInvitation {
520        server,
521        text,
522        handle: created.state.handle,
523        vault: created.vault,
524        deadline: Instant::now() + Duration::from_secs(created.state.expires_at - now),
525        expires_at: created.state.expires_at,
526        resumed: created.resumed,
527    })
528}
529
530/// The sync server, verified member count, and open invitation.
531/// Local observation only; contacts no server.
532#[derive(Debug, Default)]
533pub struct AssociationStatus {
534    pub server: Option<String>,
535    pub devices: Option<usize>,
536    pub invitation: Option<InvitationStatus>,
537}
538
539pub async fn association_status(
540    database: &Database,
541    host: &dyn ClientHost,
542) -> Result<AssociationStatus> {
543    if !is_set_up(database).await? {
544        return Ok(AssociationStatus::default());
545    }
546    let store = key_store(host, database).await?;
547    let _guard = coordination::acquire(database).await?;
548    read_association(database, &store).await
549}
550
551/// Like [`association_status`], but `None` without waiting while another
552/// sync operation holds this database, so periodic observers stay responsive.
553pub async fn try_association_status(
554    database: &Database,
555    host: &dyn ClientHost,
556) -> Result<Option<AssociationStatus>> {
557    if !is_set_up(database).await? {
558        return Ok(Some(AssociationStatus::default()));
559    }
560    let Some(_guard) = coordination::try_acquire(database)? else {
561        return Ok(None);
562    };
563    let store = key_store(host, database).await?;
564    read_association(database, &store).await.map(Some)
565}
566
567async fn read_association(
568    database: &Database,
569    store: &ProtectedLocalKeyStore,
570) -> Result<AssociationStatus> {
571    let Some((_, server)) = store.association(database).await? else {
572        return Ok(AssociationStatus::default());
573    };
574    let inputs = store.active_inputs(database, &server).await?;
575    let invitation = store
576        .open_invitation(database, &inputs)
577        .await?
578        .map(|state| InvitationStatus {
579            expires_at: state.expires_at,
580            keys_may_have_been_sent: state.keys_may_have_been_sent,
581        });
582    Ok(AssociationStatus {
583        server: Some(server),
584        devices: Some(inputs.membership.device_count()),
585        invitation,
586    })
587}
588
589pub async fn cancel_invitation(
590    link: Link,
591    database: &Database,
592    host: &dyn ClientHost,
593) -> Result<Cancellation> {
594    host.ensure_sync_allowed()?;
595    ensure!(is_set_up(database).await?, NOT_SET_UP);
596    let store = key_store(host, database).await?;
597    let _guard = coordination::acquire(database).await?;
598    let Some((_, server)) = store.association(database).await? else {
599        bail!("error sync-setup-incomplete hint=\"rerun `aven sync setup`\"");
600    };
601    let client = enrollment::Client::new(&server, link.clone())?;
602    let mut inputs = store.active_inputs(database, &server).await?;
603    let (journal, state) = store.retire_open_invitation(database, &inputs).await?;
604    let Some(state) = state else {
605        return Ok(Cancellation::None);
606    };
607    let Some(journal) = journal else {
608        return Ok(Cancellation::KeysMayHaveBeenSent {
609            expires_at: state.expires_at,
610        });
611    };
612    // Local retirement is the safety boundary. Server cancellation shortens
613    // the joiner's wait, but failure to deliver it cannot permit admission.
614    let _ = client
615        .cancel(&store, database, &mut inputs, journal.handle)
616        .await;
617    Ok(Cancellation::Cancelled)
618}
619
620/// How a wait for the invited device ended.
621#[derive(Clone, Copy, Debug, PartialEq, Eq)]
622pub enum Admission {
623    Admitted,
624    /// Another command retired or withdrew the invitation.
625    Cancelled,
626}
627
628const 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\"";
629
630enum AdmissionPoll {
631    Waiting,
632    RequestPending,
633    Complete(Admission),
634}
635
636/// Checks this invitation's local state, then the server mailbox, and admits
637/// only once a join request is waiting, so an idle wait stays cheap.
638async fn poll_admission(
639    database: &Database,
640    host: &dyn ClientHost,
641    client: &enrollment::Client,
642    invitation: &PendingInvitation,
643) -> Result<AdmissionPoll> {
644    let store = key_store(host, database).await?;
645    let _guard = coordination::acquire(database).await?;
646    match store
647        .invitation_progress(database, &invitation.handle)
648        .await?
649    {
650        InvitationProgress::Admitted => {
651            return Ok(AdmissionPoll::Complete(Admission::Admitted));
652        }
653        InvitationProgress::Closed => {
654            return Ok(AdmissionPoll::Complete(Admission::Cancelled));
655        }
656        InvitationProgress::Open => {}
657    }
658    match client
659        .join_requested(invitation.vault, invitation.handle)
660        .await
661    {
662        Ok(true) => {}
663        Ok(false) => return Ok(AdmissionPoll::Waiting),
664        Err(error) if busy(&error) => return Ok(AdmissionPoll::Waiting),
665        Err(error) => return Err(error),
666    }
667    match client
668        .admit_handle(&store, database, Some(invitation.handle))
669        .await
670    {
671        Err(error) if busy(&error) => Ok(AdmissionPoll::RequestPending),
672        result => Ok(if track_access_result(database, result).await? {
673            AdmissionPoll::Complete(Admission::Admitted)
674        } else {
675            AdmissionPoll::RequestPending
676        }),
677    }
678}
679
680/// Polls admission until the invited device joins, the invitation is
681/// cancelled, or it expires.
682pub async fn await_admission(
683    link: Link,
684    host: &dyn ClientHost,
685    database: &Database,
686    invitation: &PendingInvitation,
687) -> Result<Admission> {
688    let client = enrollment::Client::new(&invitation.server, link.clone())?;
689    let mut pending_attempt = None;
690    while Instant::now() < invitation.deadline {
691        match poll_admission(database, host, &client, invitation).await? {
692            AdmissionPoll::Complete(admission) => return Ok(admission),
693            AdmissionPoll::RequestPending => {
694                pending_attempt.get_or_insert(0);
695            }
696            AdmissionPoll::Waiting => {}
697        }
698        let delay = match pending_attempt.as_mut() {
699            Some(attempt) => {
700                let delay = join_poll_interval(*attempt);
701                *attempt = attempt.saturating_add(1);
702                delay
703            }
704            None => jittered(INVITER_POLL_INTERVAL),
705        };
706        link.wait(delay).await;
707    }
708    bail!(INVITATION_UNUSED)
709}
710
711/// Refuses joining unless this database is fresh or already joining. The
712/// fresh check reads without changing the database.
713pub async fn ensure_join_available(database: &Database, host: &dyn ClientHost) -> Result<()> {
714    host.ensure_sync_allowed()?;
715    match local_phase(database).await? {
716        LocalPhase::JoinIncomplete => Ok(()),
717        LocalPhase::NotSetUp => {
718            database
719                .peer_target_preflight()
720                .await
721                .context(JOIN_REQUIRES_EMPTY)?;
722            Ok(())
723        }
724        LocalPhase::SetupIncomplete | LocalPhase::SetUp => {
725            bail!(ALREADY_SET_UP)
726        }
727    }
728}
729
730/// A timeout proves neither expiry nor non-admission, and expiry alone does
731/// not stop an admission committed before it, so the hint is conditional on
732/// expiry and a replacement keeps the earlier invitation's admission usable.
733const 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\"";
734
735const ALREADY_SET_UP: &str =
736    "error sync-already-set-up hint=\"add devices with `aven sync invite` on this database\"";
737const JOIN_REQUIRES_EMPTY: &str = "error sync-join-requires-empty-database hint=\"join with a new database, for example `aven --db PATH sync join`\"";
738
739/// Joins sync on a fresh database, or resumes the join it started, and
740/// returns the server. `invitation` is asked for only while enrollment is
741/// unfinished; `None` resumes the request this database already made. With
742/// `replace`, an invitation this join has not used becomes a new attempt
743/// from the same device keys; earlier attempts stay able to complete.
744pub async fn run_join(
745    link: Link,
746    database: &Database,
747    host: &dyn ClientHost,
748    invitation: impl FnOnce() -> Result<Option<DeviceInvitation>>,
749    replace: bool,
750    progress: &(dyn Fn(Progress) + Sync),
751) -> Result<(String, Outcome)> {
752    host.ensure_sync_allowed()?;
753    let store = key_store(host, database).await?;
754    let blob_dir = host.blob_dir(database)?;
755    let _guard = coordination::acquire(database).await?;
756    let server = match store.association(database).await? {
757        Some((true, server)) => Some(server),
758        Some((false, _)) => bail!(ALREADY_SET_UP),
759        None => {
760            database
761                .peer_target_preflight()
762                .await
763                .context(JOIN_REQUIRES_EMPTY)?;
764            None
765        }
766    };
767    let server = if matches!(
768        store.enrollment_readiness(database).await?,
769        EnrollmentReadiness::Enrolled { .. }
770    ) {
771        server.context("error sync-join-incomplete")?
772    } else {
773        let invitation = invitation()?;
774        ensure!(
775            server
776                .as_ref()
777                .zip(invitation.as_ref())
778                .is_none_or(|(server, invitation)| *server == invitation.server),
779            "error sync-join-server-mismatch hint=\"this database started joining with another server; use an invitation for that server\""
780        );
781        let (server, invitation) = match invitation {
782            Some(invitation) => (invitation.server, Some(invitation.invitation)),
783            None => (server.context("error sync-join-invitation-required")?, None),
784        };
785        let client = enrollment::Client::new(&server, link.clone())?;
786        let deadline = Instant::now() + Duration::from_secs(invitation_seconds());
787        await_join(
788            &client, &store, database, invitation, replace, deadline, progress,
789        )
790        .await?;
791        server
792    };
793    progress(Stage::DownloadingTasks.into());
794    enrollment::Client::new(&server, link.clone())?
795        .install_reporting(&store, database, &|done, total| {
796            progress(Progress {
797                stage: Stage::DownloadingTasks,
798                amount: Some(Amount::Bytes { done, total }),
799            })
800        })
801        .await?;
802    progress(Stage::CatchingUp.into());
803    let client = tail::Client::new(&server, link.clone())?;
804    let outcome = drain_reporting(
805        &client,
806        &store,
807        database,
808        host,
809        &blob_dir,
810        ROUND_LIMIT,
811        Some(progress),
812    )
813    .await;
814    if let Err(error) = &outcome {
815        remember_vault_deletion(database, error).await;
816    }
817    Ok((server, outcome?))
818}
819
820/// Explains why a join request could not use this invitation. Every refusal
821/// leaves the database, its device keys and earlier invitations unchanged.
822fn explain_join_refusal(error: anyhow::Error) -> anyhow::Error {
823    let hint = match code(&error).as_deref() {
824        Some("enrollment-invitation-conflict") => {
825            "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`\""
826        }
827        Some("enrollment-retry-context") => {
828            "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\""
829        }
830        Some("enrollment-retry-unavailable") => {
831            "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\""
832        }
833        Some("enrollment-retry-limit") => {
834            "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`\""
835        }
836        Some("shared-state-install") => {
837            "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`\""
838        }
839        _ => return error,
840    };
841    error.context(hint)
842}
843
844/// Requests admission and waits until a retained attempt completes or the
845/// deadline passes. Local preparation failures are final. A busy post is
846/// retried; a refused one ends waiting only when no earlier attempt could
847/// still complete, since a refusal proves nothing about the others.
848pub async fn await_join(
849    client: &enrollment::Client,
850    store: &ProtectedLocalKeyStore,
851    database: &Database,
852    invitation: Option<Invitation>,
853    replace: bool,
854    deadline: Instant,
855    progress: &(dyn Fn(Progress) + Sync),
856) -> Result<()> {
857    let peer = client
858        .prepare(store, database, invitation, replace)
859        .await
860        .map_err(explain_join_refusal)?;
861    let mut posted = client.post(&peer).await;
862    progress(Stage::WaitingForInviter.into());
863    let others = client.has_other_attempts(store, database).await?;
864    let mut poll_attempt = 0_usize;
865    loop {
866        if posted.as_ref().is_err_and(busy) {
867            posted = client.post(&peer).await;
868        }
869        match client.complete(store, database).await {
870            Ok(true) => return Ok(()),
871            Err(error) if !busy(&error) => return Err(error),
872            _ => {}
873        }
874        let refused = posted.as_ref().is_err_and(|error| !busy(error));
875        if (refused && !others) || Instant::now() >= deadline {
876            return Err(match posted {
877                Err(error) if refused && !others => error,
878                Err(error) => error.context(JOIN_TIMEOUT),
879                Ok(()) => anyhow::anyhow!(JOIN_TIMEOUT),
880            });
881        }
882        client.link().wait(join_poll_interval(poll_attempt)).await;
883        poll_attempt = poll_attempt.saturating_add(1);
884    }
885}
886
887fn setup_refusal(error: &anyhow::Error) -> bool {
888    matches!(
889        code(error).as_deref(),
890        Some(
891            "bootstrap-storage-already-claimed"
892                | "bootstrap-setup-invitation-rejected"
893                | "bootstrap-setup-invitation-expired"
894        )
895    )
896}
897
898fn explain_fenced_setup_refusal(error: anyhow::Error) -> anyhow::Error {
899    match code(&error).as_deref() {
900        Some("bootstrap-setup-invitation-rejected" | "bootstrap-setup-invitation-expired") => error.context(
901            "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\"",
902        ),
903        Some("bootstrap-storage-already-claimed") => error.context(
904            "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\"",
905        ),
906        _ => error,
907    }
908}
909
910fn explain_setup_refusal(error: anyhow::Error) -> anyhow::Error {
911    match code(&error).as_deref() {
912        Some("bootstrap-storage-already-claimed") => error.context(
913            "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\"",
914        ),
915        Some("bootstrap-setup-invitation-rejected") => error.context(
916            "error sync-setup-invitation-rejected hint=\"this setup invitation expired, was replaced, or is for different storage; nothing here was changed; get the current invitation from Aven Cloud or your hosting provider, or run `aven server setup` if you host the server yourself\"",
917        ),
918        Some("bootstrap-setup-invitation-expired") => error.context(
919            "error sync-setup-invitation-expired hint=\"this setup invitation expired; nothing here was changed; get a new invitation from Aven Cloud or your hosting provider, or run `aven server setup` again if you host the server yourself\"",
920        ),
921        _ => error,
922    }
923}
924
925/// The enrollment server serves one exchange at a time; pollers retry.
926fn busy(error: &anyhow::Error) -> bool {
927    has_code(error, "enrollment-busy")
928}
929
930async fn associated_server(store: &ProtectedLocalKeyStore, database: &Database) -> Result<String> {
931    match store.association(database).await? {
932        Some((peer, server)) => {
933            ensure!(
934                !peer
935                    || matches!(
936                        store.enrollment_readiness(database).await?,
937                        EnrollmentReadiness::Enrolled { .. }
938                    ),
939                "error sync-join-incomplete hint=\"rerun `aven sync join`\""
940            );
941            Ok(server)
942        }
943        None => bail!("error sync-setup-incomplete hint=\"rerun `aven sync setup`\""),
944    }
945}
946
947/// The vault's lifetime budget of signed membership changes is spent.
948const 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/setup/#recover-from-device-loss\"";
949
950fn explain_change_limit(error: anyhow::Error) -> anyhow::Error {
951    if has_code(&error, "membership-change-limit") {
952        error.context(CHANGE_LIMIT)
953    } else {
954        error
955    }
956}
957
958/// A refusal alone proves neither a server failure nor removal of this device.
959const 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\"";
960
961/// Explains engine refusals that ordinary rounds report while a join is
962/// unfinished or new changes wait for a key change.
963fn explain_round_error(error: anyhow::Error) -> anyhow::Error {
964    match code(&error).as_deref() {
965        Some("snapshot-not-installed" | "enrollment-unresolved") => {
966            error.context("error sync-join-incomplete hint=\"rerun `aven sync join`\"")
967        }
968        Some("enrollment-unauthorized") => error.context(REFUSED),
969        Some("withdrawal-rotation-required") => error.context(KEY_CHANGE_REQUIRED),
970        _ => error,
971    }
972}
973
974/// Changes from the server were downloaded; local changes stay queued.
975const 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\"";
976
977/// Remembers a vault deletion, which stops daemon rounds until the user acts.
978async fn remember_vault_deletion(database: &Database, error: &anyhow::Error) {
979    if is_vault_deleted(error)
980        && let Err(state_error) = database.record_sync_vault_deletion().await
981    {
982        tracing::warn!(
983            error = %state_error,
984            "could not persist sync vault deletion"
985        );
986    }
987}
988
989/// A vault deletion is not an access refusal, so it records only itself.
990async fn remember_refusal(database: &Database, error: &anyhow::Error) {
991    if is_vault_deleted(error) {
992        remember_vault_deletion(database, error).await;
993    } else if is_access_refusal(error)
994        && let Err(state_error) = database.record_sync_access_refusal().await
995    {
996        tracing::warn!(
997            error = %state_error,
998            "could not persist sync access refusal"
999        );
1000    }
1001}
1002
1003/// Any authenticated server success proves current access.
1004async fn track_access_result<T>(database: &Database, result: Result<T>) -> Result<T> {
1005    match result {
1006        Ok(value) => {
1007            database.clear_sync_access_refusal().await?;
1008            Ok(value)
1009        }
1010        Err(error) => {
1011            remember_refusal(database, &error).await;
1012            Err(error)
1013        }
1014    }
1015}
1016
1017/// Drains up to `round_limit` rounds with the server bound during setup or
1018/// join. The caller holds the sync coordination lock.
1019async fn drain_associated(
1020    link: Link,
1021    database: &Database,
1022    host: &dyn ClientHost,
1023    round_limit: usize,
1024) -> Result<Outcome> {
1025    let store = key_store(host, database).await?;
1026    let blob_dir = host.blob_dir(database)?;
1027    let server = associated_server(&store, database).await?;
1028    let client = tail::Client::new(&server, link.clone())?;
1029    let result = drain(&client, &store, database, host, &blob_dir, round_limit)
1030        .await
1031        .map_err(explain_round_error);
1032    track_access_result(database, result).await
1033}
1034
1035/// Runs one interactive drain, waiting briefly for another sync to finish.
1036/// This is the user-started recheck of a recorded vault deletion: it ignores
1037/// the record and clears it on success. Automatic callers must not sync while
1038/// [`StatusReport::vault_deleted_at`] is set.
1039pub async fn run_to_completion(
1040    link: Link,
1041    database: &Database,
1042    host: &dyn ClientHost,
1043) -> Result<Outcome> {
1044    host.ensure_sync_allowed()?;
1045    ensure!(is_set_up(database).await?, NOT_SET_UP);
1046    let _guard = coordination::acquire(database).await?;
1047    let outcome = drain_associated(link, database, host, ROUND_LIMIT).await?;
1048    database.clear_sync_vault_deletion().await?;
1049    Ok(outcome)
1050}
1051
1052pub enum DaemonRound {
1053    Completed(Outcome),
1054    /// Another process holds the sync coordination lock.
1055    Deferred,
1056    /// The database has not been set up or joined; it stays local.
1057    NotSetUp,
1058    /// The server reported the vault deleted. Later rounds make no request
1059    /// until sync is reset or a user-started sync succeeds.
1060    VaultDeleted,
1061}
1062
1063/// One bounded daemon round that never waits for another sync.
1064pub async fn daemon_round(
1065    link: Link,
1066    database: &Database,
1067    host: &dyn ClientHost,
1068    round_limit: usize,
1069) -> Result<DaemonRound> {
1070    host.ensure_sync_allowed()?;
1071    if !is_set_up(database).await? {
1072        return Ok(DaemonRound::NotSetUp);
1073    }
1074    let Some(_guard) = coordination::try_acquire(database)? else {
1075        return Ok(DaemonRound::Deferred);
1076    };
1077    if database.sync_vault_deletion().await?.is_some() {
1078        return Ok(DaemonRound::VaultDeleted);
1079    }
1080    match drain_associated(link, database, host, round_limit).await {
1081        Err(error) if is_vault_deleted(&error) => Ok(DaemonRound::VaultDeleted),
1082        result => result.map(DaemonRound::Completed),
1083    }
1084}
1085
1086#[derive(Debug, Serialize)]
1087pub struct Outcome {
1088    pub version: u32,
1089    pub rounds: usize,
1090    pub metadata_caught_up: bool,
1091    pub images: ImageTransfer,
1092    pub sent_changes: usize,
1093    pub received_changes: usize,
1094    pub conflicts: usize,
1095    pub new_conflicts: usize,
1096}
1097
1098impl Outcome {
1099    /// True when another round would likely make progress now: images are
1100    /// still transferring, or metadata remains while images are not blocked.
1101    pub fn more_work_ready(&self) -> bool {
1102        self.images == ImageTransfer::Pending
1103            || (!self.metadata_caught_up && self.images == ImageTransfer::Complete)
1104    }
1105}
1106
1107/// Repeats bounded rounds until metadata is current and image work settles,
1108/// or until `round_limit` or the image retry bound stops it.
1109pub async fn drain(
1110    client: &tail::Client,
1111    store: &ProtectedLocalKeyStore,
1112    database: &Database,
1113    host: &dyn ClientHost,
1114    blob_dir: &Path,
1115    round_limit: usize,
1116) -> Result<Outcome> {
1117    drain_reporting(client, store, database, host, blob_dir, round_limit, None).await
1118}
1119
1120async fn drain_reporting(
1121    client: &tail::Client,
1122    store: &ProtectedLocalKeyStore,
1123    database: &Database,
1124    host: &dyn ClientHost,
1125    blob_dir: &Path,
1126    round_limit: usize,
1127    progress: Option<&(dyn Fn(Progress) + Sync)>,
1128) -> Result<Outcome> {
1129    let conflicts_before = conflict_identities(database).await?;
1130    super::device_label::publish_if_missing(database, store, host).await?;
1131    let mut last = None;
1132    let mut rounds = 0;
1133    let mut image_retries = 0;
1134    let mut sent_changes = 0;
1135    let mut received_changes = 0;
1136    let mut images_progress = None;
1137    let lifecycle_policy = host.attachment_lifecycle_policy();
1138    let mut drain = Box::pin(client.start_drain(store, database)).await?;
1139    while rounds < round_limit {
1140        let round: Round = Box::pin(client.round_in_drain(
1141            store,
1142            database,
1143            blob_dir,
1144            lifecycle_policy,
1145            &mut drain,
1146        ))
1147        .await?;
1148        rounds += 1;
1149        sent_changes += round.sent_changes;
1150        received_changes += round.received_changes;
1151        if let Some(progress) = progress {
1152            images_progress = report_round(
1153                database,
1154                &round,
1155                received_changes,
1156                images_progress,
1157                progress,
1158            )
1159            .await?;
1160        }
1161        last = Some(round);
1162        match round.images {
1163            ImageTransfer::Complete if round.metadata_caught_up => break,
1164            ImageTransfer::Complete | ImageTransfer::Pending => image_retries = 0,
1165            ImageTransfer::Failed | ImageTransfer::Unavailable => {
1166                image_retries += 1;
1167                if image_retries >= IMAGE_RETRY_ROUNDS {
1168                    break;
1169                }
1170            }
1171        }
1172    }
1173    let last = last.context("error sync-no-rounds")?;
1174    if last.publishing_blocked {
1175        return Err(drain.publishing_blocked_error());
1176    }
1177    let conflicts_after = conflict_identities(database).await?;
1178    Ok(Outcome {
1179        version: 1,
1180        rounds,
1181        metadata_caught_up: last.metadata_caught_up,
1182        images: last.images,
1183        sent_changes,
1184        received_changes,
1185        conflicts: conflicts_after.len(),
1186        new_conflicts: conflicts_after.difference(&conflicts_before).count(),
1187    })
1188}
1189
1190/// Reports catch-up or image progress after a round. Images count as done
1191/// when the number waiting drops, so a newly synced image never lowers the
1192/// count, and a failed transfer leaves it unchanged. Counting waiting images
1193/// costs one local query, made only while images are outstanding.
1194async fn report_round(
1195    database: &Database,
1196    round: &Round,
1197    received_changes: usize,
1198    images: Option<(u64, u64)>,
1199    progress: &(dyn Fn(Progress) + Sync),
1200) -> Result<Option<(u64, u64)>> {
1201    if !round.metadata_caught_up {
1202        progress(Progress {
1203            stage: Stage::CatchingUp,
1204            amount: Some(Amount::Changes {
1205                done: received_changes as u64,
1206            }),
1207        });
1208        return Ok(images);
1209    }
1210    if round.images == ImageTransfer::Complete {
1211        return Ok(images);
1212    }
1213    let remaining = database.encrypted_image_downloads_remaining().await?;
1214    let done = images_done(images, remaining);
1215    progress(Progress {
1216        stage: Stage::DownloadingImages,
1217        amount: Some(Amount::Images { done, remaining }),
1218    });
1219    Ok(Some((done, remaining)))
1220}
1221
1222/// Images done after a round, given the previous `(done, remaining)` report.
1223fn images_done(previous: Option<(u64, u64)>, remaining: u64) -> u64 {
1224    previous.map_or(0, |(done, before)| done + before.saturating_sub(remaining))
1225}
1226
1227async fn conflict_identities(database: &Database) -> Result<HashSet<String>> {
1228    let mut identities = HashSet::new();
1229    for workspace in database.list_workspaces().await? {
1230        for conflict in database.list_conflicts(&workspace, None, None).await? {
1231            let (first, second) = if conflict.variant_a <= conflict.variant_b {
1232                (conflict.variant_a, conflict.variant_b)
1233            } else {
1234                (conflict.variant_b, conflict.variant_a)
1235            };
1236            identities.insert(format!(
1237                "{}:{}:{}:{}:{}:{}",
1238                workspace.id,
1239                conflict.recurrence_series,
1240                conflict.task_id,
1241                conflict.field,
1242                first,
1243                second
1244            ));
1245        }
1246    }
1247    Ok(identities)
1248}
1249
1250#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1251#[serde(rename_all = "kebab-case")]
1252pub enum SyncState {
1253    NotSetUp,
1254    SetupIncomplete,
1255    JoinIncomplete,
1256    KeyChangePending,
1257    Ready,
1258    AccessRefused,
1259    VaultDeleted,
1260}
1261
1262#[derive(Serialize)]
1263pub struct StatusReport {
1264    pub version: u32,
1265    /// Member count from local verified membership, or unknown before enrollment.
1266    pub devices: Option<usize>,
1267    pub server: Option<String>,
1268    pub state: SyncState,
1269    pub local_changes_pending: Option<bool>,
1270    pub server_position: Option<i64>,
1271    pub initial_download_pending: Option<bool>,
1272    pub image_uploads_pending: Option<bool>,
1273    pub image_downloads_pending: Option<bool>,
1274    pub images_unavailable: Option<bool>,
1275    pub conflicts: i64,
1276    pub invitation: &'static str,
1277    pub invitation_expires_at: Option<u64>,
1278    pub access_refused_at: Option<String>,
1279    pub vault_deleted_at: Option<String>,
1280}
1281
1282/// Local observation only; contacts no server. Waits briefly for a running
1283/// sync or invitation exchange, which holds the installation exclusively.
1284pub async fn status_report(database: &Database, host: &dyn ClientHost) -> Result<StatusReport> {
1285    let mut report = StatusReport {
1286        version: 1,
1287        devices: None,
1288        server: None,
1289        state: SyncState::NotSetUp,
1290        local_changes_pending: None,
1291        server_position: None,
1292        initial_download_pending: None,
1293        image_uploads_pending: None,
1294        image_downloads_pending: None,
1295        images_unavailable: None,
1296        conflicts: database.unresolved_conflict_count().await?,
1297        invitation: "none",
1298        invitation_expires_at: None,
1299        access_refused_at: database
1300            .sync_access_refusal()
1301            .await?
1302            .map(|refusal| refusal.at),
1303        vault_deleted_at: database
1304            .sync_vault_deletion()
1305            .await?
1306            .map(|deletion| deletion.at),
1307    };
1308    if !is_set_up(database).await? {
1309        return Ok(report);
1310    }
1311    report.state = SyncState::SetupIncomplete;
1312    let store = key_store(host, database).await?;
1313    let _guard = coordination::acquire(database).await?;
1314    if let Some((peer, server)) = store.association(database).await? {
1315        report.server = Some(server.clone());
1316        let enrollment_ready = !peer
1317            || matches!(
1318                store.enrollment_readiness(database).await?,
1319                EnrollmentReadiness::Enrolled { .. }
1320            );
1321        if enrollment_ready {
1322            let invitation_inputs = store.active_inputs(database, &server).await?;
1323            report.devices = Some(invitation_inputs.membership.device_count());
1324            if let Some(invitation) = store.open_invitation(database, &invitation_inputs).await? {
1325                report.invitation = "open";
1326                report.invitation_expires_at = Some(invitation.expires_at);
1327            }
1328        }
1329        report.state = if !enrollment_ready {
1330            SyncState::JoinIncomplete
1331        } else {
1332            match store.tail_inputs(database, &server).await {
1333                Ok(inputs) => {
1334                    let state = database.encrypted_round_state(&inputs.authority).await?;
1335                    report.local_changes_pending = Some(!state.idle);
1336                    report.server_position = Some(state.cursor);
1337                    report.initial_download_pending = Some(state.downloads.is_none());
1338                    report.image_uploads_pending = Some(state.upload_pending);
1339                    report.image_downloads_pending = state.downloads.map(|d| d.pending);
1340                    report.images_unavailable = state.downloads.map(|d| d.unavailable);
1341                    if inputs.publishing_blocked()? {
1342                        SyncState::KeyChangePending
1343                    } else {
1344                        SyncState::Ready
1345                    }
1346                }
1347                Err(error) if peer && has_code(&error, "snapshot-not-installed") => {
1348                    SyncState::JoinIncomplete
1349                }
1350                Err(error) => return Err(error),
1351            }
1352        };
1353    }
1354    if !matches!(
1355        report.state,
1356        SyncState::SetupIncomplete | SyncState::JoinIncomplete
1357    ) {
1358        if report.vault_deleted_at.is_some() {
1359            report.state = SyncState::VaultDeleted;
1360        } else if report.access_refused_at.is_some() {
1361            report.state = SyncState::AccessRefused;
1362        }
1363    }
1364    Ok(report)
1365}
1366
1367#[cfg(test)]
1368mod tests {
1369    use super::*;
1370    use crate::sync::client::errors::has_code;
1371
1372    #[test]
1373    fn enrollment_polling_backs_off_to_a_bounded_steady_interval() {
1374        assert_eq!(join_poll_interval(0), Duration::from_millis(25));
1375        assert_eq!(join_poll_interval(1), Duration::from_millis(50));
1376        assert_eq!(join_poll_interval(2), Duration::from_millis(75));
1377        assert_eq!(join_poll_interval(3), Duration::from_millis(100));
1378        assert_eq!(join_poll_interval(usize::MAX), Duration::from_millis(100));
1379        assert_eq!(jittered(INVITER_POLL_INTERVAL), Duration::from_millis(100));
1380    }
1381
1382    #[test]
1383    fn images_done_counts_only_drops_in_remaining_images() {
1384        assert_eq!(images_done(None, 10), 0);
1385        assert_eq!(images_done(Some((0, 10)), 9), 1);
1386        // A failed transfer leaves the count unchanged.
1387        assert_eq!(images_done(Some((1, 9)), 9), 1);
1388        // Newly synced images raise what remains, never lower what is done.
1389        assert_eq!(images_done(Some((1, 9)), 12), 1);
1390        assert_eq!(images_done(Some((1, 12)), 11), 2);
1391    }
1392
1393    #[test]
1394    fn unfrozen_setup_refusals_offer_hosted_and_self_hosted_recovery() {
1395        for code in [
1396            "bootstrap-setup-invitation-rejected",
1397            "bootstrap-setup-invitation-expired",
1398        ] {
1399            let error = explain_setup_refusal(anyhow::anyhow!("error {code}"));
1400            assert!(error.to_string().contains("hosting provider"));
1401            assert!(error.to_string().contains("`aven server setup`"));
1402            assert!(error.to_string().contains("nothing here was changed"));
1403            let frozen = explain_fenced_setup_refusal(anyhow::anyhow!("error {code}"));
1404            assert!(frozen.to_string().contains("already frozen"));
1405            assert!(frozen.to_string().contains("same server storage"));
1406        }
1407    }
1408
1409    #[test]
1410    fn seed_claim_only_labels_a_real_setup_mismatch_as_invitation_mismatch() {
1411        let mismatch = anyhow::Error::new(ProtectedLocalKeyStoreError::new(
1412            ProtectedLocalKeyStoreErrorKind::SetupMismatch,
1413        ));
1414        let mismatch = explain_seed_claim_error(mismatch);
1415        assert!(
1416            has_code(&mismatch, "sync-setup-invitation-mismatch"),
1417            "{mismatch:#}"
1418        );
1419        for kind in [
1420            ProtectedLocalKeyStoreErrorKind::Unavailable,
1421            ProtectedLocalKeyStoreErrorKind::Corrupt,
1422        ] {
1423            let error = explain_seed_claim_error(anyhow::Error::new(
1424                ProtectedLocalKeyStoreError::new(kind),
1425            ));
1426            assert!(
1427                !has_code(&error, "sync-setup-invitation-mismatch"),
1428                "{error:#}"
1429            );
1430        }
1431    }
1432
1433    #[test]
1434    fn join_timeout_hint_covers_expiry_without_suggesting_discarding_data() {
1435        let hint = JOIN_TIMEOUT;
1436        assert!(hint.starts_with("error sync-join-timeout hint="));
1437        assert!(hint.contains("rerun `aven sync join`"));
1438        assert!(hint.contains("`aven sync join --new-invitation`"));
1439        for word in ["delete", "reset", "disposable", "is empty"] {
1440            assert!(!hint.contains(word), "{hint}");
1441        }
1442    }
1443
1444    #[test]
1445    fn device_change_limit_hint_points_to_starting_a_new_sync() {
1446        let error = explain_change_limit(anyhow::anyhow!("error membership-change-limit"));
1447        let hint = error.to_string();
1448        assert!(
1449            hint.starts_with("error sync-device-change-limit hint="),
1450            "{hint}"
1451        );
1452        assert!(hint.contains("#recover-from-device-loss"), "{hint}");
1453        let other = explain_change_limit(anyhow::anyhow!("error membership-invalid"));
1454        assert_eq!(other.to_string(), "error membership-invalid");
1455    }
1456}
1457
1458#[cfg(test)]
1459mod hosting_tests {
1460    use super::*;
1461    use crate::sync::client::keys::test_support::isolated_store;
1462    use crate::sync::client::{HttpHeader, HttpResponse, Session, Step};
1463    use crate::sync::seed_claim::SeedAuthority;
1464    use aven_protocol::refusal::{
1465        BOOTSTRAP_HOSTING, ENROLLMENT_HOSTING, IMAGE_HOSTING, TAIL_HOSTING,
1466    };
1467
1468    async fn exchange_family(mode: usize, link: Link, seed: &SeedAuthority) -> Result<()> {
1469        let origin = "http://127.0.0.1:3746";
1470        let context = crate::sync::encrypted_tail::Context {
1471            vault: [1; 32],
1472            genesis: [2; 32],
1473            device: [3; 32],
1474            head: [4; 32],
1475            stream: [5; 32],
1476            descriptor: [6; 32],
1477        };
1478        match mode {
1479            0 => {
1480                bootstrap::Client::new(origin, link)?
1481                    .exchange(
1482                        seed.genesis(),
1483                        seed.bearer(),
1484                        bootstrap::Operation::Status { bootstrap: [7; 32] },
1485                    )
1486                    .await?;
1487            }
1488            1 => {
1489                use crate::sync::bootstrap_staging::{
1490                    Component,
1491                    batch::{Header, Slot},
1492                };
1493                bootstrap::Client::new(origin, link)?
1494                    .put_batch(
1495                        seed.bearer(),
1496                        &Header {
1497                            vault: [1; 32],
1498                            genesis: [2; 32],
1499                            bootstrap: [7; 32],
1500                            commitment: [6; 32],
1501                            records: vec![Slot {
1502                                component: Component::DataCatalog,
1503                                index: 0,
1504                                len: 1,
1505                            }],
1506                        },
1507                        &[&[42]],
1508                    )
1509                    .await?;
1510            }
1511            2 => {
1512                enrollment::Client::new(origin, link)?
1513                    .exchange(
1514                        enrollment::Operation::Post {
1515                            vault: [1; 32],
1516                            handle: [7; 32],
1517                            request: vec![42],
1518                        },
1519                        Some(seed.bearer()),
1520                    )
1521                    .await?;
1522            }
1523            3 => {
1524                tail::Client::new(origin, link)?
1525                    .exchange(
1526                        &context,
1527                        seed.bearer(),
1528                        crate::sync::encrypted_tail::Operation::Features,
1529                    )
1530                    .await?;
1531            }
1532            4 => {
1533                tail::Client::new(origin, link)?
1534                    .batch_exchange(
1535                        &context,
1536                        seed.bearer(),
1537                        crate::sync::encrypted_tail::BatchOperation::Resolve {
1538                            operation_ids: vec![],
1539                        },
1540                    )
1541                    .await?;
1542            }
1543            5 => {
1544                tail::Client::new(origin, link)?
1545                    .image_exchange(
1546                        &context,
1547                        seed.bearer(),
1548                        crate::sync::encrypted_tail::attachments::Operation::Release {
1549                            workspace: "fixture".into(),
1550                            object: [7; 32],
1551                            descriptor_commitment: [6; 32],
1552                            reservation: [8; 32],
1553                        },
1554                    )
1555                    .await?;
1556            }
1557            _ => unreachable!(),
1558        }
1559        Ok(())
1560    }
1561
1562    #[tokio::test]
1563    async fn endpoint_hosting_refusals_retain_keys_frozen_intent_and_local_access_then_retry() {
1564        let root = tempfile::tempdir().unwrap();
1565        let db = Database::open(&root.path().join("client.sqlite"))
1566            .await
1567            .unwrap();
1568        let store = isolated_store(&db, &root.path().join("keys")).await;
1569        let seed = store.prepare_seed_claim(&db, [9; 32]).await.unwrap();
1570        let protected = seed.protected_storage_bytes();
1571        store.prepare_seed_source(&db).await.unwrap();
1572        db.capture_local_shared_state_for_setup(root.path())
1573            .await
1574            .unwrap();
1575        store
1576            .package_seed_capture(&db, root.path(), [9; 32])
1577            .await
1578            .unwrap();
1579        store.prepare_seed_adoption_intent(&db).await.unwrap();
1580        let intent = db.seed_publication_intent_bytes().await.unwrap().unwrap();
1581        let paths = [
1582            bootstrap::PATH,
1583            bootstrap::PATH,
1584            enrollment::PATH,
1585            tail::PATH,
1586            tail::BATCH_PATH,
1587            tail::IMAGES_PATH,
1588        ];
1589        let families = [
1590            &BOOTSTRAP_HOSTING,
1591            &BOOTSTRAP_HOSTING,
1592            &ENROLLMENT_HOSTING,
1593            &TAIL_HOSTING,
1594            &TAIL_HOSTING,
1595            &IMAGE_HOSTING,
1596        ];
1597        for (mode, family) in families.into_iter().enumerate() {
1598            for (status, wire_code, client_code) in [
1599                (403, family.blocked, "sync-hosting-blocked"),
1600                (403, family.deleted, "sync-vault-deleted"),
1601                (429, family.quota, "sync-hosting-quota"),
1602                (503, family.unavailable, "sync-hosting-unavailable"),
1603            ] {
1604                let mut session = Session::new(|link| async {
1605                    track_access_result(&db, exchange_family(mode, link, &seed).await).await
1606                });
1607                let Step::Request(request) = session.next().await.unwrap() else {
1608                    panic!("expected request")
1609                };
1610                assert_eq!(url::Url::parse(&request.url).unwrap().path(), paths[mode]);
1611                assert!(
1612                    request
1613                        .headers
1614                        .iter()
1615                        .any(|header| header.name == "authorization")
1616                );
1617                let frozen_request = request.body.clone();
1618                session
1619                    .accept_response(
1620                        request.context,
1621                        HttpResponse {
1622                            status,
1623                            headers: vec![HttpHeader {
1624                                name: "content-type".into(),
1625                                value: "application/json".into(),
1626                            }],
1627                            body: serde_json::to_vec(&serde_json::json!({"error": wire_code}))
1628                                .unwrap(),
1629                        },
1630                    )
1631                    .unwrap();
1632                let error = session
1633                    .next()
1634                    .await
1635                    .expect_err("hosting must stop the exchange");
1636                assert!(has_code(&error, client_code), "{error:#}");
1637                assert!(!is_access_refusal(&error));
1638                assert!(db.sync_access_refusal().await.unwrap().is_none());
1639                let deleted = client_code == "sync-vault-deleted";
1640                assert_eq!(db.sync_vault_deletion().await.unwrap().is_some(), deleted);
1641                assert_eq!(
1642                    db.seed_publication_intent_bytes().await.unwrap().unwrap(),
1643                    intent
1644                );
1645                assert_eq!(
1646                    store
1647                        .prepare_seed_claim(&db, [9; 32])
1648                        .await
1649                        .unwrap()
1650                        .protected_storage_bytes(),
1651                    protected
1652                );
1653                assert!(
1654                    !db.list_workspaces().await.unwrap().is_empty(),
1655                    "local data stays readable"
1656                );
1657
1658                // Lifting hosting policy retries the same operation; it does not
1659                // replace credentials or clear protected publication evidence.
1660                let mut retry = Session::new(|link| async {
1661                    track_access_result(&db, exchange_family(mode, link, &seed).await).await
1662                });
1663                let Step::Request(request) = retry.next().await.unwrap() else {
1664                    panic!("expected retry")
1665                };
1666                let body = if mode < 3 {
1667                    assert_eq!(request.body, frozen_request);
1668                    serde_json::json!(if mode == 0 {
1669                        "Missing"
1670                    } else if mode == 1 {
1671                        "Stored"
1672                    } else {
1673                        "Done"
1674                    })
1675                } else {
1676                    let mut envelope: serde_json::Value =
1677                        serde_json::from_slice(&request.body).unwrap();
1678                    envelope["operation"] = match mode {
1679                        3 => serde_json::json!({"Features": {"count": 128, "bytes": 1048576}}),
1680                        4 => serde_json::json!({"Resolved": []}),
1681                        _ => serde_json::json!("Done"),
1682                    };
1683                    envelope
1684                };
1685                retry
1686                    .accept_response(
1687                        request.context,
1688                        HttpResponse {
1689                            status: 200,
1690                            headers: vec![HttpHeader {
1691                                name: "content-type".into(),
1692                                value: "application/json".into(),
1693                            }],
1694                            body: serde_json::to_vec(&body).unwrap(),
1695                        },
1696                    )
1697                    .unwrap();
1698                assert!(matches!(retry.next().await.unwrap(), Step::Done(())));
1699                assert!(db.sync_access_refusal().await.unwrap().is_none());
1700                // Only a user-started sync clears a recorded deletion.
1701                assert_eq!(db.sync_vault_deletion().await.unwrap().is_some(), deleted);
1702                db.clear_sync_vault_deletion().await.unwrap();
1703                assert_eq!(
1704                    db.seed_publication_intent_bytes().await.unwrap().unwrap(),
1705                    intent
1706                );
1707            }
1708        }
1709    }
1710
1711    #[tokio::test]
1712    async fn official_http_is_rejected_before_a_session_can_dispatch_credentials() {
1713        for origin in [
1714            "http://sync.aventasks.dev",
1715            "HTTP://SYNC.AVENTASKS.DEV.:443",
1716            "http://%73ync.aventasks.dev:3746",
1717        ] {
1718            for mode in 0..3 {
1719                let mut session = Session::new(|link| async move {
1720                    match mode {
1721                        0 => {
1722                            bootstrap::Client::new(origin, link)?;
1723                        }
1724                        1 => {
1725                            enrollment::Client::new(origin, link)?;
1726                        }
1727                        _ => {
1728                            tail::Client::new(origin, link)?;
1729                        }
1730                    }
1731                    Ok(())
1732                });
1733                let error = session
1734                    .next()
1735                    .await
1736                    .expect_err("must refuse before yielding a request");
1737                assert!(has_code(&error, "sync-cloud-https-required"), "{error:#}");
1738            }
1739        }
1740    }
1741}