Skip to main content

aven_core/sync/client/engine/
reset.rs

1//! Returning a database to local-only use.
2//!
3//! Reset is local: it contacts no server and changes no membership. Other
4//! devices still list this one until they remove it.
5use anyhow::{Result, bail};
6
7use super::{LocalPhase, local_phase, unix_now};
8use crate::db::Database;
9use crate::db::installation::InstallationGuard;
10use crate::sync::client::coordination;
11use crate::sync::client::host::{ClientHost, key_store};
12
13const SETUP_IN_PROGRESS: &str = "error sync-reset-setup-in-progress hint=\"finish setup by rerunning `aven sync setup`; if the original setup can no longer be resumed, run `aven sync reset --force`\"";
14const JOIN_IN_PROGRESS: &str = "error sync-reset-join-in-progress hint=\"finish joining by rerunning `aven sync join` before resetting\"";
15const INVITATION_OPEN: &str = "error sync-reset-invitation-open hint=\"wait for the other device to join, or run `aven sync invite --cancel`, before resetting\"";
16
17/// What a reset found.
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub enum Reset {
20    /// The database took part in sync and no longer does.
21    Reset,
22    /// The database didn't take part in sync; leftover sync state, if any,
23    /// was removed.
24    NotSetUp,
25}
26
27/// Makes `database` a local database that never synced: sync state, the
28/// outbox and this database's protected keys are deleted, while tasks, images
29/// and history stay. Setup and join then apply as for a new database.
30/// Refused while joining or an unexpired invitation is unfinished. An
31/// unfinished setup requires explicit permission because abandoning one may
32/// orphan server state when its claim succeeded but its response was lost.
33pub async fn reset(
34    database: &Database,
35    host: &dyn ClientHost,
36    force_incomplete_setup: bool,
37) -> Result<Reset> {
38    let _guard = coordination::acquire(database).await?;
39    let phase = local_phase(database).await?;
40    match phase {
41        LocalPhase::SetupIncomplete if !force_incomplete_setup => bail!(SETUP_IN_PROGRESS),
42        LocalPhase::SetupIncomplete => {}
43        LocalPhase::JoinIncomplete => bail!(JOIN_IN_PROGRESS),
44        LocalPhase::SetUp if invitation_open(database, host).await => bail!(INVITATION_OPEN),
45        LocalPhase::SetUp | LocalPhase::NotSetUp => {}
46    }
47    let store = key_store(host, database).await?;
48    let installation = database
49        .file_identity()
50        .map(InstallationGuard::acquire)
51        .transpose()?;
52    // Local state goes first, so an interruption leaves a local database
53    // whose remaining keys and fence a rerun removes.
54    database.clear_sync_state().await?;
55    store.erase()?;
56    if let Some(installation) = installation {
57        installation.unfence()?;
58    }
59    Ok(match phase {
60        LocalPhase::SetupIncomplete | LocalPhase::SetUp => Reset::Reset,
61        LocalPhase::JoinIncomplete | LocalPhase::NotSetUp => Reset::NotSetUp,
62    })
63}
64
65/// An unexpired invitation that the other device may still complete. Keys
66/// that can't be read can't admit a device either, so failures count as none.
67async fn invitation_open(database: &Database, host: &dyn ClientHost) -> bool {
68    let open = async {
69        let store = key_store(host, database).await?;
70        let Some((_, server)) = store.association(database).await? else {
71            return anyhow::Ok(false);
72        };
73        let inputs = store.active_inputs(database, &server).await?;
74        let now = unix_now()?;
75        Ok(store
76            .open_invitation(database, &inputs)
77            .await?
78            .is_some_and(|invitation| invitation.expires_at > now))
79    };
80    open.await.unwrap_or(false)
81}
82
83#[cfg(test)]
84mod tests {
85    use std::path::{Path, PathBuf};
86    use std::sync::Arc;
87
88    use super::*;
89    use crate::api::{CreateTask, Store};
90    use crate::choices::{TaskPriority, TaskStatus};
91    use crate::sync::client::keys::test_support::isolated_store;
92    use crate::sync::client::keys::{FileProtectedStorage, ProtectedStorage, StoreResult};
93
94    struct Host(PathBuf);
95
96    impl ClientHost for Host {
97        fn ensure_sync_allowed(&self) -> Result<()> {
98            Ok(())
99        }
100        fn protected_storage(&self) -> StoreResult<Arc<dyn ProtectedStorage>> {
101            Ok(Arc::new(FileProtectedStorage::new(self.0.clone())))
102        }
103        fn blob_dir(&self, database: &Database) -> Result<PathBuf> {
104            Ok(database.path().with_extension("blobs"))
105        }
106        fn attachment_lifecycle_policy(&self) -> crate::attachments::LifecyclePolicy {
107            crate::attachments::LifecyclePolicy::default()
108        }
109        fn device_label(&self) -> Option<String> {
110            None
111        }
112    }
113
114    async fn database_with_task(path: &Path) -> Database {
115        let store = Store::open(path).await.unwrap();
116        let workspace = store.resolve_workspace("default").await.unwrap();
117        store
118            .create_task(
119                &workspace.id,
120                CreateTask {
121                    title: "Kept through reset".to_string(),
122                    description: String::new(),
123                    project: "app".to_string(),
124                    status: TaskStatus::Todo,
125                    priority: TaskPriority::None,
126                    metadata: Vec::new(),
127                    available_at: None,
128                    due_on: None,
129                },
130            )
131            .await
132            .unwrap();
133        drop(store);
134        Database::open(path).await.unwrap()
135    }
136
137    async fn count(database: &Database, sql: &str) -> i64 {
138        sqlx::query_scalar(sqlx::AssertSqlSafe(sql))
139            .fetch_one(database.pool())
140            .await
141            .unwrap()
142    }
143
144    #[tokio::test]
145    async fn reset_keeps_data_and_leaves_a_database_that_can_set_up_again() {
146        let directory = tempfile::tempdir().unwrap();
147        let keys = directory.path().join("keys");
148        let path = directory.path().join("aven.sqlite");
149        let database = database_with_task(&path).await;
150        let host = Host(keys.clone());
151
152        // Leftover sync state: protected keys, the fence, and local sync rows.
153        isolated_store(&database, &keys)
154            .await
155            .load_or_create()
156            .unwrap();
157        InstallationGuard::acquire(&path).unwrap().fence().unwrap();
158        sqlx::query(
159            "INSERT OR REPLACE INTO meta(key, value) VALUES
160             ('e2ee_association', 'old'), ('sync_cursor', '42'), ('sync_generation', '3')",
161        )
162        .execute(database.pool())
163        .await
164        .unwrap();
165        let key_files = || {
166            std::fs::read_dir(&keys)
167                .unwrap()
168                .filter_map(|entry| entry.unwrap().file_name().into_string().ok())
169                .filter(|name| !name.ends_with(".lock"))
170                .count()
171        };
172        assert!(key_files() > 0);
173
174        assert_eq!(
175            reset(&database, &host, false).await.unwrap(),
176            Reset::NotSetUp
177        );
178
179        assert_eq!(count(&database, "SELECT COUNT(*) FROM tasks").await, 1);
180        assert!(count(&database, "SELECT COUNT(*) FROM changes").await > 0);
181        assert_eq!(key_files(), 0);
182        assert!(
183            InstallationGuard::acquire(&path)
184                .unwrap()
185                .ensure_unbound()
186                .is_ok()
187        );
188        assert_eq!(database.meta("e2ee_association").await.unwrap(), None);
189        assert_eq!(
190            database.meta("sync_cursor").await.unwrap().as_deref(),
191            Some("0")
192        );
193        assert_eq!(local_phase(&database).await.unwrap(), LocalPhase::NotSetUp);
194        super::super::ensure_setup_available(&database, &host)
195            .await
196            .unwrap();
197        // Setup would create a new keyring rather than find a missing one.
198        isolated_store(&database, &keys)
199            .await
200            .load_or_create()
201            .unwrap();
202
203        // Rerunning is harmless.
204        assert_eq!(
205            reset(&database, &host, false).await.unwrap(),
206            Reset::NotSetUp
207        );
208        assert_eq!(count(&database, "SELECT COUNT(*) FROM tasks").await, 1);
209    }
210}