lexe/unstable/
ffs.rs

1//! Flat file system abstraction.
2
3use std::{
4    fs,
5    io::{self, Read},
6    path::{Path, PathBuf},
7};
8
9use lexe_crypto::rng::{RngExt, ThreadFastRng};
10
11/// Abstraction over a flat file system (no subdirs), suitable for mocking.
12///
13/// **Invariant**: The `Ffs` must always be ready for `read` / `write` /
14/// `delete` calls, including after `delete_all`.
15pub trait Ffs {
16    /// Reads the entire contents of `filename`.
17    ///
18    /// NOTE: Use [`io::ErrorKind::NotFound`] to detect if a file is missing.
19    #[cfg_attr(not(feature = "unstable"), allow(dead_code))]
20    fn read(&self, filename: &str) -> io::Result<Vec<u8>> {
21        let mut buf = Vec::new();
22        self.read_into(filename, &mut buf)?;
23        Ok(buf)
24    }
25
26    /// Reads the contents of `filename` into `buf`.
27    fn read_into(&self, filename: &str, buf: &mut Vec<u8>) -> io::Result<()>;
28
29    /// Reads all filenames in the `Ffs`.
30    #[cfg_attr(not(feature = "unstable"), allow(dead_code))]
31    fn read_dir(&self) -> io::Result<Vec<String>> {
32        let mut filenames = Vec::new();
33        self.read_dir_visitor(|filename| {
34            filenames.push(filename.to_owned());
35            Ok(())
36        })?;
37        Ok(filenames)
38    }
39
40    /// Visit all filenames in the `Ffs`.
41    fn read_dir_visitor(
42        &self,
43        dir_visitor: impl FnMut(&str) -> io::Result<()>,
44    ) -> io::Result<()>;
45
46    /// Write `data` to `filename`, overwriting any existing file.
47    fn write(&self, filename: &str, data: &[u8]) -> io::Result<()>;
48
49    /// Delete all files and directories in the `Ffs` without deleting the `Ffs`
50    /// itself or any artifacts required for its continued use.
51    fn delete_all(&self) -> io::Result<()>;
52
53    /// Delete file.
54    #[cfg_attr(not(feature = "unstable"), allow(dead_code))]
55    fn delete(&self, filename: &str) -> io::Result<()>;
56}
57
58/// File system impl for [`Ffs`] that does real IO.
59#[derive(Clone)]
60pub struct DiskFs {
61    /// Files are stored flat (i.e., no subdirectories) in this directory.
62    base_dir: PathBuf,
63
64    /// `{base_dir}/.write`
65    ///
66    /// Used to support atomic writes. We fully write files to this subdir
67    /// before moving them to their final destination in `base_dir`.
68    ///
69    /// We store these pending writes in a subdirectory of `base_dir` to avoid
70    /// crossing a filesystem boundary when moving them (which would make the
71    /// move definitely not atomic).
72    write_dir: PathBuf,
73}
74
75impl DiskFs {
76    /// Create a new [`DiskFs`] ready for use.
77    ///
78    /// Normally, it's expected that this directory already exists. In case that
79    /// directory doesn't exist, this fn will create `base_dir` and any parent
80    /// directories.
81    pub fn create_dir_all(base_dir: PathBuf) -> anyhow::Result<Self> {
82        // Ensure the base_dir exists
83        fs::create_dir_all(base_dir.as_path())?;
84
85        // Clean up any write_dir from before. This could contain partially
86        // complete writes from just before a crash.
87        let write_dir = Self::write_dir_path(&base_dir);
88        fsext::remove_dir_all_idempotent(&write_dir)?;
89        fs::create_dir(write_dir.as_path())?;
90
91        Ok(Self {
92            base_dir,
93            write_dir,
94        })
95    }
96
97    /// Create a new [`DiskFs`] at `base_dir`, but clean any existing files
98    /// first.
99    pub fn create_clean_dir_all(base_dir: PathBuf) -> anyhow::Result<Self> {
100        // Clean up any existing directory, if it exists.
101        fsext::remove_dir_all_idempotent(&base_dir)?;
102        fs::create_dir_all(base_dir.as_path())?;
103
104        let write_dir = Self::write_dir_path(&base_dir);
105        fs::create_dir(write_dir.as_path())?;
106
107        Ok(Self {
108            base_dir,
109            write_dir,
110        })
111    }
112
113    fn write_dir_path(base_dir: &Path) -> PathBuf {
114        base_dir.join(".write")
115    }
116}
117
118impl Ffs for DiskFs {
119    fn read_into(&self, filename: &str, buf: &mut Vec<u8>) -> io::Result<()> {
120        let mut file = fs::File::open(self.base_dir.join(filename).as_path())?;
121        file.read_to_end(buf)?;
122        Ok(())
123    }
124
125    fn read_dir_visitor(
126        &self,
127        mut dir_visitor: impl FnMut(&str) -> io::Result<()>,
128    ) -> io::Result<()> {
129        for maybe_file_entry in self.base_dir.read_dir()? {
130            let file_entry = maybe_file_entry?;
131
132            // Only visit files.
133            if file_entry.file_type()?.is_file() {
134                // Just skip non-UTF-8 filenames.
135                if let Some(filename) = file_entry.file_name().to_str() {
136                    dir_visitor(filename)?;
137                }
138            }
139        }
140        Ok(())
141    }
142
143    fn write(&self, filename: &str, data: &[u8]) -> io::Result<()> {
144        let final_dest_path = self.base_dir.join(filename);
145
146        // Sample a new random alphanumeric filename to use in the .write subdir
147        // ex: "{base_dir}/.write/z2l86yb3zYS6CT7C".
148        //
149        // This way multiple threads can't partially write to the same file.
150        // Only one will win, and the write will be atomic.
151        let tmp_write_path = {
152            let name: [u8; 16] = ThreadFastRng::new().gen_alphanum_bytes();
153            let name_str = std::str::from_utf8(name.as_slice())
154                .expect("ASCII is all valid UTF-8");
155            self.write_dir.join(name_str)
156        };
157
158        // Low effort atomic write (sans fsync's).
159        fs::write(tmp_write_path.as_path(), data)?;
160        fs::rename(tmp_write_path.as_path(), final_dest_path)?;
161        Ok(())
162    }
163
164    fn delete_all(&self) -> io::Result<()> {
165        fs::remove_dir_all(self.base_dir.as_path())?;
166        fs::create_dir(self.base_dir.as_path())?;
167        // Recreate the .write dir so subsequent writes still work.
168        fs::create_dir(self.write_dir.as_path())?;
169        Ok(())
170    }
171
172    fn delete(&self, filename: &str) -> io::Result<()> {
173        fs::remove_file(self.base_dir.join(filename).as_path())?;
174        Ok(())
175    }
176}
177
178/// [`std::fs`] extensions.
179// TODO(max): Maybe move to lexe-std
180pub mod fsext {
181    use std::{fs, io, path::Path};
182
183    /// [`std::fs::remove_dir_all`] but does not error on file not found.
184    /// Returns `true` if the directory existed and was deleted.
185    pub fn remove_dir_all_idempotent(dir: &Path) -> io::Result<bool> {
186        match fs::remove_dir_all(dir) {
187            Ok(()) => Ok(true),
188            Err(ref e) if e.kind() == io::ErrorKind::NotFound => Ok(false),
189            Err(e) => Err(e),
190        }
191    }
192}
193
194/// [`Ffs`]-related test utilities.
195#[cfg(any(test, feature = "test-utils"))]
196pub mod test_utils {
197    use std::{cell::RefCell, collections::BTreeMap, io};
198
199    use lexe_crypto::rng::{FastRng, RngSliceExt};
200
201    use super::Ffs;
202
203    fn io_err_not_found(filename: &str) -> io::Error {
204        io::Error::new(io::ErrorKind::NotFound, filename)
205    }
206
207    /// An in-memory [`Ffs`] implementation, useful for testing.
208    #[derive(Debug)]
209    pub struct InMemoryFfs {
210        inner: RefCell<InMemoryFfsInner>,
211    }
212
213    #[derive(Debug)]
214    struct InMemoryFfsInner {
215        rng: FastRng,
216        files: BTreeMap<String, Vec<u8>>,
217    }
218
219    impl InMemoryFfs {
220        /// Create a new empty [`InMemoryFfs`].
221        pub fn new() -> Self {
222            Self {
223                inner: RefCell::new(InMemoryFfsInner {
224                    rng: FastRng::new(),
225                    files: BTreeMap::new(),
226                }),
227            }
228        }
229
230        /// Create a new [`InMemoryFfs`] with a seeded RNG.
231        pub fn from_rng(rng: FastRng) -> Self {
232            Self {
233                inner: RefCell::new(InMemoryFfsInner {
234                    rng,
235                    files: BTreeMap::new(),
236                }),
237            }
238        }
239    }
240
241    impl Default for InMemoryFfs {
242        fn default() -> Self {
243            Self::new()
244        }
245    }
246
247    impl Ffs for InMemoryFfs {
248        fn read_into(
249            &self,
250            filename: &str,
251            buf: &mut Vec<u8>,
252        ) -> io::Result<()> {
253            match self.inner.borrow().files.get(filename) {
254                Some(data) => buf.extend_from_slice(data),
255                None => return Err(io_err_not_found(filename)),
256            }
257            Ok(())
258        }
259
260        fn read_dir_visitor(
261            &self,
262            mut dir_visitor: impl FnMut(&str) -> io::Result<()>,
263        ) -> io::Result<()> {
264            // Shuffle the file order to ensure we don't rely on it.
265            let mut filenames = self
266                .inner
267                .borrow()
268                .files
269                .keys()
270                .cloned()
271                .collect::<Vec<_>>();
272            filenames.shuffle(&mut self.inner.borrow_mut().rng);
273
274            for filename in &filenames {
275                dir_visitor(filename)?;
276            }
277            Ok(())
278        }
279
280        fn write(&self, filename: &str, data: &[u8]) -> io::Result<()> {
281            self.inner
282                .borrow_mut()
283                .files
284                .insert(filename.to_owned(), data.to_owned());
285            Ok(())
286        }
287
288        fn delete_all(&self) -> io::Result<()> {
289            self.inner.borrow_mut().files = BTreeMap::new();
290            Ok(())
291        }
292
293        fn delete(&self, filename: &str) -> io::Result<()> {
294            match self.inner.borrow_mut().files.remove(filename) {
295                Some(_) => Ok(()),
296                None => Err(io_err_not_found(filename)),
297            }
298        }
299    }
300}