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