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, 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
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 Cloud, your hosting provider or `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 Ok(setup(link, database, host, Some(invitation), progress)
254 .await?
255 .1)
256}
257
258pub const SETUP_INVITATION_REQUIRED: &str = "sync-setup-resume-invitation-required";
262
263pub 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 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 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 if setup_refusal(&error) {
341 let fenced = database.seed_source_pin().await?.is_some();
342 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 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 {
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
417pub 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 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
479pub 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 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#[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
551pub 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 let _ = client
615 .cancel(&store, database, &mut inputs, journal.handle)
616 .await;
617 Ok(Cancellation::Cancelled)
618}
619
620#[derive(Clone, Copy, Debug, PartialEq, Eq)]
622pub enum Admission {
623 Admitted,
624 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
636async 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
680pub 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
711pub 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
730const 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
739pub 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
820fn 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
844pub 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
925fn 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
947const 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
958const 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
961fn 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
974const 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
977async 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
989async 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
1003async 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
1017async 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
1035pub 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 Deferred,
1056 NotSetUp,
1058 VaultDeleted,
1061}
1062
1063pub 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 pub fn more_work_ready(&self) -> bool {
1102 self.images == ImageTransfer::Pending
1103 || (!self.metadata_caught_up && self.images == ImageTransfer::Complete)
1104 }
1105}
1106
1107pub 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
1190async 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
1222fn 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 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
1282pub 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 assert_eq!(images_done(Some((1, 9)), 9), 1);
1388 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 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 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}