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}