Skip to main content

audio_code/
storage.rs

1//! Simple saving: high scores, settings, progress. Available as `ctx.storage`.
2//!
3//! Values are stored as text in a small `key = value` file that is loaded when the game
4//! starts and written when it quits (or when you call [`Storage::save`]).
5//!
6//! ```no_run
7//! # use audio_code::prelude::*;
8//! # fn f(ctx: &mut Context, score: u32) {
9//! let best: u32 = ctx.storage.get_or("highscore", 0);
10//! if score > best {
11//!     ctx.storage.set("highscore", score);
12//!     ctx.speak("New high score!");
13//! }
14//! let volume: f32 = ctx.storage.get_or("volume", 0.8);
15//! # }
16//! ```
17//!
18//! The file is `<game title>.save` in the working directory (in a macOS app bundle:
19//! `~/Library/Application Support/<game title>/`) unless set with
20//! [`Engine::save_file`](crate::Engine::save_file). Anything that implements `Display` and
21//! `FromStr` can be stored: numbers, `bool`, `String`, ...
22//!
23//! ## Encryption (optional)
24//!
25//! By default the file is plain text, so players can read and edit it. With
26//! [`Engine::encrypt_save`](crate::Engine::encrypt_save) (or [`Storage::open_encrypted`]) the
27//! file is encrypted with XChaCha20-Poly1305, using a key made from a secret word you choose:
28//!
29//! ```no_run
30//! # use audio_code::prelude::*;
31//! # struct Game;
32//! # impl Scene for Game { fn update(&mut self, _: &mut Context) {} }
33//! fn main() -> audio_code::Result<()> {
34//!     Engine::new("My game").encrypt_save("my secret word").run(Game)
35//! }
36//! ```
37//!
38//! * Nothing else changes: `ctx.storage.get` / `set` work as before.
39//! * The file cannot be read, and any change to it is detected. A file that cannot be
40//!   decrypted (wrong secret, edited or damaged) is not overwritten: it is renamed to
41//!   `<file>.broken`, the game starts with empty storage, and [`Storage::load_error`] says why.
42//! * An existing plain text save is read once and written encrypted on the next save, so
43//!   turning encryption on keeps the players' progress.
44//! * The secret is compiled into your game, so a determined player can dig it out of the
45//!   executable. Encryption stops casual cheating and accidental edits; it is not a safe place
46//!   for real secrets such as passwords.
47
48use std::collections::BTreeMap;
49use std::path::{Path, PathBuf};
50use std::str::FromStr;
51
52use chacha20poly1305::aead::{Aead, KeyInit, Payload};
53use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
54use sha2::{Digest, Sha256};
55
56use crate::error::{Error, Result};
57
58/// Start of an encrypted save file. Also authenticated, so it cannot be swapped.
59const MAGIC: &[u8] = b"AUDIOCODE-SAVE-ENC1\n";
60const NONCE_LEN: usize = 24;
61
62/// Key-value storage that survives restarts, optionally encrypted.
63#[derive(Default)]
64pub struct Storage {
65    path: Option<PathBuf>,
66    values: BTreeMap<String, String>,
67    dirty: bool,
68    key: Option<[u8; 32]>,
69    load_error: Option<String>,
70}
71
72impl std::fmt::Debug for Storage {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        // Never print the key.
75        f.debug_struct("Storage")
76            .field("path", &self.path)
77            .field("values", &self.values.len())
78            .field("encrypted", &self.key.is_some())
79            .field("load_error", &self.load_error)
80            .finish()
81    }
82}
83
84impl Storage {
85    /// Storage that lives only in memory and is never written.
86    pub fn in_memory() -> Self {
87        Self::default()
88    }
89
90    /// Loads storage from a plain text file at `path`. A missing file simply gives empty
91    /// storage. An encrypted file cannot be read without its secret: it is renamed to
92    /// `<file>.broken` and [`Storage::load_error`] explains why.
93    pub fn open(path: impl Into<PathBuf>) -> Self {
94        Self::load(path.into(), None)
95    }
96
97    /// Loads storage from an encrypted file at `path`, using `secret` to make the key. A
98    /// missing file gives empty storage. A plain text file is read and written encrypted on
99    /// the next save. A file that cannot be decrypted (wrong secret, edited or damaged) is
100    /// renamed to `<file>.broken`, and [`Storage::load_error`] explains why.
101    pub fn open_encrypted(path: impl Into<PathBuf>, secret: &str) -> Self {
102        Self::load(path.into(), Some(derive_key(secret)))
103    }
104
105    fn load(path: PathBuf, key: Option<[u8; 32]>) -> Self {
106        let mut storage = Self { path: Some(path.clone()), key, ..Self::default() };
107        let Ok(bytes) = std::fs::read(&path) else {
108            return storage;
109        };
110        let text = match (bytes.strip_prefix(MAGIC), key) {
111            (Some(sealed), Some(key)) => decrypt(&key, sealed),
112            (Some(_), None) => Err("the save file is encrypted, but no secret was given".to_string()),
113            (None, _) => {
114                // Plain text: with a key it is rewritten encrypted on the next save.
115                storage.dirty = key.is_some();
116                String::from_utf8(bytes).map_err(|_| "the save file is not valid text".to_string())
117            }
118        };
119        match text {
120            Ok(text) => storage.parse(&text),
121            Err(reason) => storage.keep_broken(reason),
122        }
123        storage
124    }
125
126    fn parse(&mut self, text: &str) {
127        for line in text.lines() {
128            if let Some((key, value)) = line.split_once('=') {
129                self.values.insert(key.trim().to_string(), unescape(value.trim_start()));
130            }
131        }
132    }
133
134    /// Moves an unreadable file out of the way, so saving does not destroy it.
135    fn keep_broken(&mut self, reason: String) {
136        let Some(path) = self.path.clone() else {
137            return;
138        };
139        let mut backup = path.clone().into_os_string();
140        backup.push(".broken");
141        let message = match std::fs::rename(&path, &backup) {
142            Ok(()) => format!("{reason}; it was kept as {}", PathBuf::from(&backup).display()),
143            Err(_) => {
144                // Could not move it: never write over it.
145                self.path = None;
146                format!("{reason}; saving is disabled so the file is not overwritten")
147            }
148        };
149        eprintln!("audio-code: could not load {}: {message}", path.display());
150        self.load_error = Some(message);
151    }
152
153    /// Why the save file could not be loaded, if it could not: wrong secret, edited or
154    /// damaged file. The unreadable file was kept as `<file>.broken`. `None` when loading
155    /// worked or there was no file.
156    ///
157    /// ```no_run
158    /// # use audio_code::prelude::*;
159    /// # fn f(ctx: &mut Context) {
160    /// if ctx.storage.load_error().is_some() {
161    ///     ctx.speak("Your save file is damaged. Starting a new game.");
162    /// }
163    /// # }
164    /// ```
165    pub fn load_error(&self) -> Option<&str> {
166        self.load_error.as_deref()
167    }
168
169    /// Whether the file is written encrypted.
170    pub fn is_encrypted(&self) -> bool {
171        self.key.is_some()
172    }
173
174    /// Turns encryption on with a secret (`Some`) or off (`None`). The file is rewritten in
175    /// the new form on the next save.
176    pub fn set_encryption(&mut self, secret: Option<&str>) {
177        self.key = secret.map(derive_key);
178        self.dirty = true;
179    }
180
181    /// The file this storage is saved to, if any.
182    pub fn path(&self) -> Option<&Path> {
183        self.path.as_deref()
184    }
185
186    /// Reads a value. Returns `None` if it is missing or cannot be parsed as `T`.
187    pub fn get<T: FromStr>(&self, key: &str) -> Option<T> {
188        self.values.get(key).and_then(|v| v.parse().ok())
189    }
190
191    /// Reads a value, or `default` if it is missing or invalid.
192    pub fn get_or<T: FromStr>(&self, key: &str, default: T) -> T {
193        self.get(key).unwrap_or(default)
194    }
195
196    /// Stores a value. It is written to disk when the game quits or [`Storage::save`] is called.
197    ///
198    /// # Panics
199    /// If `key` contains `=` or a line break.
200    pub fn set(&mut self, key: &str, value: impl ToString) {
201        assert!(!key.contains(['=', '\n', '\r']), "storage keys must not contain '=' or line breaks");
202        let value = value.to_string();
203        if self.values.get(key) != Some(&value) {
204            self.values.insert(key.trim().to_string(), value);
205            self.dirty = true;
206        }
207    }
208
209    /// Adds `amount` to a number (missing counts as 0) and returns the new value.
210    /// Handy for statistics: `ctx.storage.add("games_played", 1)`.
211    pub fn add(&mut self, key: &str, amount: i64) -> i64 {
212        let value = self.get_or::<i64>(key, 0) + amount;
213        self.set(key, value);
214        value
215    }
216
217    /// Removes a value.
218    pub fn remove(&mut self, key: &str) {
219        if self.values.remove(key).is_some() {
220            self.dirty = true;
221        }
222    }
223
224    /// Whether a value exists.
225    pub fn contains(&self, key: &str) -> bool {
226        self.values.contains_key(key)
227    }
228
229    /// All keys in alphabetical order.
230    pub fn keys(&self) -> impl Iterator<Item = &str> {
231        self.values.keys().map(String::as_str)
232    }
233
234    /// Removes everything.
235    pub fn clear(&mut self) {
236        if !self.values.is_empty() {
237            self.values.clear();
238            self.dirty = true;
239        }
240    }
241
242    /// Writes changes to disk now. Does nothing for in-memory storage or without changes.
243    pub fn save(&mut self) -> Result<()> {
244        let Some(path) = &self.path else {
245            return Ok(());
246        };
247        if !self.dirty {
248            return Ok(());
249        }
250        let mut text = String::new();
251        for (key, value) in &self.values {
252            text.push_str(key);
253            text.push_str(" = ");
254            text.push_str(&escape(value));
255            text.push('\n');
256        }
257        if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
258            std::fs::create_dir_all(dir)?;
259        }
260        let bytes = match &self.key {
261            Some(key) => encrypt(key, text.as_bytes())?,
262            None => text.into_bytes(),
263        };
264        std::fs::write(path, bytes)?;
265        self.dirty = false;
266        Ok(())
267    }
268}
269
270/// Makes a 256 bit key from the secret word.
271fn derive_key(secret: &str) -> [u8; 32] {
272    let mut hash = Sha256::new();
273    hash.update(b"audio-code save key v1\0");
274    hash.update(secret.as_bytes());
275    hash.finalize().into()
276}
277
278/// `MAGIC`, a random nonce, then the ciphertext with its authentication tag.
279fn encrypt(key: &[u8; 32], plain: &[u8]) -> Result<Vec<u8>> {
280    let mut nonce = [0u8; NONCE_LEN];
281    getrandom::fill(&mut nonce).map_err(|e| Error::Io(std::io::Error::other(e.to_string())))?;
282    let cipher = XChaCha20Poly1305::new(&Key::from(*key));
283    let sealed = cipher
284        .encrypt(&XNonce::from(nonce), Payload { msg: plain, aad: MAGIC })
285        .map_err(|_| Error::Io(std::io::Error::other("could not encrypt the save file")))?;
286    let mut out = Vec::with_capacity(MAGIC.len() + NONCE_LEN + sealed.len());
287    out.extend_from_slice(MAGIC);
288    out.extend_from_slice(&nonce);
289    out.extend_from_slice(&sealed);
290    Ok(out)
291}
292
293/// Decrypts what follows `MAGIC` in a save file.
294fn decrypt(key: &[u8; 32], sealed: &[u8]) -> std::result::Result<String, String> {
295    let damaged = || "the save file was changed, is damaged, or was saved with another secret".to_string();
296    if sealed.len() < NONCE_LEN {
297        return Err(damaged());
298    }
299    let (nonce, data) = sealed.split_at(NONCE_LEN);
300    let nonce: [u8; NONCE_LEN] = nonce.try_into().map_err(|_| damaged())?;
301    let cipher = XChaCha20Poly1305::new(&Key::from(*key));
302    let plain = cipher.decrypt(&XNonce::from(nonce), Payload { msg: data, aad: MAGIC }).map_err(|_| damaged())?;
303    String::from_utf8(plain).map_err(|_| damaged())
304}
305
306fn escape(value: &str) -> String {
307    value.replace('\\', "\\\\").replace('\n', "\\n").replace('\r', "\\r")
308}
309
310fn unescape(value: &str) -> String {
311    let mut out = String::new();
312    let mut chars = value.chars();
313    while let Some(c) = chars.next() {
314        if c == '\\' {
315            match chars.next() {
316                Some('n') => out.push('\n'),
317                Some('r') => out.push('\r'),
318                Some(other) => out.push(other),
319                None => out.push('\\'),
320            }
321        } else {
322            out.push(c);
323        }
324    }
325    out
326}
327
328/// Turns a game title into a safe file name.
329pub(crate) fn default_file(title: &str) -> PathBuf {
330    let name: String = title
331        .chars()
332        .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
333        .collect();
334    let name = if name.trim_matches('_').is_empty() { "game".to_string() } else { name };
335    let file = PathBuf::from(format!("{name}.save"));
336    // Inside a macOS app bundle the working directory is `/`, which is not writable.
337    match crate::bundle::data_dir(&name) {
338        Some(dir) => dir.join(file),
339        None => file,
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    #[test]
348    fn round_trip() {
349        let dir = std::env::temp_dir().join(format!("audio-code-storage-{}", std::process::id()));
350        let path = dir.join("test.save");
351        let mut s = Storage::open(&path);
352        s.set("score", 42);
353        s.set("name", "Line one\nback\\slash = ok");
354        s.set("on", true);
355        assert_eq!(s.add("plays", 2), 2);
356        s.save().unwrap();
357
358        let s = Storage::open(&path);
359        assert_eq!(s.get::<u32>("score"), Some(42));
360        assert_eq!(s.get::<String>("name").as_deref(), Some("Line one\nback\\slash = ok"));
361        assert!(s.get_or("on", false));
362        assert_eq!(s.get_or("missing", 7), 7);
363        assert_eq!(s.get::<u32>("name"), None);
364        std::fs::remove_dir_all(dir).unwrap();
365    }
366
367    #[test]
368    fn encrypted_round_trip_and_tampering() {
369        let dir = std::env::temp_dir().join(format!("audio-code-encrypted-{}", std::process::id()));
370        let path = dir.join("game.save");
371        let mut s = Storage::open_encrypted(&path, "secret");
372        s.set("score", 42);
373        s.set("name", "Hero = best");
374        s.save().unwrap();
375
376        let bytes = std::fs::read(&path).unwrap();
377        assert!(bytes.starts_with(MAGIC));
378        assert!(!String::from_utf8_lossy(&bytes).contains("score"), "not readable as text");
379
380        let s = Storage::open_encrypted(&path, "secret");
381        assert_eq!(s.get::<u32>("score"), Some(42));
382        assert_eq!(s.get::<String>("name").as_deref(), Some("Hero = best"));
383        assert!(s.load_error().is_none());
384        assert!(s.is_encrypted());
385
386        // Wrong secret: the file is kept as .broken and not overwritten.
387        let wrong = Storage::open_encrypted(&path, "other");
388        assert!(wrong.load_error().is_some());
389        assert_eq!(wrong.get::<u32>("score"), None);
390        let broken = dir.join("game.save.broken");
391        assert!(broken.is_file());
392        assert!(!path.exists());
393        let kept = Storage::open_encrypted(&broken, "secret");
394        assert_eq!(kept.get::<u32>("score"), Some(42), "the kept file is intact");
395        std::fs::remove_file(&broken).unwrap();
396
397        // Tampering: flip one byte of the ciphertext.
398        let mut s = Storage::open_encrypted(&path, "secret");
399        s.set("score", 42);
400        s.save().unwrap();
401        let mut bytes = std::fs::read(&path).unwrap();
402        let last = bytes.len() - 1;
403        bytes[last] ^= 1;
404        std::fs::write(&path, &bytes).unwrap();
405        let s = Storage::open_encrypted(&path, "secret");
406        assert!(s.load_error().unwrap().contains("changed"));
407        std::fs::remove_dir_all(dir).unwrap();
408    }
409
410    #[test]
411    fn plain_save_is_migrated_and_encrypted_needs_secret() {
412        let dir = std::env::temp_dir().join(format!("audio-code-migrate-{}", std::process::id()));
413        let path = dir.join("game.save");
414        let mut plain = Storage::open(&path);
415        plain.set("level", 3);
416        plain.save().unwrap();
417
418        let mut s = Storage::open_encrypted(&path, "secret");
419        assert_eq!(s.get::<u32>("level"), Some(3), "old plain save still loads");
420        s.save().unwrap();
421        assert!(std::fs::read(&path).unwrap().starts_with(MAGIC), "written encrypted");
422
423        let s = Storage::open(&path);
424        assert!(s.load_error().unwrap().contains("no secret"));
425        assert!(dir.join("game.save.broken").is_file());
426        assert!(!format!("{s:?}").contains("key"));
427
428        let mut off = Storage::open_encrypted(dir.join("game.save.broken"), "secret");
429        off.set_encryption(None);
430        off.save().unwrap();
431        let text = std::fs::read_to_string(dir.join("game.save.broken")).unwrap();
432        assert!(text.contains("level = 3"), "turned off: plain text again");
433        std::fs::remove_dir_all(dir).unwrap();
434    }
435
436    #[test]
437    fn file_names() {
438        assert_eq!(default_file("My Game: 2"), PathBuf::from("My_Game__2.save"));
439        assert_eq!(default_file("?!"), PathBuf::from("game.save"));
440    }
441}