Skip to main content

aven_core/db/
installation.rs

1//! Denial-only exclusion for explicitly selected E2EE installations.
2//!
3//! A marker survives SQLite replacement. It never authorizes initialization or
4//! adoption. Locks serialize supported replacement with source preparation, not
5//! arbitrary database users. Ordinary operations share the lock; source setup
6//! and replacement acquire it exclusively. All acquisition is nonblocking.
7//! Only a sync reset removes the marker, after the database's sync state and
8//! protected keys are gone. Live raw replacement is unsupported.
9use std::fs::{self, File, OpenOptions};
10use std::io::Write;
11use std::path::{Path, PathBuf};
12
13use anyhow::{Context, Result, ensure};
14
15pub struct InstallationGuard {
16    _lock: File,
17    marker: PathBuf,
18    identity: PathBuf,
19    exclusive: bool,
20}
21
22impl Drop for InstallationGuard {
23    fn drop(&mut self) {
24        // Closing alone leaves the lock held by descriptors inherited during
25        // concurrent process creation, even when they are close-on-exec.
26        let _ = self._lock.unlock();
27    }
28}
29
30impl InstallationGuard {
31    pub fn acquire(path: &Path) -> Result<Self> {
32        Self::acquire_mode(path, true)
33    }
34
35    /// Ordinary operations share exclusion against source setup and replacement.
36    pub(crate) fn acquire_plaintext(path: &Path) -> Result<Self> {
37        let guard = Self::acquire_mode(path, false)?;
38        guard.ensure_unbound()?;
39        Ok(guard)
40    }
41
42    /// Backups share exclusion but may read an encrypted-sync installation.
43    pub(crate) fn acquire_for_backup(path: &Path) -> Result<Self> {
44        Self::acquire_mode(path, false)
45    }
46
47    fn acquire_mode(path: &Path, exclusive: bool) -> Result<Self> {
48        let parent = path
49            .parent()
50            .filter(|p| !p.as_os_str().is_empty())
51            .unwrap_or(Path::new("."));
52        let name = path
53            .file_name()
54            .context("error installation-path-invalid")?;
55        let identity = parent.canonicalize()?.join(name);
56        if let Ok(metadata) = fs::symlink_metadata(&identity) {
57            ensure!(
58                metadata.is_file() && !metadata.file_type().is_symlink(),
59                "error installation-path-alias-unsupported"
60            );
61            #[cfg(unix)]
62            {
63                use std::os::unix::fs::MetadataExt;
64                ensure!(
65                    metadata.nlink() == 1,
66                    "error installation-path-alias-unsupported"
67                );
68            }
69        }
70        let sidecar = |suffix: &str| {
71            let mut name = name.to_os_string();
72            name.push(suffix);
73            identity.with_file_name(name)
74        };
75        let lock_path = sidecar(".aven-installation.lock");
76        if let Ok(metadata) = fs::symlink_metadata(&lock_path) {
77            ensure!(
78                metadata.is_file() && !metadata.file_type().is_symlink(),
79                "error installation-lock-unsafe"
80            );
81        }
82        let lock = crate::private_fs::open_lock_file(&lock_path)
83            .context("error installation-lock-unsafe")?;
84        if exclusive {
85            lock.try_lock()
86        } else {
87            lock.try_lock_shared()
88        }
89        .context("error installation-busy")?;
90        Ok(Self {
91            _lock: lock,
92            marker: sidecar(".aven-e2ee-bound"),
93            identity,
94            exclusive,
95        })
96    }
97
98    pub fn identity(&self) -> &Path {
99        &self.identity
100    }
101
102    pub fn ensure_unbound(&self) -> Result<()> {
103        match fs::symlink_metadata(&self.marker) {
104            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
105            Err(error) => Err(error.into()),
106            Ok(_) => anyhow::bail!(
107                "error e2ee-installation-fenced encrypted-tail-and-replacement-unavailable"
108            ),
109        }
110    }
111
112    pub(crate) fn ensure_restore_target_unbound(&self) -> Result<()> {
113        self.ensure_replacement_target_unbound(
114            "This database takes part in sync, so restore is refused here. Restore the backup to a new database path.",
115        )
116    }
117
118    fn ensure_import_target_unbound(&self) -> Result<()> {
119        self.ensure_replacement_target_unbound(
120            "This database takes part in sync, so import is refused here. Import into a new database path.",
121        )
122    }
123
124    fn ensure_replacement_target_unbound(&self, message: &str) -> Result<()> {
125        match fs::symlink_metadata(&self.marker) {
126            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
127            Err(error) => Err(error.into()),
128            Ok(_) => anyhow::bail!(message.to_string()),
129        }
130    }
131
132    /// Lifts the fence of a database that no longer takes part in sync.
133    pub fn unfence(&self) -> Result<()> {
134        ensure!(self.exclusive, "error installation-exclusive-lock-required");
135        match fs::remove_file(&self.marker) {
136            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
137            result => result?,
138        }
139        File::open(self.marker.parent().context("missing marker parent")?)?.sync_all()?;
140        Ok(())
141    }
142
143    /// Permanently refuses replacement even if later source setup fails.
144    pub fn fence(&self) -> Result<()> {
145        ensure!(self.exclusive, "error installation-exclusive-lock-required");
146        if fs::symlink_metadata(&self.marker).is_ok() {
147            return Ok(());
148        }
149        let mut file = OpenOptions::new()
150            .write(true)
151            .create_new(true)
152            .open(&self.marker)?;
153        file.write_all(b"aven-e2ee-denial-only-v1\n")?;
154        file.sync_all()?;
155        File::open(self.marker.parent().context("missing marker parent")?)?.sync_all()?;
156        Ok(())
157    }
158}
159
160impl super::Database {
161    pub(crate) fn plaintext_installation_guard(&self) -> Result<Option<InstallationGuard>> {
162        self.file_identity()
163            .map(InstallationGuard::acquire_plaintext)
164            .transpose()
165    }
166
167    pub(crate) fn backup_installation_guard(&self) -> Result<Option<InstallationGuard>> {
168        self.file_identity()
169            .map(InstallationGuard::acquire_for_backup)
170            .transpose()
171    }
172
173    pub(crate) fn import_installation_guard(&self) -> Result<Option<InstallationGuard>> {
174        let guard = self
175            .file_identity()
176            .map(InstallationGuard::acquire_for_backup)
177            .transpose()?;
178        if let Some(guard) = &guard {
179            guard.ensure_import_target_unbound()?;
180        }
181        Ok(guard)
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[cfg(unix)]
190    #[test]
191    fn drop_releases_lock_while_an_inherited_descriptor_remains_open() {
192        let root = tempfile::tempdir().unwrap();
193        let path = root.path().join("db.sqlite");
194        for exclusive in [true, false] {
195            let guard = InstallationGuard::acquire_mode(&path, exclusive).unwrap();
196            // A duplicate shares the open file description, just like a child
197            // process's inherited descriptor before close-on-exec runs.
198            let inherited = guard._lock.try_clone().unwrap();
199            assert!(InstallationGuard::acquire(&path).is_err());
200            drop(guard);
201            let next = InstallationGuard::acquire(&path).unwrap();
202            assert!(InstallationGuard::acquire(&path).is_err());
203            drop(inherited);
204            assert!(InstallationGuard::acquire(&path).is_err());
205            drop(next);
206            assert!(InstallationGuard::acquire(&path).is_ok());
207        }
208    }
209
210    #[test]
211    fn missing_target_retains_same_denial_identity_and_aliases_fail() {
212        let root = tempfile::tempdir().unwrap();
213        let path = root.path().join("db.sqlite");
214        let guard = InstallationGuard::acquire(&path).unwrap();
215        let identity = guard.identity().to_path_buf();
216        guard.fence().unwrap();
217        drop(guard);
218        fs::write(&path, b"fixture").unwrap();
219        let guard = InstallationGuard::acquire(&path).unwrap();
220        assert_eq!(guard.identity(), identity);
221        assert!(guard.ensure_unbound().is_err());
222        drop(guard);
223        #[cfg(unix)]
224        {
225            let alias = root.path().join("alias.sqlite");
226            std::os::unix::fs::symlink(&path, &alias).unwrap();
227            assert!(InstallationGuard::acquire(&alias).is_err());
228            fs::remove_file(&alias).unwrap();
229            fs::hard_link(&path, &alias).unwrap();
230            assert!(InstallationGuard::acquire(&alias).is_err());
231            assert!(InstallationGuard::acquire(&path).is_err());
232        }
233    }
234}
235
236#[cfg(test)]
237mod concurrency_tests {
238    use super::*;
239    use crate::db::Database;
240    use std::future::Future;
241    use std::task::{Context as TaskContext, Waker};
242
243    #[tokio::test]
244    async fn ordinary_operations_overlap_across_clones_and_independent_pools() {
245        let root = tempfile::tempdir().unwrap();
246        let path = root.path().join("db.sqlite");
247        let database = Database::open(&path).await.unwrap();
248        let independent = Database::open(&path).await.unwrap();
249        let clone = database.clone();
250        let source = Database::open(&root.path().join("source.sqlite"))
251            .await
252            .unwrap();
253        let capture = source
254            .capture_local_shared_state_never_dispatched()
255            .await
256            .unwrap();
257        let capture = capture.shared_state();
258        let writer = database.acquire_writer().await.unwrap();
259        let mut page = Box::pin(database.install_shared_state(capture));
260        let mut context = TaskContext::from_waker(Waker::noop());
261        // Polling stops at the held writer gate, after installation acquisition.
262        assert!(page.as_mut().poll(&mut context).is_pending());
263        crate::db::backup_database(&path, &root.path().join("backup.sqlite"))
264            .await
265            .unwrap();
266        independent
267            .create_backup_archive(root.path(), &root.path().join("backup.tar.zst"))
268            .await
269            .unwrap();
270        let nested = InstallationGuard::acquire_plaintext(&path).unwrap();
271        assert!(nested.fence().is_err());
272        drop(nested);
273        let mut second = Box::pin(clone.install_shared_state(capture));
274        assert!(second.as_mut().poll(&mut context).is_pending());
275        // Both ordinary calls coexist, but exclusive setup/replacement cannot.
276        assert!(InstallationGuard::acquire(&path).is_err());
277        drop(writer);
278        page.await.unwrap();
279        assert!(InstallationGuard::acquire(&path).is_err());
280        // Only installation exclusion is under test, not the target's contents.
281        let _ = second.await;
282        let exclusive = InstallationGuard::acquire(&path).unwrap();
283        assert!(
284            independent
285                .install_shared_state(capture)
286                .await
287                .unwrap_err()
288                .to_string()
289                .contains("installation")
290        );
291        exclusive.fence().unwrap();
292        drop(exclusive);
293        crate::db::backup_database(&path, &root.path().join("fenced.sqlite"))
294            .await
295            .unwrap();
296        independent
297            .create_backup_archive(root.path(), &root.path().join("fenced.tar.zst"))
298            .await
299            .unwrap();
300    }
301}