Skip to main content

blockworx_opfs/
lib.rs

1//! A `.bwx` container in origin-private storage.
2//!
3//! The container's layout — which file, in what order, written before what
4//! — is [`blockworx_store`]'s and is not repeated here. What this crate
5//! answers is the trait under it: where the bytes of one container go in a
6//! browser, and who holds the right to write them.
7//!
8//! Two values, and the [`Root`] is where a session starts:
9//!
10//! ```text
11//! navigator.storage.getDirectory()   Root        the origin's own directory
12//!   doc.bwx/                         Opfs        one container's storage
13//!     manifest.jsonl  revs/  assets/ Entry       what the store names
14//! ```
15//!
16//! **Everything here really is asynchronous.** Origin-private storage on
17//! the main thread is promises all the way down, so [`Opfs::residency`] is
18//! [`Residency::Resident`]: the store reads the container in when it opens
19//! it and owes its writes back
20//! ([`Container::drain`](blockworx_store::container::Container::drain)),
21//! and nothing may resolve a future here by polling it.
22//!
23//! **What a browser has to be given room for.** Only Chromium has
24//! `FileSystemHandle.move()`, so a rename copies and removes rather than
25//! moving. Safari has no `createWritable` outside a worker, which is what
26//! every write here is made of, so this storage is Chromium and Firefox
27//! for now; Safari's way in is the kernel in a worker, over
28//! `createSyncAccessHandle`. The lock is the origin's lock manager rather than a `lock`
29//! entry, because nothing in a browser can be asked whether the tab that
30//! wrote an entry is still open — see [`locks`].
31
32pub mod directory;
33pub mod fault;
34pub mod file;
35pub mod locks;
36
37use std::cell::RefCell;
38
39use wasm_bindgen::JsCast as _;
40use web_sys::FileSystemDirectoryHandle;
41
42use blockworx_store::container::MANIFEST;
43use blockworx_store::lock::{Claim, Holding};
44use blockworx_store::record::WallTime;
45use blockworx_store::storage::{Entry, Name, Residency, Storage};
46
47use crate::directory::Depth;
48use crate::fault::{mistaken, settled};
49
50pub use crate::directory::Missing;
51
52/// The origin's private directory: where a browser keeps this app's
53/// containers, and the only place it keeps them.
54#[derive(Clone)]
55pub struct Root(FileSystemDirectoryHandle);
56
57impl Root {
58    /// # Errors
59    /// A page with no origin-private storage — which is every page that is
60    /// not a secure context.
61    pub async fn open() -> std::io::Result<Self> {
62        let origin = web_sys::window()
63            .ok_or_else(|| {
64                std::io::Error::other(
65                    "origin-private storage is reached from a page, and there is none",
66                )
67            })?
68            .navigator()
69            .storage();
70        let root = settled(origin.get_directory()).await?;
71        Ok(Self(
72            root.dyn_into().map_err(mistaken("the origin's root"))?,
73        ))
74    }
75
76    /// The container called `name`, made if it is not there and `missing`
77    /// says so.
78    ///
79    /// A directory, not a document: whether what stands there is a
80    /// container is what [`Store::opening`](blockworx_store::handle::Store)
81    /// answers.
82    ///
83    /// # Errors
84    /// [`std::io::ErrorKind::NotFound`] for a name nothing stands under and
85    /// nothing is to be made, or the browser's refusal.
86    pub async fn container(&self, name: &Name, missing: Missing) -> std::io::Result<Opfs> {
87        Ok(Opfs {
88            dir: directory::directory(&self.0, name.as_str(), missing).await?,
89            root: self.0.clone(),
90            name: name.clone(),
91            held: RefCell::default(),
92        })
93    }
94
95    /// Every container in the origin, by name and in name order — the
96    /// library's list.
97    ///
98    /// A directory holding a manifest is what counts as one, so a folder
99    /// something else in the origin made is not offered as a document.
100    ///
101    /// # Errors
102    /// The browser's refusal.
103    pub async fn containers(&self) -> std::io::Result<Vec<Name>> {
104        let mut containers = Vec::new();
105        for held in directory::directories(&self.0).await? {
106            let Some(name) = Name::new(&held) else {
107                continue;
108            };
109            let dir = directory::directory(&self.0, name.as_str(), Missing::IsNothing).await?;
110            if directory::holds(&dir, MANIFEST.as_str()).await? {
111                containers.push(name);
112            }
113        }
114        containers.sort();
115        Ok(containers)
116    }
117
118    /// Whether anything already stands under `name` — what a new document
119    /// and a rename each ask before they take one.
120    ///
121    /// # Errors
122    /// The browser's refusal.
123    pub async fn holds(&self, name: &Name) -> std::io::Result<bool> {
124        directory::holds(&self.0, name.as_str()).await
125    }
126
127    /// Remove a container and everything in it — the library's delete,
128    /// where [`Opfs::discard`] is the store's own and takes only an
129    /// emptied one.
130    ///
131    /// # Errors
132    /// [`std::io::ErrorKind::NotFound`] for a name nothing stands under,
133    /// or the browser's refusal.
134    pub async fn remove(&self, name: &Name) -> std::io::Result<()> {
135        directory::remove(&self.0, name.as_str(), Depth::Everything).await
136    }
137
138    /// Remove a container no view holds. Its lock is claimed first and kept
139    /// until the directory is gone, so a container open in another tab is
140    /// refused rather than pulled out from under the session writing it.
141    ///
142    /// # Errors
143    /// [`std::io::ErrorKind::ResourceBusy`] for a container another view
144    /// holds, and otherwise as [`Self::remove`].
145    pub async fn remove_unheld(&self, name: &Name) -> std::io::Result<()> {
146        let Some(_held) = locks::request(&Opfs::lock_name(name)).await? else {
147            return Err(std::io::Error::new(
148                std::io::ErrorKind::ResourceBusy,
149                format!("{name} is open in another tab"),
150            ));
151        };
152        self.remove(name).await
153    }
154}
155
156/// One container's directory in origin-private storage.
157pub struct Opfs {
158    /// The origin's root, which a rename and a discard reach the container
159    /// through rather than reaching into it.
160    root: FileSystemDirectoryHandle,
161    dir: FileSystemDirectoryHandle,
162    name: Name,
163    held: RefCell<Option<locks::Held>>,
164}
165
166impl Opfs {
167    /// The directory `at` is held in — made along the way where a write is
168    /// what asked — and the name it is held under.
169    async fn within(
170        &self,
171        at: &Entry,
172        missing: Missing,
173    ) -> std::io::Result<(FileSystemDirectoryHandle, String)> {
174        let path = segments(at);
175        let Some((name, holding)) = path.split_last() else {
176            return Err(std::io::Error::new(
177                std::io::ErrorKind::InvalidInput,
178                format!("{} is the container itself, not an entry in it", self.name),
179            ));
180        };
181        Ok((self.walk(holding, missing).await?, (*name).to_owned()))
182    }
183
184    async fn entry(
185        &self,
186        at: &Entry,
187        missing: Missing,
188    ) -> std::io::Result<web_sys::FileSystemFileHandle> {
189        let (dir, name) = self.within(at, missing).await?;
190        directory::file(&dir, &name, missing).await
191    }
192
193    async fn walk(
194        &self,
195        path: &[&str],
196        missing: Missing,
197    ) -> std::io::Result<FileSystemDirectoryHandle> {
198        let mut at = self.dir.clone();
199        for segment in path {
200            at = directory::directory(&at, segment, missing).await?;
201        }
202        Ok(at)
203    }
204
205    fn lock_name(name: &Name) -> String {
206        format!("blockworx/{name}")
207    }
208}
209
210impl std::fmt::Debug for Opfs {
211    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
212        write!(f, "{} in origin-private storage", self.name)
213    }
214}
215
216impl Storage for Opfs {
217    fn name(&self) -> Name {
218        self.name.clone()
219    }
220
221    fn names(&self, at: &Entry) -> String {
222        if at.as_str().is_empty() {
223            return self.name.to_string();
224        }
225        format!("{}/{at}", self.name)
226    }
227
228    fn residency(&self) -> Residency {
229        Residency::Resident
230    }
231
232    async fn read<'a>(&'a self, at: &'a Entry) -> std::io::Result<Option<Vec<u8>>> {
233        let Some(handle) = nothing_there(self.entry(at, Missing::IsNothing).await)? else {
234            return Ok(None);
235        };
236        file::read(&handle).await.map(Some)
237    }
238
239    /// A name a *directory* stands under answers `false` rather than the
240    /// browser's refusal, which is what a map answers too: this is asked
241    /// about entries, and the container's own directories are laid out
242    /// rather than looked for.
243    async fn exists<'a>(&'a self, at: &'a Entry) -> std::io::Result<bool> {
244        match self.entry(at, Missing::IsNothing).await {
245            Ok(_) => Ok(true),
246            Err(why) if not_an_entry(&why) => Ok(false),
247            Err(why) => Err(why),
248        }
249    }
250
251    async fn write<'a>(&'a self, at: &'a Entry, bytes: &'a [u8]) -> std::io::Result<()> {
252        file::write(&self.entry(at, Missing::IsMade).await?, bytes).await
253    }
254
255    async fn append<'a>(&'a self, at: &'a Entry, bytes: &'a [u8]) -> std::io::Result<()> {
256        file::append(&self.entry(at, Missing::IsMade).await?, bytes).await
257    }
258
259    async fn truncate<'a>(&'a self, at: &'a Entry, len: u64) -> std::io::Result<()> {
260        file::truncate(&self.entry(at, Missing::IsMade).await?, len).await
261    }
262
263    async fn list<'a>(&'a self, dir: &'a Entry) -> std::io::Result<Vec<String>> {
264        let within = self.walk(&segments(dir), Missing::IsNothing).await?;
265        directory::names(&within).await
266    }
267
268    async fn remove<'a>(&'a self, at: &'a Entry) -> std::io::Result<()> {
269        let (dir, name) = self.within(at, Missing::IsNothing).await?;
270        directory::remove(&dir, &name, Depth::Entry).await
271    }
272
273    async fn create_dir<'a>(&'a self, dir: &'a Entry) -> std::io::Result<()> {
274        self.walk(&segments(dir), Missing::IsMade).await?;
275        Ok(())
276    }
277
278    /// The bytes travel, since only Chromium has `move()`, and the lock
279    /// goes with them: it is named after the container, so a rename must
280    /// take the new name's lock as well as the new name.
281    ///
282    /// Both are taken before anything moves. A view claiming a name whose
283    /// directory it has not laid down yet holds that lock and nothing else,
284    /// so a rename that copied first and asked afterwards would find the
285    /// lock gone and carry on writing a container it no longer holds.
286    async fn rename<'a>(&'a mut self, to: &'a Name) -> std::io::Result<()> {
287        if directory::holds(&self.root, to.as_str()).await? {
288            return Err(taken(to));
289        }
290        let holding = self.held.borrow().is_some();
291        let relocked = if holding {
292            Some(
293                locks::request(&Self::lock_name(to))
294                    .await?
295                    .ok_or_else(|| taken(to))?,
296            )
297        } else {
298            None
299        };
300
301        let moved = directory::directory(&self.root, to.as_str(), Missing::IsMade).await?;
302        directory::copy(&self.dir, &moved).await?;
303        directory::remove(&self.root, self.name.as_str(), Depth::Everything).await?;
304
305        if let Some(relocked) = relocked {
306            *self.held.borrow_mut() = Some(relocked);
307        }
308        self.dir = moved;
309        self.name = to.clone();
310        Ok(())
311    }
312
313    async fn discard(&self) -> std::io::Result<()> {
314        directory::remove(&self.root, self.name.as_str(), Depth::Entry).await
315    }
316
317    /// `now` names nobody here: the origin's lock manager records no
318    /// holder, only that the lock is held, which is why a refusal is
319    /// [`Holding::Elsewhere`].
320    async fn claim(&self, _now: WallTime) -> std::io::Result<Claim> {
321        let Some(held) = locks::request(&Self::lock_name(&self.name)).await? else {
322            return Ok(Claim::Held(Holding::Elsewhere));
323        };
324        *self.held.borrow_mut() = Some(held);
325        Ok(Claim::Taken)
326    }
327
328    fn release(&self) {
329        drop(self.held.borrow_mut().take());
330    }
331}
332
333/// The path `at` names, as the directories to walk and the entry at the
334/// end. Empty for [`Entry::ROOT`], which is the container itself.
335fn segments(at: &Entry) -> Vec<&str> {
336    at.as_str().split('/').filter(|of| !of.is_empty()).collect()
337}
338
339/// A missing entry is nothing rather than a failure — the one refusal the
340/// container reads as an answer.
341fn nothing_there<T>(got: std::io::Result<T>) -> std::io::Result<Option<T>> {
342    match got {
343        Ok(found) => Ok(Some(found)),
344        Err(why) if why.kind() == std::io::ErrorKind::NotFound => Ok(None),
345        Err(why) => Err(why),
346    }
347}
348
349/// A name that is not this container's to take: something already stands
350/// under it, or another view has claimed it and not laid it down yet.
351fn taken(name: &Name) -> std::io::Error {
352    std::io::Error::new(
353        std::io::ErrorKind::AlreadyExists,
354        format!("{name} already exists"),
355    )
356}
357
358/// Nothing the container would call an entry stands where this was asked:
359/// no such name, or a name something that is not a file stands under.
360fn not_an_entry(why: &std::io::Error) -> bool {
361    matches!(
362        why.kind(),
363        std::io::ErrorKind::NotFound | std::io::ErrorKind::InvalidInput
364    )
365}