Skip to main content

coven_foundation/files/
layout.rs

1//! The stores under an app directory, and publication of a new device's store.
2
3use std::fs;
4use std::io;
5use std::path::PathBuf;
6
7#[cfg(any(test, feature = "test-utils"))]
8use crate::files::DirectoryCreationError;
9
10use crate::files::protection::Protection;
11use crate::files::{
12    settings, BootstrapDirectoryError, FileError, SettingsError, StoreDeletionLock, StoreDir,
13    StoreDirAccess, StoreLockError,
14};
15use crate::id_source::{IdSource, StoreId};
16use crate::task::BugSinkRef;
17
18/// The stores under `app_dir`, one directory each (E1).
19#[cfg_attr(
20    not(feature = "test-utils"),
21    doc = r#"
22Directory-only creation is a test fixture.
23```compile_fail
24use coven_foundation::{files::StoreDirectories, id_source::{StoreId, UuidIds, IdSource}};
25let layout = StoreDirectories::new("stores".into(), std::sync::Arc::new(coven_foundation::task::LoggedBugs));
26let _ = layout.create_store_dir(StoreId(UuidIds.new_id()), &UuidIds);
27```"#
28)]
29#[derive(Clone)]
30pub struct StoreDirectories {
31    app_dir: PathBuf,
32    /// The class every store's files take, where the app chose one.
33    protection: Protection,
34    /// Where a bug in work nobody waits for goes, for every store here
35    /// (E15): the app's own reporter.
36    bugs: BugSinkRef,
37}
38
39impl std::fmt::Debug for StoreDirectories {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        f.debug_struct("StoreDirectories")
42            .field("app_dir", &self.app_dir)
43            .field("protection", &self.protection)
44            .finish_non_exhaustive()
45    }
46}
47
48/// Which of the store directories a listing reads.
49#[derive(Clone, Copy, Debug, PartialEq, Eq)]
50enum Installations {
51    /// Published stores, including those whose replacement is unfinished.
52    Published,
53    /// Stores whose first installation on this device hasn't been published.
54    UnfinishedFirst,
55}
56
57impl StoreDirectories {
58    /// The stores under `app_dir`, one directory each (E1). The app creates
59    /// `app_dir`; coven creates directories only inside it. A bug in work
60    /// nobody waits for, in any of the stores, goes to `bugs` (E15).
61    pub fn new(app_dir: PathBuf, bugs: BugSinkRef) -> Self {
62        Self { app_dir, protection: Protection::none(), bugs }
63    }
64
65    /// Where a bug in work nobody waits for goes, for every store here
66    /// (E15).
67    pub fn bugs(&self) -> &BugSinkRef {
68        &self.bugs
69    }
70
71    /// Give the files of every store these directories create, restore,
72    /// join, replace or open for writing Apple's protection class
73    /// `protection` (E1). On a volume that keeps no classes each of those
74    /// fails before any file changes. Without it, files keep the class they
75    /// have, and new ones take their folder's.
76    #[cfg(target_vendor = "apple")]
77    pub fn file_protection(mut self, protection: crate::files::FileProtection) -> Self {
78        self.protection = Protection::new(protection);
79        self
80    }
81
82    /// The folder that holds one directory per store.
83    fn stores(&self) -> PathBuf {
84        self.app_dir.join("stores")
85    }
86
87    /// The stores on this device, ordered by id (E1), including those whose
88    /// replacement is unfinished, but no unpublished first installation.
89    /// Only directories with a canonical UUID name and matching valid settings
90    /// are stores. Invalid entries are logged; filesystem failures are errors.
91    pub async fn store_ids(&self) -> Result<Vec<StoreId>, FileError> {
92        self.listed(Installations::Published).await
93    }
94
95    /// The stores whose first installation on this device is unfinished, a
96    /// restore or a join that hasn't loaded, ordered by id (§19.1). A store
97    /// whose replacement is unfinished is listed by `store_ids` instead.
98    pub async fn unfinished_first_installations(&self) -> Result<Vec<StoreId>, FileError> {
99        self.listed(Installations::UnfinishedFirst).await
100    }
101
102    /// Lock an unfinished first installation for deletion while no call holds
103    /// it: no restore or join, in this process or another, holds its
104    /// bootstrap lock, and one closing in this process has been waited for.
105    /// `None` when a call holds it, or it is no longer an unfinished first
106    /// installation: published, being replaced, or gone. Whether anything
107    /// still refers to it, a join's saved code or a restore's record, is for
108    /// the caller to read while it holds the lock, since no call can begin on
109    /// the installation meanwhile (§13.4, §11.6).
110    pub async fn lock_unfinished_first(
111        &self,
112        id: StoreId,
113    ) -> Result<Option<StoreDeletionLock>, BootstrapDirectoryError> {
114        self.store_dir(&id).closed().await?;
115        let path = self.stores().join(id.to_string());
116        crate::task::blocking_completing(
117            "lock an unfinished installation",
118            self.bugs.clone(),
119            move || {
120                let lock = match super::lock::for_deletion(&path, id, None) {
121                    Ok(Some(lock)) => lock,
122                    // A call holds it, or nothing of it is left.
123                    Ok(None) | Err(StoreLockError::AlreadyOpen(_)) => return Ok(None),
124                    Err(error) => return Err(BootstrapDirectoryError::from(error)),
125                };
126                let first = super::bootstrap::is_pending(&path)?
127                    && super::device_reset::read(&path)?.is_none();
128                Ok(first.then_some(lock))
129            },
130        )
131        .await
132        .map_err(FileError::from)?
133    }
134
135    /// The stores whose deletion from this device is recorded and
136    /// unfinished, ordered by id: a directory under its deletion name, or the
137    /// store's writer or reader lock file with no directory. A deletion's
138    /// first step renames the store's directory, so either is what a cut
139    /// deletion leaves (E1).
140    pub async fn recorded_deletions(&self) -> Result<Vec<StoreId>, FileError> {
141        let root = self.stores();
142        crate::task::blocking_completing("list recorded deletions", self.bugs.clone(), move || {
143            recorded_deletions(&root)
144        })
145        .await?
146    }
147
148    /// Whether the deletion of the store with `id` is recorded and
149    /// unfinished, as `recorded_deletions` reads it.
150    pub async fn deletion_recorded(&self, id: StoreId) -> Result<bool, FileError> {
151        let published = self.stores().join(id.to_string());
152        crate::task::blocking_completing(
153            "inspect a recorded deletion",
154            self.bugs.clone(),
155            move || deletion_recorded(&published, id),
156        )
157        .await?
158    }
159
160    async fn listed(&self, installations: Installations) -> Result<Vec<StoreId>, FileError> {
161        let layout = self.clone();
162        let bugs = self.bugs.clone();
163        crate::task::blocking_completing("list stores", bugs, move || {
164            layout.list_stores(installations)
165        })
166        .await?
167    }
168
169    fn list_stores(&self, installations: Installations) -> Result<Vec<StoreId>, FileError> {
170        let root = self.stores();
171        let entries = match fs::read_dir(&root) {
172            Ok(entries) => entries,
173            Err(error) if error.kind() == io::ErrorKind::NotFound => {
174                tracing::debug!(path = %root.display(), "stores directory is absent");
175                return Ok(Vec::new());
176            }
177            Err(source) => return Err(FileError::at("list store directories", &root, source)),
178        };
179        let mut stores = Vec::new();
180        for entry in entries {
181            let entry = entry
182                .map_err(|source| FileError::at("read store directory entry", &root, source))?;
183            let path = entry.path();
184            if !entry
185                .file_type()
186                .map_err(|source| FileError::at("inspect store directory", &path, source))?
187                .is_dir()
188            {
189                tracing::debug!(path = %path.display(), "entry is not a store directory");
190                continue;
191            }
192            let name = entry.file_name();
193            let id = match name.to_str().map(crate::id_source::canonical_uuid) {
194                Some(Ok(id)) => StoreId(id),
195                _ => {
196                    tracing::debug!(path = %path.display(), "directory name is not a store id");
197                    continue;
198                }
199            };
200            // A first installation stays hidden from the stores until
201            // published; a store whose replacement is unfinished lists, so
202            // the app can finish it.
203            let first =
204                super::bootstrap::is_pending(&path)? && super::device_reset::read(&path)?.is_none();
205            if first != (installations == Installations::UnfinishedFirst) {
206                tracing::debug!(path = %path.display(), first, "store is not in this listing");
207                continue;
208            }
209            match settings::read(&path, id) {
210                Ok(_) => stores.push(id),
211                Err(SettingsError::File(error)) => return Err(error),
212                Err(SettingsError::Bug(bug)) => return Err(FileError::Bug(bug)),
213                Err(error) => {
214                    tracing::warn!(path = %path.display(), error = %error, "directory has no valid store settings")
215                }
216            }
217        }
218        stores.sort();
219        Ok(stores)
220    }
221
222    /// The directory of the store with `id` (E1). This does not create it,
223    /// read settings, or acquire the writer's lock.
224    pub fn store_dir(&self, id: &StoreId) -> StoreDir {
225        StoreDir::new(self.stores().join(id.to_string()), *id, self.protection, self.bugs.clone())
226    }
227
228    /// Publish a directory fixture without initializing member custody.
229    #[cfg(any(test, feature = "test-utils"))]
230    pub fn create_store_dir(
231        &self,
232        id: StoreId,
233        ids: &dyn IdSource,
234    ) -> Result<StoreDir, DirectoryCreationError> {
235        crate::files::creation::create(
236            &self.stores(),
237            id,
238            ids,
239            false,
240            self.protection,
241            self.bugs.clone(),
242        )
243    }
244}
245
246/// Whether a deletion of the store whose published directory is `published`
247/// is recorded: its directory under the deletion name, or, with no directory
248/// at all, its writer or reader lock file.
249fn deletion_recorded(published: &std::path::Path, id: StoreId) -> Result<bool, FileError> {
250    use super::atomic_file::exists;
251    // Creation refuses an id while its deletion's remains are there, so a
252    // published directory has none beside it.
253    if exists("inspect a store", published)? {
254        return Ok(false);
255    }
256    if exists("inspect a store's deletion", &super::lock::deletion_path(published, id))? {
257        return Ok(true);
258    }
259    for path in super::lock::lock_paths(published, id) {
260        if exists("inspect a store's lock", &path)? {
261            return Ok(true);
262        }
263    }
264    Ok(false)
265}
266
267fn recorded_deletions(root: &std::path::Path) -> Result<Vec<StoreId>, FileError> {
268    let entries = match fs::read_dir(root) {
269        Ok(entries) => entries,
270        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
271        Err(source) => return Err(FileError::at("list store directories", root, source)),
272    };
273    let mut named = std::collections::BTreeSet::new();
274    for entry in entries {
275        let entry =
276            entry.map_err(|source| FileError::at("read store directory entry", root, source))?;
277        let name = entry.file_name();
278        let Some(name) = name.to_str() else {
279            tracing::debug!(name = ?name, "entry name is not UTF-8, so no store's");
280            continue;
281        };
282        // The names a deletion leaves: `.coven-delete-<id>`, `.<id>.lock` and
283        // `.<id>.readers.lock`.
284        let id = name
285            .strip_prefix(".coven-delete-")
286            .or_else(|| name.strip_prefix('.').and_then(|rest| rest.strip_suffix(".readers.lock")))
287            .or_else(|| name.strip_prefix('.').and_then(|rest| rest.strip_suffix(".lock")));
288        if let Some(Ok(id)) = id.map(crate::id_source::canonical_uuid) {
289            named.insert(StoreId(id));
290        }
291    }
292    let mut recorded = Vec::new();
293    for id in named {
294        if deletion_recorded(&root.join(id.to_string()), id)? {
295            recorded.push(id);
296        }
297    }
298    Ok(recorded)
299}
300
301impl StoreDirectories {
302    /// Reserve a hidden, restartable new-device store. A second attempt for the
303    /// same id waits for a dropped bootstrap's remaining work to release its
304    /// locks; a live bootstrap still refuses it. Ordinary store listings exclude
305    /// it until `BootstrapStore::publish` succeeds.
306    pub async fn begin_bootstrap(
307        &self,
308        id: StoreId,
309        ids: &dyn IdSource,
310    ) -> Result<super::BootstrapStore, super::BootstrapDirectoryError> {
311        super::bootstrap::reserve_bootstrap_directory(
312            &self.stores(),
313            id,
314            ids,
315            self.protection,
316            self.bugs.clone(),
317        )
318        .await
319    }
320
321    /// Replace the installation of a published store, or continue a
322    /// replacement under way: hold the store's bootstrap and writer locks, so
323    /// no handle opens it meanwhile, and load the new installation in a
324    /// hidden directory inside the store's directory (§19.3). A recorded
325    /// replacement continues its load, and a swap under way finishes. One
326    /// that the other call began is refused with
327    /// `StoreLockError::ReplacementUnfinished`, changing nothing.
328    pub async fn begin_replacement(
329        &self,
330        id: StoreId,
331        by: super::ReplacedBy,
332        ids: &dyn IdSource,
333    ) -> Result<super::ReplacementStore, super::BootstrapDirectoryError> {
334        super::replacement::begin(&self.stores(), id, by, ids, self.protection, self.bugs.clone())
335            .await
336    }
337}
338
339#[cfg(test)]
340#[path = "layout_tests.rs"]
341mod tests;