Skip to main content

shuyi_app/storage/
native.rs

1//! [`FileStorage`]: one `.ron` file per key in the OS data directory.
2//!
3//! The data directory is resolved by hand from environment variables rather
4//! than with the `directories` crate: it pulls in `dirs-sys`, which
5//! unconditionally depends on `option-ext` (MPL-2.0), denied by game ADR-0013.
6
7use std::collections::BTreeSet;
8use std::fmt;
9use std::fs;
10use std::io::{self, Write as _};
11use std::panic::{RefUnwindSafe, UnwindSafe};
12use std::path::{Path, PathBuf};
13
14use shuyi_ui::storage::{Storage, StorageError, is_valid_key};
15
16#[cfg(test)]
17mod fake_disk;
18
19/// A [`Storage`] backed by one file per key, under the OS's per-app data
20/// directory ([`data_dir`]).
21///
22/// The folder holds `<key>.ron`, the value, and `<key>.ron.bak`, the
23/// value before it (ticket 3045). A `<key>.ron.tmp` is there only between
24/// a crash and the next write, which removes it. A sync of the folder
25/// (Steam Auto-Cloud) must carry the `.bak` files with the `.ron` files:
26/// a key whose `.ron` is gone reads its good `.bak`, so a save deleted on
27/// one machine would come back from its backup on another.
28///
29/// A write puts the new value in `<key>.ron.tmp` and flushes it to the
30/// disk, copies the old `<key>.ron` (when it is a good copy: not empty,
31/// not all zero bytes, UTF-8) to `<key>.ron.bak` and flushes that, renames
32/// the `.tmp` onto the `.ron`, and syncs the folder. A read of a `.ron`
33/// that is missing, empty, all zero bytes or not UTF-8 beside a good
34/// `.bak` reads the `.bak`, and logs a warning. What that promises:
35///
36/// - **a crash of the game**, on every OS: the key reads the old value or
37///   the new one;
38/// - **a power cut or a crash of the system**, on Linux (the Steam Deck)
39///   and macOS: the key reads the old value or the new one, and a write
40///   that returned `Ok` reads the new one, since the file and the folder
41///   are flushed before it returns (`F_FULLFSYNC` on macOS). A file
42///   system that can't sync a folder (it answers `Unsupported`) keeps the
43///   rename only as well as it journals it;
44/// - **a power cut** on Windows: `std` opens no folder for a sync, so the
45///   folder isn't flushed. NTFS journals the rename, so the key reads the
46///   old value or the new one, never damage; a write that returned in the
47///   moments before the cut may read the old one.
48///
49/// A key written `""` or only `\0` characters keeps no backup, so it reads
50/// back as written and not as damage.
51#[derive(Debug)]
52pub struct FileStorage {
53    dir: PathBuf,
54    disk: Box<dyn Disk>,
55    /// Where a read that falls back to a backup says so.
56    warn: fn(&str),
57    /// Whether a write has removed the `.tmp` files a crash left.
58    swept: bool,
59}
60
61impl FileStorage {
62    /// Locates (but does not create) the data directory of the game whose
63    /// storage is called `name` ([`data_dir`]). Fails only if the OS has
64    /// no usable home directory.
65    pub fn new(name: &str) -> Result<Self, StorageError> {
66        let dir = data_dir(name).ok_or_else(|| {
67            StorageError::Backend("could not determine an OS data directory".to_owned())
68        })?;
69        Ok(Self::at(dir, Box::new(StdDisk), log_warning))
70    }
71
72    /// A storage in `dir`, for tests outside this module.
73    #[cfg(test)]
74    pub(crate) fn in_dir(dir: PathBuf) -> Self {
75        Self::at(dir, Box::new(StdDisk), log_warning)
76    }
77
78    /// A storage in `dir`, through `disk`, that warns with `warn`.
79    fn at(dir: PathBuf, disk: Box<dyn Disk>, warn: fn(&str)) -> Self {
80        Self {
81            dir,
82            disk,
83            warn,
84            swept: false,
85        }
86    }
87
88    fn path(&self, key: &str) -> PathBuf {
89        self.dir.join(format!("{key}.ron"))
90    }
91
92    fn tmp_path(&self, key: &str) -> PathBuf {
93        self.dir.join(format!("{key}.ron.tmp"))
94    }
95
96    fn bak_path(&self, key: &str) -> PathBuf {
97        self.dir.join(format!("{key}.ron.bak"))
98    }
99
100    /// The backup's text, if it is a good copy.
101    fn good_backup(&self, key: &str) -> Option<String> {
102        let bytes = self.disk.read(&self.bak_path(key)).ok()?;
103        if is_good(&bytes) {
104            String::from_utf8(bytes).ok()
105        } else {
106            None
107        }
108    }
109
110    /// Removes the `.tmp` files a crash left in the folder.
111    fn sweep(&self) -> Result<(), StorageError> {
112        let names = self
113            .disk
114            .names(&self.dir)
115            .map_err(|e| backend_error(&self.dir, &e))?;
116        for name in names.iter().filter(|name| name.ends_with(".ron.tmp")) {
117            remove_if_there(&*self.disk, &self.dir.join(name))?;
118        }
119        Ok(())
120    }
121}
122
123/// Logs a warning of [`FileStorage`]'s.
124fn log_warning(message: &str) {
125    // check-text: not player text (a warning for the log)
126    macroquad::prelude::warn!("storage: {}", message);
127}
128
129/// The OS's per-app data directory for the game whose storage is called
130/// `name`: that folder in `%APPDATA%` on Windows, in
131/// `~/Library/Application Support` on macOS, in `$XDG_DATA_HOME` (or
132/// `~/.local/share`) elsewhere on Unix. `None` if the relevant environment
133/// variable isn't set.
134pub fn data_dir(name: &str) -> Option<PathBuf> {
135    #[cfg(target_os = "windows")]
136    {
137        std::env::var_os("APPDATA").map(|dir| PathBuf::from(dir).join(name))
138    }
139    #[cfg(target_os = "macos")]
140    {
141        std::env::var_os("HOME").map(|home| {
142            PathBuf::from(home)
143                .join("Library/Application Support")
144                .join(name)
145        })
146    }
147    #[cfg(all(unix, not(target_os = "macos")))]
148    {
149        if let Some(xdg_data_home) = std::env::var_os("XDG_DATA_HOME") {
150            Some(PathBuf::from(xdg_data_home).join(name))
151        } else {
152            std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share").join(name))
153        }
154    }
155}
156
157impl Storage for FileStorage {
158    fn read(&self, key: &str) -> Result<Option<String>, StorageError> {
159        check_key(key)?;
160        let path = self.path(key);
161        let main = match self.disk.read(&path) {
162            Ok(bytes) => Some(bytes),
163            Err(e) if e.kind() == io::ErrorKind::NotFound => None,
164            Err(e) => return Err(backend_error(&path, &e)),
165        };
166        if !main.as_deref().is_some_and(is_good)
167            && let Some(backup) = self.good_backup(key)
168        {
169            // check-text: not player text (a warning for the log)
170            (self.warn)(&format!(
171                "{key}: {}, read its backup instead",
172                damage(main.as_deref())
173            ));
174            return Ok(Some(backup));
175        }
176        main.map(String::from_utf8)
177            .transpose()
178            .map_err(|e| StorageError::Backend(format!("{}: {e}", path.display())))
179    }
180
181    fn write(&mut self, key: &str, value: &str) -> Result<(), StorageError> {
182        check_key(key)?;
183        self.disk
184            .create_dir_all(&self.dir)
185            .map_err(|e| backend_error(&self.dir, &e))?;
186        if !self.swept {
187            self.sweep()?;
188            self.swept = true;
189        }
190        let tmp = self.tmp_path(key);
191        self.disk
192            .write_synced(&tmp, value.as_bytes())
193            .map_err(|e| backend_error(&tmp, &e))?;
194        let dest = self.path(key);
195        let bak = self.bak_path(key);
196        if is_good(value.as_bytes()) {
197            let old = match self.disk.read(&dest) {
198                Ok(bytes) => Some(bytes),
199                Err(e) if e.kind() == io::ErrorKind::NotFound => None,
200                Err(e) => return Err(backend_error(&dest, &e)),
201            };
202            if let Some(old) = old.filter(|old| is_good(old)) {
203                self.disk
204                    .write_synced(&bak, &old)
205                    .map_err(|e| backend_error(&bak, &e))?;
206            }
207        } else {
208            remove_if_there(&*self.disk, &bak)?;
209        }
210        self.disk
211            .rename(&tmp, &dest)
212            .map_err(|e| backend_error(&dest, &e))?;
213        self.disk
214            .sync_dir(&self.dir)
215            .map_err(|e| backend_error(&self.dir, &e))
216    }
217
218    fn delete(&mut self, key: &str) -> Result<(), StorageError> {
219        check_key(key)?;
220        remove_if_there(&*self.disk, &self.bak_path(key))?;
221        remove_if_there(&*self.disk, &self.path(key))?;
222        match self.disk.sync_dir(&self.dir) {
223            Err(e) if e.kind() != io::ErrorKind::NotFound => Err(backend_error(&self.dir, &e)),
224            _ => Ok(()),
225        }
226    }
227
228    fn list(&self) -> Result<Vec<String>, StorageError> {
229        let names = match self.disk.names(&self.dir) {
230            Ok(names) => names,
231            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
232            Err(e) => return Err(backend_error(&self.dir, &e)),
233        };
234        let mut keys = BTreeSet::new();
235        for name in &names {
236            if let Some(key) = name.strip_suffix(".ron") {
237                keys.insert(key.to_owned());
238            } else if let Some(key) = name.strip_suffix(".ron.bak")
239                && !names.contains(&format!("{key}.ron"))
240                && self.good_backup(key).is_some()
241            {
242                keys.insert(key.to_owned());
243            }
244        }
245        Ok(keys.into_iter().collect())
246    }
247}
248
249/// Whether a file's bytes are a good copy of a value: not empty, not all
250/// zero bytes (what a power cut can leave), and UTF-8.
251fn is_good(bytes: &[u8]) -> bool {
252    !bytes.is_empty() && bytes.iter().any(|&b| b != 0) && std::str::from_utf8(bytes).is_ok()
253}
254
255/// Why a main file that is not a good copy ([`is_good`]) isn't, for the
256/// warning.
257// check-text: not player text (a warning for the log)
258fn damage(main: Option<&[u8]>) -> &'static str {
259    match main {
260        None => "the file is missing",
261        Some([]) => "the file is empty",
262        Some(bytes) if bytes.iter().all(|&b| b == 0) => "the file is all zero bytes",
263        Some(_) => "the file is not UTF-8",
264    }
265}
266
267/// Removes `path`; not an error if it isn't there.
268fn remove_if_there(disk: &dyn Disk, path: &Path) -> Result<(), StorageError> {
269    match disk.remove(path) {
270        Err(e) if e.kind() != io::ErrorKind::NotFound => Err(backend_error(path, &e)),
271        _ => Ok(()),
272    }
273}
274
275fn check_key(key: &str) -> Result<(), StorageError> {
276    if is_valid_key(key) {
277        Ok(())
278    } else {
279        Err(StorageError::InvalidKey(key.to_owned()))
280    }
281}
282
283fn backend_error(path: &Path, e: &io::Error) -> StorageError {
284    StorageError::Backend(format!("{}: {e}", path.display()))
285}
286
287/// The file calls [`FileStorage`] makes: [`StdDisk`] in the game, a fake
288/// that can lose the power in the tests. `Send`, `Sync` and unwind safe,
289/// as [`FileStorage`] has always been.
290trait Disk: fmt::Debug + Send + Sync + UnwindSafe + RefUnwindSafe {
291    /// A file's bytes.
292    fn read(&self, path: &Path) -> io::Result<Vec<u8>>;
293    /// Creates (or empties) a file, writes `bytes` and flushes them to the
294    /// disk.
295    fn write_synced(&self, path: &Path, bytes: &[u8]) -> io::Result<()>;
296    fn rename(&self, from: &Path, to: &Path) -> io::Result<()>;
297    fn remove(&self, path: &Path) -> io::Result<()>;
298    /// Flushes a folder's names to the disk, so a rename survives the
299    /// power.
300    fn sync_dir(&self, dir: &Path) -> io::Result<()>;
301    /// The file names in a folder.
302    fn names(&self, dir: &Path) -> io::Result<Vec<String>>;
303    fn create_dir_all(&self, dir: &Path) -> io::Result<()>;
304}
305
306/// The [`Disk`] of `std::fs`.
307#[derive(Debug)]
308struct StdDisk;
309
310impl Disk for StdDisk {
311    fn read(&self, path: &Path) -> io::Result<Vec<u8>> {
312        fs::read(path)
313    }
314
315    fn write_synced(&self, path: &Path, bytes: &[u8]) -> io::Result<()> {
316        let mut file = fs::File::create(path)?;
317        file.write_all(bytes)?;
318        file.sync_all()
319    }
320
321    fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
322        fs::rename(from, to)
323    }
324
325    fn remove(&self, path: &Path) -> io::Result<()> {
326        fs::remove_file(path)
327    }
328
329    /// On Unix, opens the folder and syncs it; a file system that doesn't
330    /// sync folders (`Unsupported`, `InvalidInput`) is not an error.
331    #[cfg(unix)]
332    fn sync_dir(&self, dir: &Path) -> io::Result<()> {
333        match fs::File::open(dir).and_then(|folder| folder.sync_all()) {
334            Err(e)
335                if matches!(
336                    e.kind(),
337                    io::ErrorKind::Unsupported | io::ErrorKind::InvalidInput
338                ) =>
339            {
340                Ok(())
341            }
342            result => result,
343        }
344    }
345
346    /// On Windows, nothing: `std` opens no folder for a sync, and NTFS
347    /// journals the rename.
348    #[cfg(not(unix))]
349    fn sync_dir(&self, _dir: &Path) -> io::Result<()> {
350        Ok(())
351    }
352
353    fn names(&self, dir: &Path) -> io::Result<Vec<String>> {
354        let mut names = Vec::new();
355        for entry in fs::read_dir(dir)? {
356            if let Ok(name) = entry?.file_name().into_string() {
357                names.push(name);
358            }
359        }
360        Ok(names)
361    }
362
363    fn create_dir_all(&self, dir: &Path) -> io::Result<()> {
364        fs::create_dir_all(dir)
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371    use shuyi_ui::storage::conformance_suite;
372    use std::cell::RefCell;
373
374    thread_local! {
375        static WARNINGS: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
376    }
377
378    /// The tests' `warn`: keeps the warning for [`warnings`].
379    fn catch(message: &str) {
380        WARNINGS.with(|warnings| warnings.borrow_mut().push(message.to_owned()));
381    }
382
383    /// The warnings this test's storage gave since the last call.
384    fn warnings() -> Vec<String> {
385        WARNINGS.with(RefCell::take)
386    }
387
388    fn storage_in(dir: PathBuf) -> FileStorage {
389        FileStorage::at(dir, Box::new(StdDisk), catch)
390    }
391
392    fn storage() -> (FileStorage, tempfile::TempDir) {
393        let tmp = tempfile::tempdir().unwrap();
394        (storage_in(tmp.path().to_owned()), tmp)
395    }
396
397    fn files(dir: &Path) -> Vec<String> {
398        let mut names: Vec<_> = fs::read_dir(dir)
399            .unwrap()
400            .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
401            .collect();
402        names.sort();
403        names
404    }
405
406    #[test]
407    fn passes_the_conformance_suite() {
408        let (mut storage, _tmp) = storage();
409        conformance_suite(&mut storage);
410    }
411
412    #[test]
413    fn the_storage_stays_send_sync_and_unwind_safe() {
414        fn auto_traits<T: Send + Sync + UnwindSafe + RefUnwindSafe>() {}
415        auto_traits::<FileStorage>();
416    }
417
418    #[test]
419    fn writes_are_atomic_and_do_not_leave_tmp_files_behind() {
420        let (mut storage, tmp) = storage();
421        storage.write("save-slot-1", "abc").unwrap();
422        assert_eq!(files(tmp.path()), ["save-slot-1.ron"]);
423    }
424
425    #[test]
426    fn a_second_write_keeps_the_first_value_as_the_backup() {
427        let (mut storage, tmp) = storage();
428        storage.write("save-slot-1", "first").unwrap();
429        storage.write("save-slot-1", "second").unwrap();
430        assert_eq!(
431            files(tmp.path()),
432            ["save-slot-1.ron", "save-slot-1.ron.bak"]
433        );
434        let bak = fs::read_to_string(tmp.path().join("save-slot-1.ron.bak")).unwrap();
435        assert_eq!(bak, "first");
436        assert_eq!(
437            storage.read("save-slot-1").unwrap(),
438            Some("second".to_owned())
439        );
440    }
441
442    #[test]
443    fn a_damaged_main_file_reads_its_backup_and_warns_why() {
444        let damages: [(&str, Option<&[u8]>); 4] = [
445            ("the file is missing", None),
446            ("the file is empty", Some(b"")),
447            ("the file is all zero bytes", Some(b"\0\0\0\0")),
448            ("the file is not UTF-8", Some(b"\xff\xfe(gold")),
449        ];
450        for (why, bytes) in damages {
451            let (mut storage, tmp) = storage();
452            storage.write("save-slot-1", "first").unwrap();
453            storage.write("save-slot-1", "second").unwrap();
454            let main = tmp.path().join("save-slot-1.ron");
455            match bytes {
456                Some(bytes) => fs::write(&main, bytes).unwrap(),
457                None => fs::remove_file(&main).unwrap(),
458            }
459            assert_eq!(
460                storage.read("save-slot-1").unwrap(),
461                Some("first".to_owned()),
462                "{why}"
463            );
464            assert_eq!(
465                warnings(),
466                [format!("save-slot-1: {why}, read its backup instead")]
467            );
468        }
469    }
470
471    #[test]
472    fn without_a_backup_a_damaged_main_file_reads_as_it_is() {
473        let (storage, tmp) = storage();
474        let main = tmp.path().join("save-slot-1.ron");
475        assert_eq!(storage.read("save-slot-1").unwrap(), None);
476        fs::write(&main, b"").unwrap();
477        assert_eq!(storage.read("save-slot-1").unwrap(), Some(String::new()));
478        fs::write(&main, b"\0\0").unwrap();
479        assert_eq!(
480            storage.read("save-slot-1").unwrap(),
481            Some("\0\0".to_owned())
482        );
483        fs::write(&main, b"\xff\xfe").unwrap();
484        assert!(matches!(
485            storage.read("save-slot-1"),
486            Err(StorageError::Backend(_))
487        ));
488        assert_eq!(warnings(), Vec::<String>::new());
489    }
490
491    #[test]
492    fn a_damaged_backup_is_not_read() {
493        let (storage, tmp) = storage();
494        fs::write(tmp.path().join("save-slot-1.ron"), b"").unwrap();
495        fs::write(tmp.path().join("save-slot-1.ron.bak"), b"\0\0").unwrap();
496        assert_eq!(storage.read("save-slot-1").unwrap(), Some(String::new()));
497        assert_eq!(warnings(), Vec::<String>::new());
498    }
499
500    #[test]
501    fn writing_an_empty_or_zero_value_removes_the_backup_and_reads_back() {
502        for value in ["", "\0\0"] {
503            let (mut storage, tmp) = storage();
504            storage.write("save-slot-1", "first").unwrap();
505            storage.write("save-slot-1", "second").unwrap();
506            storage.write("save-slot-1", value).unwrap();
507            assert_eq!(files(tmp.path()), ["save-slot-1.ron"], "{value:?}");
508            assert_eq!(storage.read("save-slot-1").unwrap(), Some(value.to_owned()));
509            assert_eq!(warnings(), Vec::<String>::new());
510        }
511    }
512
513    #[test]
514    fn a_tmp_file_a_crash_left_is_ignored_and_gone_after_the_next_write() {
515        let (mut storage, tmp) = storage();
516        fs::write(tmp.path().join("save-slot-1.ron.tmp"), "(gold: 2").unwrap();
517        assert_eq!(storage.read("save-slot-1").unwrap(), None);
518        assert_eq!(storage.list().unwrap(), Vec::<String>::new());
519        storage.write("settings", "{}").unwrap();
520        assert_eq!(files(tmp.path()), ["settings.ron"]);
521    }
522
523    #[test]
524    fn delete_removes_the_backup_too() {
525        let (mut storage, tmp) = storage();
526        storage.write("save-slot-1", "first").unwrap();
527        storage.write("save-slot-1", "second").unwrap();
528        storage.delete("save-slot-1").unwrap();
529        assert_eq!(files(tmp.path()), Vec::<String>::new());
530        assert_eq!(storage.read("save-slot-1").unwrap(), None);
531    }
532
533    #[test]
534    fn list_names_keys_and_good_lone_backups_but_never_bak_or_tmp_files() {
535        let (storage, tmp) = storage();
536        for (name, bytes) in [
537            ("main.ron", &b"1"[..]),
538            ("main.ron.bak", b"0"),
539            ("lone-backup.ron.bak", b"1"),
540            ("empty-backup.ron.bak", b""),
541            ("left.ron.tmp", b"1"),
542        ] {
543            fs::write(tmp.path().join(name), bytes).unwrap();
544        }
545        assert_eq!(storage.list().unwrap(), ["lone-backup", "main"]);
546    }
547
548    #[test]
549    fn creates_the_directory_on_first_write() {
550        let tmp = tempfile::tempdir().unwrap();
551        let dir = tmp.path().join("nested").join("data");
552        let mut storage = storage_in(dir.clone());
553        assert!(!dir.exists());
554        storage.write("settings", "{}").unwrap();
555        assert!(dir.exists());
556        assert_eq!(storage.read("settings").unwrap(), Some("{}".to_owned()));
557    }
558
559    #[test]
560    fn missing_directory_lists_as_empty_not_an_error() {
561        let tmp = tempfile::tempdir().unwrap();
562        let mut storage = storage_in(tmp.path().join("never-created"));
563        assert_eq!(storage.list().unwrap(), Vec::<String>::new());
564        storage.delete("settings").unwrap();
565    }
566
567    #[test]
568    fn the_data_directory_is_the_storage_name_in_the_os_data_folder() {
569        let dir = data_dir("some-game");
570        assert!(
571            dir.as_ref().is_some_and(|d| d.ends_with("some-game")),
572            "{dir:?}"
573        );
574        #[cfg(target_os = "windows")]
575        assert_eq!(
576            dir,
577            std::env::var_os("APPDATA").map(|d| PathBuf::from(d).join("some-game"))
578        );
579    }
580}