Skip to main content

prov_store/
fs.rs

1//! The write half of prov's filesystem port.
2//!
3//! [`prov_graph::fs`] declares [`ReadStorage`] — everything the traversal core
4//! needs, and nothing that can change a byte. This module declares the other
5//! half: [`Storage`], the durability vocabulary a backend answers with
6//! ([`Capabilities`], [`Durability`], [`SyncGuarantee`]), and the
7//! write-temp-then-rename protocol that makes a replacement crash-atomic.
8//!
9//! The method set mirrors [`std::fs`] names exactly, so an adapter is
10//! mechanical to write. A backend implements the read surface on
11//! [`ReadStorage`] over in `prov-graph` and the write/mutate/durability surface
12//! here.
13
14use std::io;
15use std::path::{Path, PathBuf};
16use std::sync::Arc;
17
18use prov_graph::fs::ReadStorage;
19
20pub mod memory;
21
22pub use memory::InMemoryFs;
23pub use prov_graph::fs::StdFs;
24
25/// An async filesystem backend prov can drive — [`ReadStorage`] plus everything
26/// that changes bytes on disk.
27///
28/// Each method mirrors the [`std::fs`] function of the same name. Backends
29/// implement the write/mutate/durability surface here and the read surface on
30/// [`ReadStorage`].
31pub trait Storage: ReadStorage {
32    // ---- write ----
33
34    /// Write a file, replacing it if it already exists. Mirrors
35    /// [`std::fs::write`].
36    fn write(&self, path: &Path, contents: &[u8]) -> impl Future<Output = io::Result<()>>;
37
38    /// Create a directory and all missing parents. Mirrors
39    /// [`std::fs::create_dir_all`].
40    fn create_dir_all(&self, path: &Path) -> impl Future<Output = io::Result<()>>;
41
42    // ---- mutate ----
43
44    /// Remove a regular file. Mirrors [`std::fs::remove_file`].
45    fn remove_file(&self, path: &Path) -> impl Future<Output = io::Result<()>>;
46
47    /// Recursively remove a directory and its contents. Mirrors
48    /// [`std::fs::remove_dir_all`].
49    fn remove_dir_all(&self, path: &Path) -> impl Future<Output = io::Result<()>>;
50
51    /// Rename or move a file or directory. Mirrors [`std::fs::rename`].
52    fn rename(&self, from: &Path, to: &Path) -> impl Future<Output = io::Result<()>>;
53
54    // ---- durability ----
55    //
56    // prov spans backends with very different crash guarantees — `std::fs`
57    // (atomic rename and fsync on every major OS), OPFS (a flush primitive but a
58    // weak rename), IndexedDB (its own multi-object transactions). Rather than
59    // assume the strongest of these and silently lie on the weakest, the crash-
60    // safety machinery *asks* what a backend can promise and adapts. These three
61    // members are defaulted to the pessimistic answer, so a backend gains a
62    // guarantee only by explicitly claiming it.
63
64    /// What durability guarantees this backend can make. Defaults to
65    /// [`Capabilities::NONE`] — a backend promises a guarantee only by saying so,
66    /// so an adapter that forgets to override this degrades to the most defensive
67    /// path rather than to a false promise.
68    fn capabilities(&self) -> Capabilities {
69        Capabilities::NONE
70    }
71
72    /// Flush `path` — and nothing else — to the strength `need` asks for.
73    ///
74    /// `path` names *one* object, and only that object is flushed. To make a
75    /// directory entry durable (the naming half of a create or a rename), sync
76    /// the directory itself: on a POSIX filesystem a directory is a thing that
77    /// can be opened and fsynced, and prov's own
78    /// [`write_atomic`](Storage::write_atomic) does exactly that after its
79    /// rename. Folding the parent into every call instead would flush twice as
80    /// much as any single step needs, and would leave the caller unable to say
81    /// which of the two it actually meant.
82    ///
83    /// `need` is the *weakest* guarantee that is still correct at the call site,
84    /// not a wish. [`Durability::Ordered`] asks only that everything written to
85    /// `path` before this call land before anything written after it — enough to
86    /// stop a rename overtaking the bytes it publishes, and on some platforms far
87    /// cheaper than the real thing. [`Durability::Durable`] asks that the bytes
88    /// survive power loss. A backend may always answer with something stronger
89    /// than it was asked for; it may never answer with something weaker.
90    ///
91    /// The default is a no-op, which is the *correct* behavior for any backend
92    /// whose [`capabilities`](Storage::capabilities) report
93    /// [`SyncGuarantee::None`]: it cannot make the promise, so it must not
94    /// pretend to. A backend that can flush must both override this and report
95    /// the strongest request it genuinely honors — the two always travel
96    /// together, and [`SyncGuarantee::satisfies`] is how a caller asks.
97    fn sync(&self, path: &Path, need: Durability) -> impl Future<Output = io::Result<()>> {
98        async move {
99            let _ = (path, need);
100            Ok(())
101        }
102    }
103
104    /// Replace `path`'s contents with `contents` atomically and durably: no
105    /// observer — concurrent reader or post-crash survivor — ever sees a splice
106    /// of old and new bytes, and once this returns the new contents outlive a
107    /// power loss.
108    ///
109    /// The default composes the primitives into the standard protocol, whenever
110    /// [`capabilities`](Storage::capabilities) report `atomic_replace`:
111    ///
112    /// 1. write the bytes to a temporary sibling;
113    /// 2. [`sync`](Storage::sync) that sibling [`Ordered`](Durability::Ordered),
114    ///    so the rename cannot be reordered ahead of the bytes it publishes;
115    /// 3. [`rename`](Storage::rename) it over the target — *this* is the atomic
116    ///    instant;
117    /// 4. `sync` the target's **parent directory** [`Durable`](Durability::Durable),
118    ///    which is what carries the rename itself through a power cut.
119    ///
120    /// Two flushes, and each one is load-bearing. Neither of the two this
121    /// protocol conspicuously does *not* do would buy anything. The bytes are
122    /// never flushed under their final name, because a rename does not move an
123    /// inode: the file the target now names is the very one step 2 flushed, and
124    /// nothing has been written to it since. The sibling's own directory entry is
125    /// never flushed either, because nobody is owed a temporary that survives a
126    /// crash — only the directory state *after* the rename is worth a barrier.
127    ///
128    /// A backend that cannot rename atomically falls back to a plain durable
129    /// write, which is *not* crash-atomic; a caller that needs the guarantee
130    /// consults `capabilities` and leans on the journal instead of pretending
131    /// this call gave it. A backend with a better native path — a transactional
132    /// store — overrides this method wholesale.
133    ///
134    /// The temporary is removed on any failure, so a torn attempt leaves the
135    /// target exactly as it was and no litter behind. It is a dotted sibling in
136    /// the target's own directory, so the follow-up rename stays within one
137    /// filesystem (a cross-device rename is neither atomic nor, often, even
138    /// permitted).
139    fn write_atomic(&self, path: &Path, contents: &[u8]) -> impl Future<Output = io::Result<()>> {
140        async move {
141            if !self.capabilities().atomic_replace {
142                // No atomic rename to lean on: the honest best effort is a plain
143                // durable write. Not crash-atomic — and the caller was told so by
144                // `capabilities`, so this is a documented degrade, not a lie.
145                // Both the bytes and, if this call created the file, the entry
146                // naming them have to be flushed; there is no rename here to fold
147                // the second into.
148                self.write(path, contents).await?;
149                self.sync(path, Durability::Durable).await?;
150                return match parent_dir(path) {
151                    Some(dir) => self.sync(dir, Durability::Durable).await,
152                    None => Ok(()),
153                };
154            }
155            let tmp = temp_sibling(path);
156            // Any failure past this point must not leave the staging file behind,
157            // and must never have touched the target — hence the whole dance
158            // happens on `tmp` and only the rename names `path`.
159            let staged = async {
160                self.write(&tmp, contents).await?;
161                self.sync(&tmp, Durability::Ordered).await?;
162                self.rename(&tmp, path).await
163            }
164            .await;
165            match staged {
166                // The bytes are already flushed and the rename has happened, so
167                // the directory entry is the last thing standing between this
168                // write and a power cut.
169                Ok(()) => match parent_dir(path) {
170                    Some(dir) => self.sync(dir, Durability::Durable).await,
171                    // A bare relative filename, whose directory is the process's
172                    // current one — not a path prov holds, nor one it owns.
173                    None => Ok(()),
174                },
175                Err(e) => {
176                    // Best-effort cleanup: if even this fails the target is still
177                    // untouched, so the atomicity promise holds regardless — the
178                    // worst case is one stray dotfile, not a torn document.
179                    let _ = self.remove_file(&tmp).await;
180                    Err(e)
181                }
182            }
183        }
184    }
185}
186
187impl<S: Storage + ?Sized> Storage for &S {
188    async fn write(&self, path: &Path, contents: &[u8]) -> io::Result<()> {
189        (**self).write(path, contents).await
190    }
191
192    async fn create_dir_all(&self, path: &Path) -> io::Result<()> {
193        (**self).create_dir_all(path).await
194    }
195
196    async fn remove_file(&self, path: &Path) -> io::Result<()> {
197        (**self).remove_file(path).await
198    }
199
200    async fn remove_dir_all(&self, path: &Path) -> io::Result<()> {
201        (**self).remove_dir_all(path).await
202    }
203
204    async fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
205        (**self).rename(from, to).await
206    }
207
208    fn capabilities(&self) -> Capabilities {
209        (**self).capabilities()
210    }
211
212    async fn sync(&self, path: &Path, need: Durability) -> io::Result<()> {
213        (**self).sync(path, need).await
214    }
215
216    async fn write_atomic(&self, path: &Path, contents: &[u8]) -> io::Result<()> {
217        (**self).write_atomic(path, contents).await
218    }
219}
220
221impl<S: Storage + ?Sized> Storage for Arc<S> {
222    async fn write(&self, path: &Path, contents: &[u8]) -> io::Result<()> {
223        (**self).write(path, contents).await
224    }
225
226    async fn create_dir_all(&self, path: &Path) -> io::Result<()> {
227        (**self).create_dir_all(path).await
228    }
229
230    async fn remove_file(&self, path: &Path) -> io::Result<()> {
231        (**self).remove_file(path).await
232    }
233
234    async fn remove_dir_all(&self, path: &Path) -> io::Result<()> {
235        (**self).remove_dir_all(path).await
236    }
237
238    async fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
239        (**self).rename(from, to).await
240    }
241
242    fn capabilities(&self) -> Capabilities {
243        (**self).capabilities()
244    }
245
246    async fn sync(&self, path: &Path, need: Durability) -> io::Result<()> {
247        (**self).sync(path, need).await
248    }
249
250    async fn write_atomic(&self, path: &Path, contents: &[u8]) -> io::Result<()> {
251        (**self).write_atomic(path, contents).await
252    }
253}
254
255/// The durability guarantees a [`Storage`] backend can make — declared by the
256/// backend through [`Storage::capabilities`], honored by prov's crash-safety
257/// machinery.
258///
259/// The point of naming these explicitly is that prov must run correctly over
260/// backends that keep very different promises. Rather than assume a guarantee and
261/// corrupt data on the backend that cannot keep it, prov reads the
262/// capabilities and picks the strongest *protocol the backend actually supports*:
263/// a filesystem gets atomic-rename writes and a journal; a transactional store is
264/// handed the whole change set to commit itself; a backend that can promise
265/// neither still works, it simply cannot claim a write survives a crash.
266#[derive(Debug, Clone, Copy, PartialEq, Eq)]
267pub struct Capabilities {
268    /// The backend can replace an existing file's contents in one indivisible
269    /// step, so no crash exposes a half-written file — an observer sees the whole
270    /// old contents or the whole new. On a filesystem this is realized by
271    /// [`Storage::write_atomic`]'s write-temp-then-`rename`; a backend may
272    /// instead be atomic by nature.
273    pub atomic_replace: bool,
274
275    /// How strong the backend's [`Storage::sync`] is: whether it can flush at
276    /// all, and if so whether a flush merely orders writes or carries them
277    /// through a power cut. `fsync` on `std::fs`, `FileSystemSyncAccessHandle
278    /// .flush()` on OPFS, the implicit durability of a committed IndexedDB
279    /// transaction.
280    pub sync_guarantee: SyncGuarantee,
281
282    /// The backend commits changes to *many* objects as one indivisible unit, so
283    /// prov's own write-ahead journal would be redundant and it should defer
284    /// to the backend instead. True for IndexedDB; false for a plain filesystem,
285    /// where multi-file atomicity is prov's job to provide.
286    pub native_transactions: bool,
287}
288
289impl Capabilities {
290    /// Promises nothing — the safe assumption for an unknown backend, and the
291    /// [`Storage::capabilities`] default. Every field is the pessimistic value,
292    /// so code that checks a capability before relying on it takes the most
293    /// defensive branch unless a backend has explicitly earned a lighter one.
294    pub const NONE: Self = Self {
295        atomic_replace: false,
296        sync_guarantee: SyncGuarantee::None,
297        native_transactions: false,
298    };
299
300    /// A conventional local filesystem: atomic replacement by rename and durable
301    /// fsync, but no native multi-object transaction (that is the journal's job).
302    /// What [`StdFs`] reports on every platform prov targets.
303    pub const LOCAL_FS: Self = Self {
304        atomic_replace: true,
305        sync_guarantee: SyncGuarantee::Durable,
306        native_transactions: false,
307    };
308
309    /// An in-process, memory-only store ([`InMemoryFs`]): every mutation takes
310    /// the backend's single lock for its whole duration, so one write already
311    /// swaps old bytes for new as one indivisible step — no separate
312    /// temp-then-rename dance is needed for `atomic_replace` to be true. But
313    /// nothing here is backed by anything other than process memory, so its
314    /// `sync_guarantee` is [`SyncGuarantee::None`]: there is nothing to flush,
315    /// and the entire store evaporates the instant the process exits — it cannot
316    /// even promise ordering against a crash it will not survive.
317    /// `native_transactions`
318    /// is false too — the lock makes each *single* call atomic, not a batch of
319    /// several calls committed together, so a multi-file change set still
320    /// needs prov's own journal over this backend exactly as it would over a
321    /// real filesystem.
322    pub const IN_MEMORY: Self = Self {
323        atomic_replace: true,
324        sync_guarantee: SyncGuarantee::None,
325        native_transactions: false,
326    };
327}
328
329/// What a caller needs from one [`Storage::sync`] call — the *weakest* guarantee
330/// that is still correct at that point, so that a backend able to serve it
331/// cheaply is free to.
332#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
333pub enum Durability {
334    /// Everything written to the path before this call must land before anything
335    /// written after it. It says nothing about *when*: a crash may still lose
336    /// the lot, only never a suffix without its prefix. This is all
337    /// [`Storage::write_atomic`] needs from its staging flush — the rename must
338    /// not be seen before the bytes it publishes — and on Apple platforms it is
339    /// the difference between a barrier and draining the drive's write cache.
340    Ordered,
341    /// Once the call returns, the bytes survive power loss.
342    Durable,
343}
344
345/// How strong a backend's [`Storage::sync`] actually is — the standing answer to
346/// a [`Durability`] request, declared once in [`Capabilities`] rather than
347/// discovered per call.
348///
349/// Deliberately three-valued rather than the "can this backend flush?" boolean
350/// it replaces, because that question has a common and useful middle answer it
351/// could not express: a backend that orders writes against each other without
352/// paying for a device-wide cache drain. Offered only `true` and `false`, such a
353/// backend has to either overstate — claiming a durability it does not deliver —
354/// or understate, claiming it cannot flush at all when ordering is precisely
355/// what [`Storage::write_atomic`] asks it for.
356#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
357pub enum SyncGuarantee {
358    /// `sync` does nothing: an in-memory store, or a port with no flush
359    /// primitive under it to call.
360    None,
361    /// `sync` orders writes against each other, but does not promise any of them
362    /// outlives a power cut.
363    Ordered,
364    /// `sync` flushes through to durable storage.
365    Durable,
366}
367
368impl SyncGuarantee {
369    /// Whether a backend making this guarantee can honor `need`.
370    pub const fn satisfies(self, need: Durability) -> bool {
371        match need {
372            Durability::Ordered => !matches!(self, SyncGuarantee::None),
373            Durability::Durable => matches!(self, SyncGuarantee::Durable),
374        }
375    }
376}
377
378/// The temporary sibling [`Storage::write_atomic`]'s default protocol stages a
379/// write through before renaming it into place. A dotted, suffixed name in the
380/// target's own directory: dotted and suffixed so it reads as plainly prov's
381/// and will not collide with a real document, and a *sibling* so the rename that
382/// follows never crosses a filesystem boundary.
383/// The directory holding `path`, when there is one to name. `Path::parent`
384/// answers `Some("")` for a bare relative filename like `index.md` — the
385/// process's current directory, which prov neither holds a path to nor owns —
386/// and that empty path is not something a backend can open, so it is folded in
387/// with "no parent" here rather than at each call site.
388fn parent_dir(path: &Path) -> Option<&Path> {
389    path.parent().filter(|p| !p.as_os_str().is_empty())
390}
391
392fn temp_sibling(path: &Path) -> PathBuf {
393    let name = path
394        .file_name()
395        .and_then(|n| n.to_str())
396        .unwrap_or("document");
397    path.with_file_name(format!(".{name}.prov-tmp"))
398}
399
400impl Storage for StdFs {
401    async fn write(&self, path: &Path, contents: &[u8]) -> io::Result<()> {
402        std::fs::write(path, contents)
403    }
404
405    async fn create_dir_all(&self, path: &Path) -> io::Result<()> {
406        std::fs::create_dir_all(path)
407    }
408
409    async fn remove_file(&self, path: &Path) -> io::Result<()> {
410        std::fs::remove_file(path)
411    }
412
413    async fn remove_dir_all(&self, path: &Path) -> io::Result<()> {
414        std::fs::remove_dir_all(path)
415    }
416
417    async fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
418        std::fs::rename(from, to)
419    }
420
421    fn capabilities(&self) -> Capabilities {
422        // Every OS prov targets gives an atomic same-filesystem rename and an
423        // fsync. `std::fs::rename` replaces the destination on all of them —
424        // POSIX by definition, Windows via `MoveFileEx(MOVEFILE_REPLACE_EXISTING)`
425        // — so the write-temp-then-rename protocol in the default `write_atomic`
426        // is genuinely atomic here.
427        Capabilities::LOCAL_FS
428    }
429
430    async fn sync(&self, path: &Path, need: Durability) -> io::Result<()> {
431        // `fsync` is the only flush in the standard library, and it is the strong
432        // one — so both requests are answered with it. Answering `Ordered` more
433        // cheaply means a platform-specific primitive (`F_BARRIERFSYNC` on Apple,
434        // `sync_file_range` on Linux) and the `libc` dependency that comes with
435        // it; a port that wants the cheaper answer can wrap this one and say so
436        // in its own `capabilities`, which is exactly what `SyncGuarantee` is for.
437        let _ = need;
438        sync_path(path)
439    }
440}
441
442/// Flush exactly `path` — file or directory — so a preceding write or rename to
443/// it is durable. The one place a real OS difference lives, quarantined behind
444/// the port here rather than leaking up into the engine.
445fn sync_path(path: &Path) -> io::Result<()> {
446    // A fresh read handle is enough: fsync acts on the inode, not the descriptor,
447    // so it flushes writes made through any handle. A path that does not exist (a
448    // fallback write that failed before creating it) has nothing to flush and is
449    // not an error.
450    //
451    // Opening a *directory* for reading and fsyncing it — how
452    // [`Storage::write_atomic`] makes its rename durable — is a POSIX facility.
453    // Windows has no equivalent (`MoveFileEx`'s durability is a separate story),
454    // and rejects the open outright, so there the directory step is skipped
455    // rather than faked.
456    #[cfg(not(unix))]
457    if path.is_dir() {
458        return Ok(());
459    }
460    match std::fs::File::open(path) {
461        Ok(file) => file.sync_all()?,
462        Err(e) if e.kind() == io::ErrorKind::NotFound => {}
463        Err(e) => return Err(e),
464    }
465    Ok(())
466}
467
468#[cfg(test)]
469mod tests {
470    use prov_graph::exec::block_on;
471
472    use super::*;
473
474    fn tmp(name: &str) -> PathBuf {
475        let dir = std::env::temp_dir().join(format!("prov-fs-{name}-{}", std::process::id()));
476        let _ = std::fs::remove_dir_all(&dir);
477        std::fs::create_dir_all(&dir).unwrap();
478        dir
479    }
480
481    // ---- capability declaration ----
482
483    #[test]
484    fn stdfs_declares_the_local_filesystem_guarantees() {
485        // The native adapter promises atomic replacement and durable fsync, but
486        // not native transactions — the journal's job, not the filesystem's.
487        assert_eq!(StdFs.capabilities(), Capabilities::LOCAL_FS);
488        assert!(StdFs.capabilities().atomic_replace);
489        assert_eq!(StdFs.capabilities().sync_guarantee, SyncGuarantee::Durable);
490        assert!(!StdFs.capabilities().native_transactions);
491    }
492
493    #[test]
494    fn a_guarantee_answers_only_the_requests_it_can_keep() {
495        // The whole point of the three-valued guarantee: the middle one can serve
496        // `write_atomic`'s staging flush without being able to serve its final
497        // one, which a boolean had no way to say.
498        assert!(!SyncGuarantee::None.satisfies(Durability::Ordered));
499        assert!(!SyncGuarantee::None.satisfies(Durability::Durable));
500        assert!(SyncGuarantee::Ordered.satisfies(Durability::Ordered));
501        assert!(!SyncGuarantee::Ordered.satisfies(Durability::Durable));
502        assert!(SyncGuarantee::Durable.satisfies(Durability::Ordered));
503        assert!(SyncGuarantee::Durable.satisfies(Durability::Durable));
504    }
505
506    // ---- the atomic-write protocol ----
507
508    // ---- sync ----
509
510    #[test]
511    fn sync_of_a_missing_path_is_not_an_error() {
512        // A fallback write that failed before creating the file leaves nothing to
513        // flush; asking to sync it is a no-op, not a failure.
514        let root = tmp("sync-missing");
515        block_on(StdFs.sync(&root.join("never-created.md"), Durability::Durable)).unwrap();
516    }
517
518    #[test]
519    fn sync_flushes_a_directory_as_readily_as_a_file() {
520        // `write_atomic` makes its rename durable by syncing the directory, so a
521        // directory has to be something `sync` accepts rather than something it
522        // reaches only via a file's parent.
523        let root = tmp("sync-dir");
524        block_on(StdFs.sync(&root, Durability::Durable)).unwrap();
525    }
526}