Skip to main content

tor_persist/
fs.rs

1//! Filesystem + JSON implementation of StateMgr.
2
3#![forbid(unsafe_code)] // if you remove this, enable (or write) miri tests (git grep miri)
4
5mod clean;
6
7use crate::err::{Action, ErrorSource, Resource};
8use crate::load_store;
9use crate::{Error, LockStatus, Result, StateMgr};
10use fs_mistrust::CheckedDir;
11use fs_mistrust::anon_home::PathExt as _;
12use fslock_guard::LockFileGuard;
13use futures::FutureExt;
14use oneshot_fused_workaround as oneshot;
15use serde::{Serialize, de::DeserializeOwned};
16use std::path::{Path, PathBuf};
17use std::sync::{Arc, Mutex};
18use tor_error::warn_report;
19use tracing::info;
20use web_time_compat::{SystemTime, SystemTimeExt};
21
22/// Implementation of StateMgr that stores state as JSON files on disk.
23///
24/// # Locking
25///
26/// This manager uses a lock file to determine whether it's allowed to
27/// write to the disk.  Only one process should write to the disk at
28/// a time, though any number may read from the disk.
29///
30/// By default, every `FsStateMgr` starts out unlocked, and only able
31/// to read.  Use [`FsStateMgr::try_lock()`] to lock it.
32///
33/// # Limitations
34///
35/// 1. This manager only accepts objects that can be serialized as
36///    JSON documents.  Some types (like maps with non-string keys) can't
37///    be serialized as JSON.
38///
39/// 2. This manager normalizes keys to an fs-safe format before saving
40///    data with them.  This keeps you from accidentally creating or
41///    reading files elsewhere in the filesystem, but it doesn't prevent
42///    collisions when two keys collapse to the same fs-safe filename.
43///    Therefore, you should probably only use ascii keys that are
44///    fs-safe on all systems.
45///
46/// NEVER use user-controlled or remote-controlled data for your keys.
47#[cfg_attr(docsrs, doc(cfg(not(target_arch = "wasm32"))))]
48#[derive(Clone, Debug)]
49pub struct FsStateMgr {
50    /// Inner reference-counted object.
51    inner: Arc<FsStateMgrInner>,
52}
53
54/// Inner reference-counted object, used by `FsStateMgr`.
55#[derive(Debug)]
56struct FsStateMgrInner {
57    /// Directory in which we store state files.
58    statepath: CheckedDir,
59    /// Lockfile to achieve exclusive access to state files.
60    lockfile: Mutex<Option<LockFileGuard>>,
61    /// A oneshot sender that is used to alert other tasks when this lock is
62    /// finally dropped.
63    ///
64    /// It is a sender for Void because we never actually want to send anything here;
65    /// we only want to generate canceled events.
66    #[allow(dead_code)] // the only purpose of this field is to be dropped.
67    lock_dropped_tx: oneshot::Sender<void::Void>,
68    /// Cloneable handle which resolves when this lock is dropped.
69    lock_dropped_rx: futures::future::Shared<oneshot::Receiver<void::Void>>,
70}
71
72impl FsStateMgr {
73    /// Construct a new `FsStateMgr` to store data in `path`.
74    ///
75    /// This function will try to create `path` if it does not already
76    /// exist.
77    ///
78    /// All files must be "private" according to the rules specified in `mistrust`.
79    pub fn from_path_and_mistrust<P: AsRef<Path>>(
80        path: P,
81        mistrust: &fs_mistrust::Mistrust,
82    ) -> Result<Self> {
83        let path = path.as_ref();
84        let dir = path.join("state");
85
86        let statepath = mistrust
87            .verifier()
88            .check_content()
89            .make_secure_dir(&dir)
90            .map_err(|e| {
91                Error::new(
92                    e,
93                    Action::Initializing,
94                    Resource::Directory { dir: dir.clone() },
95                )
96            })?;
97
98        let (lock_dropped_tx, lock_dropped_rx) = oneshot::channel();
99        let lock_dropped_rx = lock_dropped_rx.shared();
100        Ok(FsStateMgr {
101            inner: Arc::new(FsStateMgrInner {
102                statepath,
103                lockfile: Mutex::new(None),
104                lock_dropped_tx,
105                lock_dropped_rx,
106            }),
107        })
108    }
109    /// Like from_path_and_mistrust, but do not verify permissions.
110    ///
111    /// Testing only.
112    #[cfg(test)]
113    pub fn from_path<P: AsRef<Path>>(path: P) -> Result<Self> {
114        Self::from_path_and_mistrust(
115            path,
116            &fs_mistrust::Mistrust::new_dangerously_trust_everyone(),
117        )
118    }
119
120    /// Return a filename, relative to the top of this directory, to use for
121    /// storing data with `key`.
122    ///
123    /// See "Limitations" section on [`FsStateMgr`] for caveats.
124    fn rel_filename(&self, key: &str) -> PathBuf {
125        (sanitize_filename::sanitize(key) + ".json").into()
126    }
127    /// Return the top-level directory for this storage manager.
128    ///
129    /// (This is the same directory passed to
130    /// [`FsStateMgr::from_path_and_mistrust`].)
131    pub fn path(&self) -> &Path {
132        self.inner
133            .statepath
134            .as_path()
135            .parent()
136            .expect("No parent directory even after path.join?")
137    }
138
139    /// Remove old and/or obsolete items from this storage manager.
140    ///
141    /// Requires that we hold the lock.
142    fn clean(&self, now: SystemTime) {
143        for fname in clean::files_to_delete(self.inner.statepath.as_path(), now) {
144            info!("Deleting obsolete file {}", fname.anonymize_home());
145            if let Err(e) = std::fs::remove_file(&fname) {
146                warn_report!(e, "Unable to delete {}", fname.anonymize_home(),);
147            }
148        }
149    }
150
151    /// Operate using a `load_store::Target` for `key` in this state dir
152    fn with_load_store_target<T, F>(&self, key: &str, action: Action, f: F) -> Result<T>
153    where
154        F: FnOnce(load_store::Target<'_>) -> std::result::Result<T, ErrorSource>,
155    {
156        let rel_fname = self.rel_filename(key);
157        f(load_store::Target {
158            dir: &self.inner.statepath,
159            rel_fname: &rel_fname,
160        })
161        .map_err(|source| Error::new(source, action, self.err_resource(key)))
162    }
163
164    /// Return a `Resource` object representing the file with a given key.
165    fn err_resource(&self, key: &str) -> Resource {
166        Resource::File {
167            container: self.path().to_path_buf(),
168            file: PathBuf::from("state").join(self.rel_filename(key)),
169        }
170    }
171
172    /// Return a `Resource` object representing our lock file.
173    fn err_resource_lock(&self) -> Resource {
174        Resource::File {
175            container: self.path().to_path_buf(),
176            file: "state.lock".into(),
177        }
178    }
179
180    /// Return a handle which resolves when the file is unlocked
181    pub fn wait_for_unlock(
182        &self,
183    ) -> impl futures::Future<Output = ()> + Send + Sync + 'static + use<> {
184        self.inner.lock_dropped_rx.clone().map(|_| ())
185    }
186}
187
188impl StateMgr for FsStateMgr {
189    fn can_store(&self) -> bool {
190        let lockfile = self
191            .inner
192            .lockfile
193            .lock()
194            .expect("Poisoned lock on state lockfile");
195        lockfile.is_some()
196    }
197
198    fn try_lock(&self) -> Result<LockStatus> {
199        let mut lockfile = self
200            .inner
201            .lockfile
202            .lock()
203            .expect("Poisoned lock on state lockfile");
204        if lockfile.is_some() {
205            return Ok(LockStatus::AlreadyHeld);
206        }
207        let lockpath = self.inner.statepath.join("state.lock").map_err(|e| {
208            Error::new(
209                e,
210                Action::Initializing,
211                Resource::Directory {
212                    dir: self.inner.statepath.as_path().to_owned(),
213                },
214            )
215        })?;
216
217        let guard = LockFileGuard::try_lock(lockpath.as_path())
218            .map_err(|e| Error::new(e, Action::Initializing, self.err_resource_lock()))?;
219        *lockfile = guard;
220        if lockfile.is_some() {
221            self.clean(SystemTime::get());
222            Ok(LockStatus::NewlyAcquired)
223        } else {
224            Ok(LockStatus::NoLock)
225        }
226    }
227
228    fn unlock(&self) -> Result<()> {
229        let mut lockfile = self
230            .inner
231            .lockfile
232            .lock()
233            .expect("Poisoned lock on state lockfile");
234
235        // Dropping the guard will release the lock.
236        let _guard: Option<LockFileGuard> = lockfile.take();
237        Ok(())
238    }
239    fn load<D>(&self, key: &str) -> Result<Option<D>>
240    where
241        D: DeserializeOwned,
242    {
243        self.with_load_store_target(key, Action::Loading, |t| t.load())
244    }
245
246    fn store<S>(&self, key: &str, val: &S) -> Result<()>
247    where
248        S: Serialize,
249    {
250        if !self.can_store() {
251            return Err(Error::new(
252                ErrorSource::NoLock,
253                Action::Storing,
254                Resource::Manager,
255            ));
256        }
257
258        self.with_load_store_target(key, Action::Storing, |t| t.store(val))
259    }
260}
261
262#[cfg(all(test, not(miri) /* filesystem access */))]
263mod test {
264    // @@ begin test lint list maintained by maint/add_warning @@
265    #![allow(clippy::bool_assert_comparison)]
266    #![allow(clippy::clone_on_copy)]
267    #![allow(clippy::dbg_macro)]
268    #![allow(clippy::mixed_attributes_style)]
269    #![allow(clippy::print_stderr)]
270    #![allow(clippy::print_stdout)]
271    #![allow(clippy::single_char_pattern)]
272    #![allow(clippy::unwrap_used)]
273    #![allow(clippy::unchecked_time_subtraction)]
274    #![allow(clippy::useless_vec)]
275    #![allow(clippy::needless_pass_by_value)]
276    #![allow(clippy::string_slice)] // See arti#2571
277    //! <!-- @@ end test lint list maintained by maint/add_warning @@ -->
278    use super::*;
279    use std::{collections::HashMap, time::Duration};
280
281    #[test]
282    fn simple() -> Result<()> {
283        let dir = tempfile::TempDir::new().unwrap();
284        let store = FsStateMgr::from_path(dir.path())?;
285
286        assert_eq!(store.try_lock()?, LockStatus::NewlyAcquired);
287        let stuff: HashMap<_, _> = vec![("hello".to_string(), "world".to_string())]
288            .into_iter()
289            .collect();
290        store.store("xyz", &stuff)?;
291
292        let stuff2: Option<HashMap<String, String>> = store.load("xyz")?;
293        let nothing: Option<HashMap<String, String>> = store.load("abc")?;
294
295        assert_eq!(Some(stuff), stuff2);
296        assert!(nothing.is_none());
297
298        assert_eq!(dir.path(), store.path());
299
300        drop(store); // Do this to release the fs lock.
301        let store = FsStateMgr::from_path(dir.path())?;
302        let stuff3: Option<HashMap<String, String>> = store.load("xyz")?;
303        assert_eq!(stuff2, stuff3);
304
305        let stuff4: HashMap<_, _> = vec![("greetings".to_string(), "humans".to_string())]
306            .into_iter()
307            .collect();
308
309        assert!(matches!(
310            store.store("xyz", &stuff4).unwrap_err().source(),
311            ErrorSource::NoLock
312        ));
313
314        assert_eq!(store.try_lock()?, LockStatus::NewlyAcquired);
315        store.store("xyz", &stuff4)?;
316
317        let stuff5: Option<HashMap<String, String>> = store.load("xyz")?;
318        assert_eq!(Some(stuff4), stuff5);
319
320        Ok(())
321    }
322
323    #[test]
324    fn clean_successful() -> Result<()> {
325        let dir = tempfile::TempDir::new().unwrap();
326        let statedir = dir.path().join("state");
327        let store = FsStateMgr::from_path(dir.path())?;
328
329        assert_eq!(store.try_lock()?, LockStatus::NewlyAcquired);
330        let fname = statedir.join("numbat.toml");
331        let fname2 = statedir.join("quoll.json");
332        std::fs::write(fname, "we no longer use toml files.").unwrap();
333        std::fs::write(fname2, "{}").unwrap();
334
335        let count = statedir.read_dir().unwrap().count();
336        assert_eq!(count, 3); // two files, one lock.
337
338        // Now we can make sure that "clean" actually removes the right file.
339        store.clean(SystemTime::get() + Duration::from_secs(365 * 86400));
340        let lst: Vec<_> = statedir.read_dir().unwrap().collect();
341        assert_eq!(lst.len(), 2); // one file, one lock.
342        assert!(
343            lst.iter()
344                .any(|ent| ent.as_ref().unwrap().file_name() == "quoll.json")
345        );
346
347        Ok(())
348    }
349
350    #[cfg(target_family = "unix")]
351    #[test]
352    fn permissions() -> Result<()> {
353        use std::fs::Permissions;
354        use std::os::unix::fs::PermissionsExt;
355
356        let ro_dir = Permissions::from_mode(0o500);
357        let rw_dir = Permissions::from_mode(0o700);
358        let unusable = Permissions::from_mode(0o000);
359
360        let dir = tempfile::TempDir::new().unwrap();
361        let statedir = dir.path().join("state");
362        let store = FsStateMgr::from_path(dir.path())?;
363
364        assert_eq!(store.try_lock()?, LockStatus::NewlyAcquired);
365        let fname = statedir.join("numbat.toml");
366        let fname2 = statedir.join("quoll.json");
367        std::fs::write(fname, "we no longer use toml files.").unwrap();
368        std::fs::write(&fname2, "{}").unwrap();
369
370        // Make the store directory read-only and make sure that we can't delete from it.
371        std::fs::set_permissions(&statedir, ro_dir).unwrap();
372        store.clean(SystemTime::get() + Duration::from_secs(365 * 86400));
373        let lst: Vec<_> = statedir.read_dir().unwrap().collect();
374        if lst.len() == 2 {
375            // We must be root.  Don't do any more tests here.
376            return Ok(());
377        }
378        assert_eq!(lst.len(), 3); // We can't remove the file, but we didn't freak out. Great!
379        // Try failing to read a mode-0 file.
380        std::fs::set_permissions(&statedir, rw_dir).unwrap();
381        std::fs::set_permissions(fname2, unusable).unwrap();
382
383        let h: Result<Option<HashMap<String, u32>>> = store.load("quoll");
384        assert!(h.is_err());
385        assert!(matches!(h.unwrap_err().source(), ErrorSource::IoError(_)));
386
387        Ok(())
388    }
389
390    #[test]
391    fn locking() {
392        let dir = tempfile::TempDir::new().unwrap();
393        let store1 = FsStateMgr::from_path(dir.path()).unwrap();
394        let store2 = FsStateMgr::from_path(dir.path()).unwrap();
395
396        // Nobody has the lock; store1 will take it.
397        assert_eq!(store1.try_lock().unwrap(), LockStatus::NewlyAcquired);
398        assert_eq!(store1.try_lock().unwrap(), LockStatus::AlreadyHeld);
399        assert!(store1.can_store());
400
401        // store1 has the lock; store2 will try to get it and fail.
402        assert!(!store2.can_store());
403        assert_eq!(store2.try_lock().unwrap(), LockStatus::NoLock);
404        assert!(!store2.can_store());
405
406        // Store 1 will drop the lock.
407        store1.unlock().unwrap();
408        assert!(!store1.can_store());
409        assert!(!store2.can_store());
410
411        // Now store2 can get the lock.
412        assert_eq!(store2.try_lock().unwrap(), LockStatus::NewlyAcquired);
413        assert!(store2.can_store());
414        assert!(!store1.can_store());
415    }
416
417    #[test]
418    fn errors() {
419        let dir = tempfile::TempDir::new().unwrap();
420        let store = FsStateMgr::from_path(dir.path()).unwrap();
421
422        // file not found is not an error.
423        let nonesuch: Result<Option<String>> = store.load("Hello");
424        assert!(matches!(nonesuch, Ok(None)));
425
426        // bad utf8 is an error.
427        let file: PathBuf = ["state", "Hello.json"].iter().collect();
428        std::fs::write(dir.path().join(&file), b"hello world \x00\xff").unwrap();
429        let bad_utf8: Result<Option<String>> = store.load("Hello");
430        assert!(bad_utf8.is_err());
431        assert_eq!(
432            bad_utf8.unwrap_err().to_string(),
433            format!(
434                "IO error while loading persistent data on {} in {}",
435                file.to_string_lossy(),
436                dir.path().anonymize_home(),
437            ),
438        );
439    }
440}