1use 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
35pub const ROUND_LIMIT: usize = 1000;
39const IMAGE_RETRY_ROUNDS: usize = 16;
42#[cfg(not(any(test, feature = "test-support")))]
43fn invitation_seconds() -> u64 {
44 600
45}
46#[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#[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
99pub async fn is_set_up(database: &Database) -> Result<bool> {
102 Ok(database.enrollment_pin().await?.is_some() || has_seed_setup(database).await?)
103}
104
105async 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
115pub enum LocalPhase {
116 NotSetUp,
117 SetupIncomplete,
119 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#[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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
159pub enum Amount {
160 Bytes { done: u64, total: Option<u64> },
163 Changes { done: u64 },
165 Images { done: u64, remaining: u64 },
168}
169
170#[derive(Clone, Copy, Debug, PartialEq, Eq)]
173pub enum Stage {
174 PreparingData,
175 UploadingData,
176 FinishingSetup,
177 WaitingForInviter,
178 DownloadingTasks,
179 CatchingUp,
181 DownloadingImages,
183}
184
185pub fn unix_now() -> Result<u64> {
186 Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs())
187}
188
189#[derive(Clone, Debug, PartialEq, Eq)]
191pub struct SetupPreview {
192 pub workspaces: usize,
193 pub tasks: i64,
194 pub missing_images: u64,
196 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
218pub 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
243pub 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 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 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 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 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 {
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
359pub 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 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
421pub 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 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#[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
493pub 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 let _ = client
557 .cancel(&store, database, &mut inputs, journal.handle)
558 .await;
559 Ok(Cancellation::Cancelled)
560}
561
562#[derive(Clone, Copy, Debug, PartialEq, Eq)]
564pub enum Admission {
565 Admitted,
566 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
578async 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
622pub 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
653pub 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
672const 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
681pub 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
759fn 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
783pub 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
864fn 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
886const 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
897const 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
900fn 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
913const 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
927async 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
941async 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
968pub 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 Deferred,
984 NotSetUp,
986}
987
988pub 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 pub fn more_work_ready(&self) -> bool {
1023 self.images == ImageTransfer::Pending
1024 || (!self.metadata_caught_up && self.images == ImageTransfer::Complete)
1025 }
1026}
1027
1028pub 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
1111async 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
1143fn 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 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
1201pub 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 assert_eq!(images_done(Some((1, 9)), 9), 1);
1301 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}