1#![forbid(unsafe_code)] mod 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#[cfg_attr(docsrs, doc(cfg(not(target_arch = "wasm32"))))]
48#[derive(Clone, Debug)]
49pub struct FsStateMgr {
50 inner: Arc<FsStateMgrInner>,
52}
53
54#[derive(Debug)]
56struct FsStateMgrInner {
57 statepath: CheckedDir,
59 lockfile: Mutex<Option<LockFileGuard>>,
61 #[allow(dead_code)] lock_dropped_tx: oneshot::Sender<void::Void>,
68 lock_dropped_rx: futures::future::Shared<oneshot::Receiver<void::Void>>,
70}
71
72impl FsStateMgr {
73 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 #[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 fn rel_filename(&self, key: &str) -> PathBuf {
125 (sanitize_filename::sanitize(key) + ".json").into()
126 }
127 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 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 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 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 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 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 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) ))]
263mod test {
264 #![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)] 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); 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); store.clean(SystemTime::get() + Duration::from_secs(365 * 86400));
340 let lst: Vec<_> = statedir.read_dir().unwrap().collect();
341 assert_eq!(lst.len(), 2); 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 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 return Ok(());
377 }
378 assert_eq!(lst.len(), 3); 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 assert_eq!(store1.try_lock().unwrap(), LockStatus::NewlyAcquired);
398 assert_eq!(store1.try_lock().unwrap(), LockStatus::AlreadyHeld);
399 assert!(store1.can_store());
400
401 assert!(!store2.can_store());
403 assert_eq!(store2.try_lock().unwrap(), LockStatus::NoLock);
404 assert!(!store2.can_store());
405
406 store1.unlock().unwrap();
408 assert!(!store1.can_store());
409 assert!(!store2.can_store());
410
411 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 let nonesuch: Result<Option<String>> = store.load("Hello");
424 assert!(matches!(nonesuch, Ok(None)));
425
426 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}