1use 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
58const MAGIC: &[u8] = b"AUDIOCODE-SAVE-ENC1\n";
60const NONCE_LEN: usize = 24;
61
62#[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 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 pub fn in_memory() -> Self {
87 Self::default()
88 }
89
90 pub fn open(path: impl Into<PathBuf>) -> Self {
94 Self::load(path.into(), None)
95 }
96
97 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 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 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 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 pub fn load_error(&self) -> Option<&str> {
166 self.load_error.as_deref()
167 }
168
169 pub fn is_encrypted(&self) -> bool {
171 self.key.is_some()
172 }
173
174 pub fn set_encryption(&mut self, secret: Option<&str>) {
177 self.key = secret.map(derive_key);
178 self.dirty = true;
179 }
180
181 pub fn path(&self) -> Option<&Path> {
183 self.path.as_deref()
184 }
185
186 pub fn get<T: FromStr>(&self, key: &str) -> Option<T> {
188 self.values.get(key).and_then(|v| v.parse().ok())
189 }
190
191 pub fn get_or<T: FromStr>(&self, key: &str, default: T) -> T {
193 self.get(key).unwrap_or(default)
194 }
195
196 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 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 pub fn remove(&mut self, key: &str) {
219 if self.values.remove(key).is_some() {
220 self.dirty = true;
221 }
222 }
223
224 pub fn contains(&self, key: &str) -> bool {
226 self.values.contains_key(key)
227 }
228
229 pub fn keys(&self) -> impl Iterator<Item = &str> {
231 self.values.keys().map(String::as_str)
232 }
233
234 pub fn clear(&mut self) {
236 if !self.values.is_empty() {
237 self.values.clear();
238 self.dirty = true;
239 }
240 }
241
242 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
270fn 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
278fn 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
293fn 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
328pub(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 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 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 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}