fs_transaction/fs.rs
1//! The filesystem port — the seam every transaction lands through.
2//!
3//! This crate is generic over *where* files live. Rather than depend on any one
4//! concrete backend — `std::fs`, `tokio::fs`, or a browser filesystem like
5//! OPFS/IndexedDB — it asks only for a small async trait that mirrors the slice
6//! of [`std::fs`] a transaction needs. Integrators implement it over whatever
7//! backend they have; [`ChangeSet`](crate::ChangeSet) never learns which one.
8//!
9//! This is the classic *ports and adapters* seam. The traits use native
10//! `async fn` (no boxed futures), so callers keep the backend's real future
11//! types and their `Send`-ness: a backend whose futures are `Send` composes into
12//! multithreaded runtimes unchanged, and one whose futures are not — a
13//! browser backend on a single-threaded executor — is not forced to pretend
14//! otherwise. The method set mirrors [`std::fs`] names exactly, so an adapter is
15//! mechanical to write.
16//!
17//! ## The read/write split
18//!
19//! [`ReadStorage`] is everything that cannot change a byte; [`Storage`] adds the
20//! writes, the mutations, and the durability vocabulary. The split is not
21//! decoration — a consumer generic over `ReadStorage` is a *provably* read-only
22//! consumer, checked by the compiler rather than by review. Only [`Storage`]
23//! can drive a transaction.
24//!
25//! ## Durability is declared, not assumed
26//!
27//! Backends keep very different crash promises: `std::fs` has atomic rename and
28//! `fsync` on every major OS, OPFS has a flush primitive but a weak rename,
29//! IndexedDB has its own multi-object transactions. Rather than assume the
30//! strongest and silently lie on the weakest, a backend *declares* what it can
31//! keep through [`Capabilities`], and the crash-safety machinery adapts. Every
32//! durability member defaults to the pessimistic answer, so an adapter that
33//! forgets to override one degrades to the most defensive path rather than to a
34//! false promise.
35
36use std::io;
37use std::path::{Path, PathBuf};
38use std::sync::Arc;
39use std::time::SystemTime;
40
41pub mod memory;
42
43pub use memory::InMemoryFs;
44
45/// The read half of an async filesystem backend: everything the traversal core
46/// needs, and nothing that can change a byte on disk.
47///
48/// The split from [`Storage`] is not decoration — it is what lets a tree be
49/// depended on by a consumer that must not, and cannot, write: a language
50/// server, a renderer, a browser viewer. A backend that implements only this
51/// is a *provably* read-only view, checked by the compiler rather than by
52/// review.
53///
54/// Each method mirrors the [`std::fs`] function of the same name.
55/// [`try_exists`] has a default in terms of [`metadata`].
56///
57/// [`try_exists`]: ReadStorage::try_exists
58/// [`metadata`]: ReadStorage::metadata
59pub trait ReadStorage {
60 /// Read the entire contents of a file as bytes. Mirrors [`std::fs::read`].
61 fn read(&self, path: &Path) -> impl Future<Output = io::Result<Vec<u8>>>;
62
63 /// Read the entire contents of a file as a string. Mirrors
64 /// [`std::fs::read_to_string`].
65 fn read_to_string(&self, path: &Path) -> impl Future<Output = io::Result<String>>;
66
67 /// Return the entries in a directory (non-recursive). Mirrors
68 /// [`std::fs::read_dir`], but yields a `Vec` since async iterators are not
69 /// yet stable.
70 fn read_dir(&self, path: &Path) -> impl Future<Output = io::Result<Vec<DirEntry>>>;
71
72 /// Return metadata about the entry at `path`. Mirrors
73 /// [`std::fs::metadata`]; follows symlinks.
74 fn metadata(&self, path: &Path) -> impl Future<Output = io::Result<Metadata>>;
75
76 /// Returns `Ok(true)` if the path exists, `Ok(false)` if it does not, and
77 /// `Err(_)` if the check itself failed. Mirrors `std::fs::try_exists`.
78 fn try_exists(&self, path: &Path) -> impl Future<Output = io::Result<bool>> {
79 async move {
80 match self.metadata(path).await {
81 Ok(_) => Ok(true),
82 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false),
83 Err(e) => Err(e),
84 }
85 }
86 }
87}
88
89/// A borrowed [`ReadStorage`] is itself a [`ReadStorage`] — so an owned backend
90/// can be lent to something generic over `S: ReadStorage` without moving it or
91/// wrapping it in an `Arc` the caller doesn't otherwise need.
92///
93/// Every member is forwarded explicitly. The matching [`Storage`] forwarding
94/// below does the same for the durability members, where leaving any to
95/// inherit the trait's defaults would silently downgrade a real backend's
96/// guarantees the moment it was borrowed.
97impl<S: ReadStorage + ?Sized> ReadStorage for &S {
98 async fn read(&self, path: &Path) -> io::Result<Vec<u8>> {
99 (**self).read(path).await
100 }
101
102 async fn read_to_string(&self, path: &Path) -> io::Result<String> {
103 (**self).read_to_string(path).await
104 }
105
106 async fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
107 (**self).read_dir(path).await
108 }
109
110 async fn metadata(&self, path: &Path) -> io::Result<Metadata> {
111 (**self).metadata(path).await
112 }
113
114 async fn try_exists(&self, path: &Path) -> io::Result<bool> {
115 (**self).try_exists(path).await
116 }
117}
118
119/// An `Arc<S>` is itself a [`ReadStorage`] on the same terms as `&S` above — so
120/// a backend shared across several owners (several open handles, a
121/// multi-tab web client) still carries its real capabilities through the
122/// `Arc`, rather than an adapter that forgot to unwrap it silently degrading
123/// to the pessimistic defaults.
124///
125/// `Arc<S>` derefs to `S` exactly like `&S` does, so the same explicit,
126/// every-member forwarding applies for the same reason: the trait's defaults
127/// must never be reached by accident.
128impl<S: ReadStorage + ?Sized> ReadStorage for Arc<S> {
129 async fn read(&self, path: &Path) -> io::Result<Vec<u8>> {
130 (**self).read(path).await
131 }
132
133 async fn read_to_string(&self, path: &Path) -> io::Result<String> {
134 (**self).read_to_string(path).await
135 }
136
137 async fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
138 (**self).read_dir(path).await
139 }
140
141 async fn metadata(&self, path: &Path) -> io::Result<Metadata> {
142 (**self).metadata(path).await
143 }
144
145 async fn try_exists(&self, path: &Path) -> io::Result<bool> {
146 (**self).try_exists(path).await
147 }
148}
149
150/// One entry returned by [`ReadStorage::read_dir`].
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct DirEntry {
153 path: PathBuf,
154 file_type: FileType,
155}
156
157impl DirEntry {
158 /// Construct an entry from its path and type.
159 pub fn new(path: impl Into<PathBuf>, file_type: FileType) -> Self {
160 Self {
161 path: path.into(),
162 file_type,
163 }
164 }
165
166 /// The full path to the entry.
167 pub fn path(&self) -> &Path {
168 &self.path
169 }
170
171 /// The final component of the entry's path.
172 pub fn file_name(&self) -> Option<&std::ffi::OsStr> {
173 self.path.file_name()
174 }
175
176 /// The entry's type.
177 pub fn file_type(&self) -> FileType {
178 self.file_type
179 }
180}
181
182/// Metadata about a filesystem entry — the subset a transaction needs.
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub struct Metadata {
185 file_type: FileType,
186 len: u64,
187 modified: Option<SystemTime>,
188}
189
190impl Metadata {
191 /// Construct metadata from its parts.
192 pub fn new(file_type: FileType, len: u64, modified: Option<SystemTime>) -> Self {
193 Self {
194 file_type,
195 len,
196 modified,
197 }
198 }
199
200 /// The entry's type.
201 pub fn file_type(&self) -> FileType {
202 self.file_type
203 }
204
205 /// Whether the entry is a regular file.
206 pub fn is_file(&self) -> bool {
207 self.file_type.is_file()
208 }
209
210 /// Whether the entry is a directory.
211 pub fn is_dir(&self) -> bool {
212 self.file_type.is_dir()
213 }
214
215 /// Size in bytes.
216 pub fn len(&self) -> u64 {
217 self.len
218 }
219
220 /// Whether the entry is empty.
221 pub fn is_empty(&self) -> bool {
222 self.len == 0
223 }
224
225 /// Last-modified time, if the backend reports one. Mirrors
226 /// [`std::fs::Metadata::modified`], returning [`io::ErrorKind::Unsupported`]
227 /// when unavailable.
228 pub fn modified(&self) -> io::Result<SystemTime> {
229 self.modified
230 .ok_or_else(|| io::Error::new(io::ErrorKind::Unsupported, "modified time unavailable"))
231 }
232}
233
234/// [`ReadStorage`] over the process filesystem (`std::fs`).
235///
236/// The reference adapter, and the one [`ChangeSet`](crate::ChangeSet) is
237/// tuned for: it implements [`Storage`] too, and reports
238/// [`Capabilities::LOCAL_FS`].
239///
240/// The traits are async so that genuinely async backends (network, OPFS) fit;
241/// this adapter's futures are immediately ready, so any executor — including
242/// the dependency-free [`crate::exec::block_on`] — drives them to completion
243/// in a single poll.
244#[derive(Debug, Clone, Copy, Default)]
245pub struct StdFs;
246
247impl ReadStorage for StdFs {
248 async fn read(&self, path: &Path) -> io::Result<Vec<u8>> {
249 std::fs::read(path)
250 }
251
252 async fn read_to_string(&self, path: &Path) -> io::Result<String> {
253 std::fs::read_to_string(path)
254 }
255
256 async fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
257 std::fs::read_dir(path)?
258 .map(|entry| {
259 let entry = entry?;
260 Ok(DirEntry::new(
261 entry.path(),
262 convert_file_type(entry.file_type()?),
263 ))
264 })
265 .collect()
266 }
267
268 async fn metadata(&self, path: &Path) -> io::Result<Metadata> {
269 let md = std::fs::metadata(path)?;
270 Ok(Metadata::new(
271 convert_file_type(md.file_type()),
272 md.len(),
273 md.modified().ok(),
274 ))
275 }
276}
277
278fn convert_file_type(ft: std::fs::FileType) -> FileType {
279 if ft.is_dir() {
280 FileType::DIR
281 } else if ft.is_file() {
282 FileType::FILE
283 } else {
284 FileType::SYMLINK
285 }
286}
287
288/// The type of a filesystem entry.
289#[derive(Debug, Clone, Copy, PartialEq, Eq)]
290pub struct FileType {
291 is_dir: bool,
292 is_file: bool,
293 is_symlink: bool,
294}
295
296impl FileType {
297 /// A regular file.
298 pub const FILE: FileType = FileType {
299 is_dir: false,
300 is_file: true,
301 is_symlink: false,
302 };
303
304 /// A directory.
305 pub const DIR: FileType = FileType {
306 is_dir: true,
307 is_file: false,
308 is_symlink: false,
309 };
310
311 /// A symbolic link.
312 pub const SYMLINK: FileType = FileType {
313 is_dir: false,
314 is_file: false,
315 is_symlink: true,
316 };
317
318 /// Whether this is a regular file.
319 pub fn is_file(&self) -> bool {
320 self.is_file
321 }
322
323 /// Whether this is a directory.
324 pub fn is_dir(&self) -> bool {
325 self.is_dir
326 }
327
328 /// Whether this is a symbolic link.
329 pub fn is_symlink(&self) -> bool {
330 self.is_symlink
331 }
332}
333
334/// An async filesystem backend a transaction can drive — [`ReadStorage`] plus everything
335/// that changes bytes on disk.
336///
337/// Each method mirrors the [`std::fs`] function of the same name. Backends
338/// implement the write/mutate/durability surface here and the read surface on
339/// [`ReadStorage`].
340pub trait Storage: ReadStorage {
341 // ---- write ----
342
343 /// Write a file, replacing it if it already exists. Mirrors
344 /// [`std::fs::write`].
345 fn write(&self, path: &Path, contents: &[u8]) -> impl Future<Output = io::Result<()>>;
346
347 /// Create a directory and all missing parents. Mirrors
348 /// [`std::fs::create_dir_all`].
349 fn create_dir_all(&self, path: &Path) -> impl Future<Output = io::Result<()>>;
350
351 // ---- mutate ----
352
353 /// Remove a regular file. Mirrors [`std::fs::remove_file`].
354 fn remove_file(&self, path: &Path) -> impl Future<Output = io::Result<()>>;
355
356 /// Recursively remove a directory and its contents. Mirrors
357 /// [`std::fs::remove_dir_all`].
358 fn remove_dir_all(&self, path: &Path) -> impl Future<Output = io::Result<()>>;
359
360 /// Rename or move a file or directory. Mirrors [`std::fs::rename`].
361 fn rename(&self, from: &Path, to: &Path) -> impl Future<Output = io::Result<()>>;
362
363 /// Give `to` the same access permissions `from` has. A `from` that does not
364 /// exist is not an error — there is no prior state to carry over, so the
365 /// call has nothing to do.
366 ///
367 /// This exists for [`write_atomic`](Storage::write_atomic), which publishes
368 /// its bytes by renaming a freshly-created sibling over the target. A new
369 /// file is born with the backend's default permissions, and a rename carries
370 /// those onto the name it replaces — so without this step, replacing a
371 /// document the user had deliberately restricted (`chmod 600` on a private
372 /// journal entry) silently widens it to whatever the umask allows. A
373 /// content replacement must not be a permission change.
374 ///
375 /// What this does *not* close is the window before it: the sibling holds the
376 /// new contents under default permissions from the moment it is written
377 /// until this call narrows it. Shutting that window means creating the file
378 /// with the final mode already on it, which is not something
379 /// [`write`](Storage::write) — a `std::fs::write` mirror — can express. The
380 /// sibling lives in the target's own directory throughout, so whatever gates
381 /// access to the document gates access to it too.
382 ///
383 /// The default is a no-op, which is the *correct* behavior for a backend
384 /// with no permission model at all — [`InMemoryFs`], OPFS, IndexedDB. There
385 /// is nothing there to preserve, and nothing is lost by not preserving it.
386 fn copy_permissions(&self, from: &Path, to: &Path) -> impl Future<Output = io::Result<()>> {
387 async move {
388 let _ = (from, to);
389 Ok(())
390 }
391 }
392
393 // ---- durability ----
394 //
395 // This crate spans backends with very different crash guarantees — `std::fs`
396 // (atomic rename and fsync on every major OS), OPFS (a flush primitive but a
397 // weak rename), IndexedDB (its own multi-object transactions). Rather than
398 // assume the strongest of these and silently lie on the weakest, the crash-
399 // safety machinery *asks* what a backend can promise and adapts. These three
400 // members are defaulted to the pessimistic answer, so a backend gains a
401 // guarantee only by explicitly claiming it.
402
403 /// What durability guarantees this backend can make. Defaults to
404 /// [`Capabilities::NONE`] — a backend promises a guarantee only by saying so,
405 /// so an adapter that forgets to override this degrades to the most defensive
406 /// path rather than to a false promise.
407 fn capabilities(&self) -> Capabilities {
408 Capabilities::NONE
409 }
410
411 /// Flush `path` — and nothing else — to the strength `need` asks for.
412 ///
413 /// `path` names *one* object, and only that object is flushed. To make a
414 /// directory entry durable (the naming half of a create or a rename), sync
415 /// the directory itself: on a POSIX filesystem a directory is a thing that
416 /// can be opened and fsynced, and this crate's own
417 /// [`write_atomic`](Storage::write_atomic) does exactly that after its
418 /// rename. Folding the parent into every call instead would flush twice as
419 /// much as any single step needs, and would leave the caller unable to say
420 /// which of the two it actually meant.
421 ///
422 /// `need` is the *weakest* guarantee that is still correct at the call site,
423 /// not a wish. [`Durability::Ordered`] asks only that everything written to
424 /// `path` before this call land before anything written after it — enough to
425 /// stop a rename overtaking the bytes it publishes, and on some platforms far
426 /// cheaper than the real thing. [`Durability::Durable`] asks that the bytes
427 /// survive power loss. A backend may always answer with something stronger
428 /// than it was asked for; it may never answer with something weaker.
429 ///
430 /// The default is a no-op, which is the *correct* behavior for any backend
431 /// whose [`capabilities`](Storage::capabilities) report
432 /// [`SyncGuarantee::None`]: it cannot make the promise, so it must not
433 /// pretend to. A backend that can flush must both override this and report
434 /// the strongest request it genuinely honors — the two always travel
435 /// together, and [`SyncGuarantee::satisfies`] is how a caller asks.
436 fn sync(&self, path: &Path, need: Durability) -> impl Future<Output = io::Result<()>> {
437 async move {
438 let _ = (path, need);
439 Ok(())
440 }
441 }
442
443 /// Replace `path`'s contents with `contents` atomically and durably: no
444 /// observer — concurrent reader or post-crash survivor — ever sees a splice
445 /// of old and new bytes, and once this returns the new contents outlive a
446 /// power loss.
447 ///
448 /// The default composes the primitives into the standard protocol, whenever
449 /// [`capabilities`](Storage::capabilities) report `atomic_replace`:
450 ///
451 /// 1. write the bytes to a temporary sibling;
452 /// 2. [`sync`](Storage::sync) that sibling [`Ordered`](Durability::Ordered),
453 /// so the rename cannot be reordered ahead of the bytes it publishes;
454 /// 3. [`copy_permissions`](Storage::copy_permissions) from the target onto
455 /// that sibling, so the replacement carries the target's access
456 /// permissions rather than a fresh file's defaults;
457 /// 4. [`rename`](Storage::rename) it over the target — *this* is the atomic
458 /// instant;
459 /// 5. `sync` the target's **parent directory** [`Durable`](Durability::Durable),
460 /// which is what carries the rename itself through a power cut.
461 ///
462 /// Steps 2 and 3 are in that order because a backend may implement `sync` by
463 /// opening the path, and a mode faithfully copied from the target can be one
464 /// that forbids opening it to read — `0o200` is replaceable but not readable.
465 /// The cost is that the mode change lands after the flush and so is not
466 /// itself durable: a crash in that window can leave the new contents under
467 /// the *default* permissions. That is precisely the outcome every write had
468 /// before step 3 existed, so the window is a smaller bad case, never a new
469 /// one.
470 ///
471 /// Two flushes, and each one is load-bearing. Neither of the two this
472 /// protocol conspicuously does *not* do would buy anything. The bytes are
473 /// never flushed under their final name, because a rename does not move an
474 /// inode: the file the target now names is the very one step 2 flushed, and
475 /// nothing has been written to it since. The sibling's own directory entry is
476 /// never flushed either, because nobody is owed a temporary that survives a
477 /// crash — only the directory state *after* the rename is worth a barrier.
478 ///
479 /// A backend that cannot rename atomically falls back to a plain durable
480 /// write, which is *not* crash-atomic; a caller that needs the guarantee
481 /// consults `capabilities` and leans on the journal instead of pretending
482 /// this call gave it. A backend with a better native path — a transactional
483 /// store — overrides this method wholesale.
484 ///
485 /// The temporary is removed on any failure, so a torn attempt leaves the
486 /// target exactly as it was and no litter behind. It is a dotted sibling in
487 /// the target's own directory, so the follow-up rename stays within one
488 /// filesystem (a cross-device rename is neither atomic nor, often, even
489 /// permitted).
490 fn write_atomic(&self, path: &Path, contents: &[u8]) -> impl Future<Output = io::Result<()>> {
491 async move {
492 if !self.capabilities().atomic_replace {
493 // No atomic rename to lean on: the honest best effort is a plain
494 // durable write. Not crash-atomic — and the caller was told so by
495 // `capabilities`, so this is a documented degrade, not a lie.
496 // Both the bytes and, if this call created the file, the entry
497 // naming them have to be flushed; there is no rename here to fold
498 // the second into.
499 self.write(path, contents).await?;
500 self.sync(path, Durability::Durable).await?;
501 return match parent_dir(path) {
502 Some(dir) => self.sync(dir, Durability::Durable).await,
503 None => Ok(()),
504 };
505 }
506 let tmp = temp_sibling(path);
507 // Any failure past this point must not leave the staging file behind,
508 // and must never have touched the target — hence the whole dance
509 // happens on `tmp` and only the rename names `path`.
510 let staged = async {
511 self.write(&tmp, contents).await?;
512 self.sync(&tmp, Durability::Ordered).await?;
513 // The sibling was just created, so it carries default
514 // permissions rather than the target's. Carry the target's over
515 // before the rename publishes them — a replacement changes
516 // contents, never who may read them. A target that does not
517 // exist yet has nothing to carry, and this is a no-op.
518 //
519 // After the flush, not before: a backend may well implement
520 // `sync` by opening the path (`StdFs` does), and a target whose
521 // mode this faithfully copies can be one that forbids exactly
522 // that — a write-only `0o200` document is replaceable but not
523 // openable for reading. Narrowing the sibling first would make
524 // its own flush fail.
525 self.copy_permissions(path, &tmp).await?;
526 self.rename(&tmp, path).await
527 }
528 .await;
529 match staged {
530 // The bytes are already flushed and the rename has happened, so
531 // the directory entry is the last thing standing between this
532 // write and a power cut.
533 Ok(()) => match parent_dir(path) {
534 Some(dir) => self.sync(dir, Durability::Durable).await,
535 // A bare relative filename, whose directory is the process's
536 // current one — not a path this crate holds, nor one it owns.
537 None => Ok(()),
538 },
539 Err(e) => {
540 // Best-effort cleanup: if even this fails the target is still
541 // untouched, so the atomicity promise holds regardless — the
542 // worst case is one stray dotfile, not a torn document.
543 let _ = self.remove_file(&tmp).await;
544 Err(e)
545 }
546 }
547 }
548 }
549}
550
551impl<S: Storage + ?Sized> Storage for &S {
552 async fn write(&self, path: &Path, contents: &[u8]) -> io::Result<()> {
553 (**self).write(path, contents).await
554 }
555
556 async fn create_dir_all(&self, path: &Path) -> io::Result<()> {
557 (**self).create_dir_all(path).await
558 }
559
560 async fn remove_file(&self, path: &Path) -> io::Result<()> {
561 (**self).remove_file(path).await
562 }
563
564 async fn remove_dir_all(&self, path: &Path) -> io::Result<()> {
565 (**self).remove_dir_all(path).await
566 }
567
568 async fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
569 (**self).rename(from, to).await
570 }
571
572 async fn copy_permissions(&self, from: &Path, to: &Path) -> io::Result<()> {
573 (**self).copy_permissions(from, to).await
574 }
575
576 fn capabilities(&self) -> Capabilities {
577 (**self).capabilities()
578 }
579
580 async fn sync(&self, path: &Path, need: Durability) -> io::Result<()> {
581 (**self).sync(path, need).await
582 }
583
584 async fn write_atomic(&self, path: &Path, contents: &[u8]) -> io::Result<()> {
585 (**self).write_atomic(path, contents).await
586 }
587}
588
589impl<S: Storage + ?Sized> Storage for Arc<S> {
590 async fn write(&self, path: &Path, contents: &[u8]) -> io::Result<()> {
591 (**self).write(path, contents).await
592 }
593
594 async fn create_dir_all(&self, path: &Path) -> io::Result<()> {
595 (**self).create_dir_all(path).await
596 }
597
598 async fn remove_file(&self, path: &Path) -> io::Result<()> {
599 (**self).remove_file(path).await
600 }
601
602 async fn remove_dir_all(&self, path: &Path) -> io::Result<()> {
603 (**self).remove_dir_all(path).await
604 }
605
606 async fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
607 (**self).rename(from, to).await
608 }
609
610 async fn copy_permissions(&self, from: &Path, to: &Path) -> io::Result<()> {
611 (**self).copy_permissions(from, to).await
612 }
613
614 fn capabilities(&self) -> Capabilities {
615 (**self).capabilities()
616 }
617
618 async fn sync(&self, path: &Path, need: Durability) -> io::Result<()> {
619 (**self).sync(path, need).await
620 }
621
622 async fn write_atomic(&self, path: &Path, contents: &[u8]) -> io::Result<()> {
623 (**self).write_atomic(path, contents).await
624 }
625}
626
627/// The durability guarantees a [`Storage`] backend can make — declared by the
628/// backend through [`Storage::capabilities`], honored by the crash-safety
629/// machinery in [`ChangeSet`](crate::ChangeSet).
630///
631/// The point of naming these explicitly is that a transaction must run correctly
632/// over backends that keep very different promises. Rather than assume a
633/// guarantee and corrupt data on the backend that cannot keep it, the apply path
634/// reads the capabilities and picks the strongest *protocol the backend actually
635/// supports*:
636/// a filesystem gets atomic-rename writes and a journal; a transactional store is
637/// handed the whole change set to commit itself; a backend that can promise
638/// neither still works, it simply cannot claim a write survives a crash.
639#[derive(Debug, Clone, Copy, PartialEq, Eq)]
640pub struct Capabilities {
641 /// The backend can replace an existing file's contents in one indivisible
642 /// step, so no crash exposes a half-written file — an observer sees the whole
643 /// old contents or the whole new. On a filesystem this is realized by
644 /// [`Storage::write_atomic`]'s write-temp-then-`rename`; a backend may
645 /// instead be atomic by nature.
646 pub atomic_replace: bool,
647
648 /// How strong the backend's [`Storage::sync`] is: whether it can flush at
649 /// all, and if so whether a flush merely orders writes or carries them
650 /// through a power cut. `fsync` on `std::fs`, `FileSystemSyncAccessHandle
651 /// .flush()` on OPFS, the implicit durability of a committed IndexedDB
652 /// transaction.
653 pub sync_guarantee: SyncGuarantee,
654
655 /// The backend commits changes to *many* objects as one indivisible unit, so
656 /// this crate's write-ahead journal would be redundant and a caller should
657 /// defer to the backend instead. True for IndexedDB; false for a plain
658 /// filesystem, where multi-file atomicity is the journal's job to provide.
659 pub native_transactions: bool,
660}
661
662impl Capabilities {
663 /// Promises nothing — the safe assumption for an unknown backend, and the
664 /// [`Storage::capabilities`] default. Every field is the pessimistic value,
665 /// so code that checks a capability before relying on it takes the most
666 /// defensive branch unless a backend has explicitly earned a lighter one.
667 pub const NONE: Self = Self {
668 atomic_replace: false,
669 sync_guarantee: SyncGuarantee::None,
670 native_transactions: false,
671 };
672
673 /// A conventional local filesystem: atomic replacement by rename and durable
674 /// fsync, but no native multi-object transaction (that is the journal's job).
675 /// What [`StdFs`] reports on every platform this crate targets.
676 pub const LOCAL_FS: Self = Self {
677 atomic_replace: true,
678 sync_guarantee: SyncGuarantee::Durable,
679 native_transactions: false,
680 };
681
682 /// An in-process, memory-only store ([`InMemoryFs`]): every mutation takes
683 /// the backend's single lock for its whole duration, so one write already
684 /// swaps old bytes for new as one indivisible step — no separate
685 /// temp-then-rename dance is needed for `atomic_replace` to be true. But
686 /// nothing here is backed by anything other than process memory, so its
687 /// `sync_guarantee` is [`SyncGuarantee::None`]: there is nothing to flush,
688 /// and the entire store evaporates the instant the process exits — it cannot
689 /// even promise ordering against a crash it will not survive.
690 /// `native_transactions`
691 /// is false too — the lock makes each *single* call atomic, not a batch of
692 /// several calls committed together, so a multi-file change set still
693 /// needs the write-ahead journal over this backend exactly as it would over
694 /// a real filesystem.
695 pub const IN_MEMORY: Self = Self {
696 atomic_replace: true,
697 sync_guarantee: SyncGuarantee::None,
698 native_transactions: false,
699 };
700}
701
702/// What a caller needs from one [`Storage::sync`] call — the *weakest* guarantee
703/// that is still correct at that point, so that a backend able to serve it
704/// cheaply is free to.
705#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
706pub enum Durability {
707 /// Everything written to the path before this call must land before anything
708 /// written after it. It says nothing about *when*: a crash may still lose
709 /// the lot, only never a suffix without its prefix. This is all
710 /// [`Storage::write_atomic`] needs from its staging flush — the rename must
711 /// not be seen before the bytes it publishes — and on Apple platforms it is
712 /// the difference between a barrier and draining the drive's write cache.
713 Ordered,
714 /// Once the call returns, the bytes survive power loss.
715 Durable,
716}
717
718/// How strong a backend's [`Storage::sync`] actually is — the standing answer to
719/// a [`Durability`] request, declared once in [`Capabilities`] rather than
720/// discovered per call.
721///
722/// Deliberately three-valued rather than the "can this backend flush?" boolean
723/// it replaces, because that question has a common and useful middle answer it
724/// could not express: a backend that orders writes against each other without
725/// paying for a device-wide cache drain. Offered only `true` and `false`, such a
726/// backend has to either overstate — claiming a durability it does not deliver —
727/// or understate, claiming it cannot flush at all when ordering is precisely
728/// what [`Storage::write_atomic`] asks it for.
729#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
730pub enum SyncGuarantee {
731 /// `sync` does nothing: an in-memory store, or a port with no flush
732 /// primitive under it to call.
733 None,
734 /// `sync` orders writes against each other, but does not promise any of them
735 /// outlives a power cut.
736 Ordered,
737 /// `sync` flushes through to durable storage.
738 Durable,
739}
740
741impl SyncGuarantee {
742 /// Whether a backend making this guarantee can honor `need`.
743 pub const fn satisfies(self, need: Durability) -> bool {
744 match need {
745 Durability::Ordered => !matches!(self, SyncGuarantee::None),
746 Durability::Durable => matches!(self, SyncGuarantee::Durable),
747 }
748 }
749}
750
751/// The directory holding `path`, when there is one to name. `Path::parent`
752/// answers `Some("")` for a bare relative filename like `index.md` — the
753/// process's current directory, which this crate neither holds a path to nor owns —
754/// and that empty path is not something a backend can open, so it is folded in
755/// with "no parent" here rather than at each call site.
756fn parent_dir(path: &Path) -> Option<&Path> {
757 path.parent().filter(|p| !p.as_os_str().is_empty())
758}
759
760/// The temporary sibling [`Storage::write_atomic`]'s default protocol stages a
761/// write through before renaming it into place. A dotted, suffixed name in the
762/// target's own directory: dotted and suffixed so it will not collide with a
763/// real file, and a *sibling* so the rename that follows never crosses a
764/// filesystem boundary.
765fn temp_sibling(path: &Path) -> PathBuf {
766 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("file");
767 path.with_file_name(format!(".{name}.fstx-tmp"))
768}
769
770impl Storage for StdFs {
771 async fn write(&self, path: &Path, contents: &[u8]) -> io::Result<()> {
772 std::fs::write(path, contents)
773 }
774
775 async fn create_dir_all(&self, path: &Path) -> io::Result<()> {
776 std::fs::create_dir_all(path)
777 }
778
779 async fn remove_file(&self, path: &Path) -> io::Result<()> {
780 std::fs::remove_file(path)
781 }
782
783 async fn remove_dir_all(&self, path: &Path) -> io::Result<()> {
784 std::fs::remove_dir_all(path)
785 }
786
787 async fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
788 std::fs::rename(from, to)
789 }
790
791 async fn copy_permissions(&self, from: &Path, to: &Path) -> io::Result<()> {
792 let perms = match std::fs::metadata(from) {
793 Ok(meta) => meta.permissions(),
794 // Nothing to carry over: `write_atomic` is creating `from` rather
795 // than replacing it, so the new file's default permissions are the
796 // right ones and there is no prior state to lose.
797 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
798 Err(e) => return Err(e),
799 };
800 // Deliberately best-effort. On a filesystem with no permission model —
801 // exFAT or FAT32 on a USB stick, some FUSE mounts — `chmod` refuses
802 // outright, but every file there already reports the same mount-wide
803 // mode, so there was never a permission to preserve and failing the
804 // whole document write over it would be absurd. Where modes *are* real,
805 // this is a chmod on a file this process created moments ago and owns,
806 // which does not fail for any reason a caller could act on.
807 let _ = std::fs::set_permissions(to, perms);
808 Ok(())
809 }
810
811 fn capabilities(&self) -> Capabilities {
812 // Every OS this crate targets gives an atomic same-filesystem rename and an
813 // fsync. `std::fs::rename` replaces the destination on all of them —
814 // POSIX by definition, Windows via `MoveFileEx(MOVEFILE_REPLACE_EXISTING)`
815 // — so the write-temp-then-rename protocol in the default `write_atomic`
816 // is genuinely atomic here.
817 Capabilities::LOCAL_FS
818 }
819
820 async fn sync(&self, path: &Path, need: Durability) -> io::Result<()> {
821 // `fsync` is the only flush in the standard library, and it is the strong
822 // one — so both requests are answered with it. Answering `Ordered` more
823 // cheaply means a platform-specific primitive (`F_BARRIERFSYNC` on Apple,
824 // `sync_file_range` on Linux) and the `libc` dependency that comes with
825 // it; a port that wants the cheaper answer can wrap this one and say so
826 // in its own `capabilities`, which is exactly what `SyncGuarantee` is for.
827 let _ = need;
828 sync_path(path)
829 }
830}
831
832/// Flush exactly `path` — file or directory — so a preceding write or rename to
833/// it is durable. The one place a real OS difference lives, quarantined behind
834/// the port here rather than leaking up into the engine.
835fn sync_path(path: &Path) -> io::Result<()> {
836 // A fresh read handle is enough: fsync acts on the inode, not the descriptor,
837 // so it flushes writes made through any handle. A path that does not exist (a
838 // fallback write that failed before creating it) has nothing to flush and is
839 // not an error.
840 //
841 // Opening a *directory* for reading and fsyncing it — how
842 // [`Storage::write_atomic`] makes its rename durable — is a POSIX facility.
843 // Windows has no equivalent (`MoveFileEx`'s durability is a separate story),
844 // and rejects the open outright, so there the directory step is skipped
845 // rather than faked.
846 #[cfg(not(unix))]
847 if path.is_dir() {
848 return Ok(());
849 }
850 match std::fs::File::open(path) {
851 Ok(file) => file.sync_all()?,
852 Err(e) if e.kind() == io::ErrorKind::NotFound => {}
853 Err(e) => return Err(e),
854 }
855 Ok(())
856}
857
858#[cfg(test)]
859mod tests {
860 use crate::exec::block_on;
861
862 use super::*;
863
864 fn tmp(name: &str) -> PathBuf {
865 let dir = std::env::temp_dir().join(format!("fstx-fs-{name}-{}", std::process::id()));
866 let _ = std::fs::remove_dir_all(&dir);
867 std::fs::create_dir_all(&dir).unwrap();
868 dir
869 }
870
871 // ---- capability declaration ----
872
873 #[test]
874 fn stdfs_declares_the_local_filesystem_guarantees() {
875 // The native adapter promises atomic replacement and durable fsync, but
876 // not native transactions — the journal's job, not the filesystem's.
877 assert_eq!(StdFs.capabilities(), Capabilities::LOCAL_FS);
878 assert!(StdFs.capabilities().atomic_replace);
879 assert_eq!(StdFs.capabilities().sync_guarantee, SyncGuarantee::Durable);
880 assert!(!StdFs.capabilities().native_transactions);
881 }
882
883 #[test]
884 fn a_guarantee_answers_only_the_requests_it_can_keep() {
885 // The whole point of the three-valued guarantee: the middle one can serve
886 // `write_atomic`'s staging flush without being able to serve its final
887 // one, which a boolean had no way to say.
888 assert!(!SyncGuarantee::None.satisfies(Durability::Ordered));
889 assert!(!SyncGuarantee::None.satisfies(Durability::Durable));
890 assert!(SyncGuarantee::Ordered.satisfies(Durability::Ordered));
891 assert!(!SyncGuarantee::Ordered.satisfies(Durability::Durable));
892 assert!(SyncGuarantee::Durable.satisfies(Durability::Ordered));
893 assert!(SyncGuarantee::Durable.satisfies(Durability::Durable));
894 }
895
896 // ---- the atomic-write protocol ----
897
898 // ---- sync ----
899
900 #[test]
901 fn sync_of_a_missing_path_is_not_an_error() {
902 // A fallback write that failed before creating the file leaves nothing to
903 // flush; asking to sync it is a no-op, not a failure.
904 let root = tmp("sync-missing");
905 block_on(StdFs.sync(&root.join("never-created.md"), Durability::Durable)).unwrap();
906 }
907
908 #[test]
909 fn sync_flushes_a_directory_as_readily_as_a_file() {
910 // `write_atomic` makes its rename durable by syncing the directory, so a
911 // directory has to be something `sync` accepts rather than something it
912 // reaches only via a file's parent.
913 let root = tmp("sync-dir");
914 block_on(StdFs.sync(&root, Durability::Durable)).unwrap();
915 }
916}