//! Backup, restore and the daily automatic backup. //! //! # Archive format //! A plain zip (the pure-Rust `zip` crate, deflate only) with these entries, all regular files: //! `voiced.db` (a `VACUUM INTO` snapshot, consistent while the app runs), `assets/**` (data dir), //! `archive/**` and `fonts/**` (local data dir), and `manifest.json` (written last, so it can describe //! exactly what was streamed): format, app version, schema `user_version`, creation time and every file //! with its sha256 and size. Files are hashed while they are copied into the zip, one at a time, so no //! archive set is ever held in memory. //! //! # Restore design (stage now, swap on the next start) //! The running app holds `voiced.db` open (WAL) and serves files out of `assets/`, `archive/` and `fonts/`, //! so swapping them live is unsafe. Restore therefore has two phases: //! //! 1. `restore_backup` (`stage_restore`): validate the manifest (format, `schema_version <= LATEST_VERSION`, //! entry names, size caps), check the zip holds exactly the manifest's files and nothing else (no symlinks, //! no traversal, allow-listed top-level names), extract into `/pending-restore/` while hashing against //! the manifest, then open the staged DB read-only and require `integrity_check = ok` and a matching //! `user_version`. Only then is `/pending_restore.json` written. The live data is not touched. //! Staging lives in the same directory as its destination (`` for the DB and assets, `` for the //! archive and fonts) so the final move is a rename. //! 2. `apply_pending_restore` runs in `init_state` after the directories exist and BEFORE the database is //! opened. It moves the current `voiced.db` (with its `-wal`/`-shm` sidecars, so an orphan WAL can never be //! replayed into the restored file), `assets/`, `archive/` and `fonts/` into //! `/backups/pre-restore-/` (never pruned, never deleted), then moves the staged files into //! place. Any failure rolls everything back and the app starts on the old data. The outcome is recorded in //! `/last_restore.json` for the Data tab. A restored older-schema DB is then migrated by `db::open` //! like any other (with its own pre-migration backup). //! //! `restart_app` relaunches the binary after a short delay (see `restart_delay_from_env`) so the //! single-instance lock of the exiting process is released before the new one starts. //! //! # Automatic backup //! A background thread writes `/backups/auto/voiced-auto-YYYYMMDD.zip` at most once per calendar day //! (checked now and then hourly while the app stays open), keeps the newest 14 and logs failures. It opens its //! own SQLite connection, so it never contends for the app's database mutex. use super::raw::write_atomic; use crate::db::{has_user_tables, vacuum_into, LATEST_VERSION}; use crate::AppState; use chrono::{DateTime, Local, NaiveDate}; use rusqlite::{Connection, OpenFlags}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet}; use std::fs::{self, File, OpenOptions}; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use tauri::State; pub const BACKUP_FORMAT: &str = "voiced.backup.v1"; /// Automatic backups kept in `backups/auto`. pub const AUTO_KEEP: usize = 14; const MANIFEST_NAME: &str = "manifest.json"; const DB_ENTRY: &str = "voiced.db"; const ROOTS: [&str; 3] = ["assets", "archive", "fonts"]; const MARKER_NAME: &str = "pending_restore.json"; const LAST_RESTORE_NAME: &str = "last_restore.json"; const STAGING_NAME: &str = "pending-restore"; const AUTO_PREFIX: &str = "voiced-auto-"; const AUTO_SUFFIX: &str = ".zip"; const MAX_ENTRIES: usize = 200_000; const MAX_MANIFEST_BYTES: u64 = 32 * 1024 * 1024; const MAX_FILE_BYTES: u64 = 8 * 1024 * 1024 * 1024; const MAX_TOTAL_BYTES: u64 = 64 * 1024 * 1024 * 1024; const MAX_NAME_LEN: usize = 512; /// The two data roots. On Linux they are the same directory; on Windows `data` is %APPDATA% and `local` /// is %LOCALAPPDATA%. #[derive(Debug, Clone)] pub struct DataDirs { pub data: PathBuf, pub local: PathBuf, } impl DataDirs { pub fn new(data: &Path, local: &Path) -> Self { Self { data: data.to_path_buf(), local: local.to_path_buf() } } fn from_state(state: &AppState) -> Self { Self::new(&state.data_dir, &state.local_data_dir) } pub fn db(&self) -> PathBuf { self.data.join(DB_ENTRY) } pub fn backups(&self) -> PathBuf { self.data.join("backups") } pub fn auto_dir(&self) -> PathBuf { self.backups().join("auto") } fn marker(&self) -> PathBuf { self.data.join(MARKER_NAME) } fn last_restore(&self) -> PathBuf { self.data.join(LAST_RESTORE_NAME) } /// Where a root (`assets`, `archive`, `fonts`) lives. fn root_dir(&self, root: &str) -> PathBuf { match root { "assets" => self.data.join("assets"), _ => self.local.join(root), } } /// Staging directory on the same volume as the destination of an entry. fn staging_for(&self, first_component: &str) -> PathBuf { match first_component { "archive" | "fonts" => self.local.join(STAGING_NAME), _ => self.data.join(STAGING_NAME), } } fn staging_dirs(&self) -> [PathBuf; 2] { [self.data.join(STAGING_NAME), self.local.join(STAGING_NAME)] } fn clear_staging(&self) { for dir in self.staging_dirs() { let _ = fs::remove_dir_all(dir); } } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ManifestFile { pub path: String, pub sha256: String, pub size: u64, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Manifest { pub format: String, pub app_version: String, /// `PRAGMA user_version` of the snapshot. pub schema_version: i64, pub created_at: String, pub files: Vec, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct BackupSummary { pub path: String, pub created_at: String, pub schema_version: i64, pub file_count: usize, pub total_bytes: u64, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PendingRestore { pub backup_name: String, pub backup_created_at: String, pub app_version: String, pub schema_version: i64, pub file_count: usize, pub total_bytes: u64, pub staged_at: String, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LastRestore { pub ok: bool, pub at: String, pub backup_name: String, pub message: String, pub safety_dir: Option, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct BackupStatus { pub auto_backup: bool, pub auto_dir: String, pub last_auto_backup: Option, pub auto_backup_count: usize, pub pending_restore: Option, pub last_restore: Option, } // ---------------------------------------------------------------- helpers /// Removes a temp file when dropped, unless `keep` was called. struct TempFile(PathBuf); impl Drop for TempFile { fn drop(&mut self) { let _ = fs::remove_file(&self.0); } } fn sibling_with_suffix(path: &Path, suffix: &str) -> Result { let mut name = path .file_name() .ok_or_else(|| "The path has no file name".to_string())? .to_os_string(); name.push(suffix); Ok(path.with_file_name(name)) } fn hex(bytes: &[u8]) -> String { bytes.iter().map(|b| format!("{b:02x}")).collect() } fn io_err(what: &str, path: &Path, e: std::io::Error) -> String { format!("{what} {}: {e}", path.display()) } fn user_version(conn: &Connection) -> rusqlite::Result { conn.pragma_query_value(None, "user_version", |r| r.get(0)) } /// Entry names: forward-slash relative paths under an allow-listed top level. Rejects absolute paths, drive /// letters, backslashes, `.`/`..`/empty components, control characters and Windows-hostile names. fn validate_entry_path(name: &str) -> Result<(), String> { let bad = |why: &str| Err(format!("Unsafe file name in the backup ({why}): {name:?}")); if name.is_empty() || name.len() > MAX_NAME_LEN { return bad("empty or too long"); } if name.starts_with('/') || name.contains('\\') || name.contains(':') || name.chars().any(|c| c.is_control()) { return bad("absolute, backslash, colon or control character"); } let parts: Vec<&str> = name.split('/').collect(); for p in &parts { if p.is_empty() || *p == "." || *p == ".." || p.ends_with('.') || p.ends_with(' ') { return bad("path traversal or invalid component"); } } let allowed = name == DB_ENTRY || (parts.len() >= 2 && ROOTS.contains(&parts[0])); if !allowed { return bad("not a Voiced data file"); } Ok(()) } fn is_symlink_mode(mode: u32) -> bool { mode & 0o170000 == 0o120000 } /// Every regular file under `root`, as (zip entry name, path). Symlinks and other special files are skipped. fn collect_files(root_name: &str, root: &Path, out: &mut Vec<(String, PathBuf)>) -> Result<(), String> { fn walk(prefix: &str, dir: &Path, out: &mut Vec<(String, PathBuf)>) -> Result<(), String> { let entries = match fs::read_dir(dir) { Ok(e) => e, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), Err(e) => return Err(io_err("Could not read", dir, e)), }; for entry in entries { let entry = entry.map_err(|e| io_err("Could not read", dir, e))?; let name = entry.file_name().to_string_lossy().into_owned(); let meta = match fs::symlink_metadata(entry.path()) { Ok(m) => m, Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, Err(e) => return Err(io_err("Could not read", &entry.path(), e)), }; let entry_name = format!("{prefix}/{name}"); if meta.is_dir() { walk(&entry_name, &entry.path(), out)?; } else if meta.is_file() { out.push((entry_name, entry.path())); } } Ok(()) } walk(root_name, root, out) } fn zip_options(entry: &str) -> zip::write::SimpleFileOptions { // Archived PDFs and images are already compressed; the DB and fonts are not. let method = if entry == DB_ENTRY || entry.starts_with("fonts/") { zip::CompressionMethod::Deflated } else { zip::CompressionMethod::Stored }; zip::write::SimpleFileOptions::default().compression_method(method).large_file(true) } /// Stream `src` into the zip as `entry`, hashing as it goes. Returns None if the file vanished. fn add_file( zip: &mut zip::ZipWriter, entry: &str, src: &Path, ) -> Result, String> { let mut file = match File::open(src) { Ok(f) => f, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(e) => return Err(io_err("Could not read", src, e)), }; zip.start_file(entry, zip_options(entry)).map_err(|e| e.to_string())?; let mut hasher = Sha256::new(); let mut size = 0u64; let mut buf = vec![0u8; 64 * 1024]; loop { let n = file.read(&mut buf).map_err(|e| io_err("Could not read", src, e))?; if n == 0 { break; } hasher.update(&buf[..n]); zip.write_all(&buf[..n]).map_err(|e| format!("Could not write the backup: {e}"))?; size += n as u64; } Ok(Some(ManifestFile { path: entry.to_string(), sha256: hex(&hasher.finalize()), size })) } // ---------------------------------------------------------------- create fn is_inside(path: &Path, dir: &Path) -> bool { // Compare canonical parents when possible so a relative or symlinked spelling cannot slip through. let canon = |p: &Path| p.canonicalize().unwrap_or_else(|_| p.to_path_buf()); let parent = path.parent().map(canon).unwrap_or_else(|| path.to_path_buf()); parent.starts_with(canon(dir)) } pub fn create_backup_impl(dirs: &DataDirs, dest: &Path, now: DateTime) -> Result { if !dest.is_absolute() { return Err("The backup path must be absolute".to_string()); } for root in ROOTS { if is_inside(dest, &dirs.root_dir(root)) { return Err(format!("Choose a folder outside the {root} folder for the backup")); } } let parent = dest.parent().ok_or_else(|| "The backup path has no folder".to_string())?; fs::create_dir_all(parent).map_err(|e| io_err("Could not create", parent, e))?; // 1. Consistent DB snapshot through a second connection (WAL lets it read while the app writes). let snapshot = TempFile(sibling_with_suffix(dest, &format!(".{}.db.tmp", uuid::Uuid::new_v4().simple()))?); let schema_version = { let conn = Connection::open(dirs.db()).map_err(|e| format!("Could not open the database: {e}"))?; conn.busy_timeout(std::time::Duration::from_secs(10)).map_err(|e| e.to_string())?; vacuum_into(&conn, &snapshot.0).map_err(|e| format!("Could not snapshot the database: {e}"))?; user_version(&conn).map_err(|e| e.to_string())? }; // 2. Stream everything into `.part`, then rename. let part = sibling_with_suffix(dest, ".part")?; let part_guard = TempFile(part.clone()); let mut files: Vec = Vec::new(); { let out = File::create(&part).map_err(|e| io_err("Could not create", &part, e))?; let mut zip = zip::ZipWriter::new(out); match add_file(&mut zip, DB_ENTRY, &snapshot.0)? { Some(f) => files.push(f), None => return Err("The database snapshot disappeared".to_string()), } for root in ROOTS { let mut found = Vec::new(); collect_files(root, &dirs.root_dir(root), &mut found)?; found.sort(); for (entry, path) in found { if let Some(f) = add_file(&mut zip, &entry, &path)? { files.push(f); } } } let manifest = Manifest { format: BACKUP_FORMAT.to_string(), app_version: env!("CARGO_PKG_VERSION").to_string(), schema_version, created_at: now.to_rfc3339(), files: files.clone(), }; let text = serde_json::to_vec_pretty(&manifest).map_err(|e| e.to_string())?; zip.start_file(MANIFEST_NAME, zip_options(DB_ENTRY)).map_err(|e| e.to_string())?; zip.write_all(&text).map_err(|e| format!("Could not write the backup: {e}"))?; let out = zip.finish().map_err(|e| format!("Could not finish the backup: {e}"))?; out.sync_all().map_err(|e| io_err("Could not flush", &part, e))?; } fs::rename(&part, dest).map_err(|e| io_err("Could not write", dest, e))?; drop(part_guard); // the part file was renamed away, so this is a no-op Ok(BackupSummary { path: dest.to_string_lossy().into_owned(), created_at: now.to_rfc3339(), schema_version, file_count: files.len(), total_bytes: files.iter().map(|f| f.size).sum(), }) } // ---------------------------------------------------------------- stage (validate + extract) fn read_manifest(archive: &mut zip::ZipArchive) -> Result { let entry = archive .by_name(MANIFEST_NAME) .map_err(|_| "This file is not a Voiced backup (no manifest.json)".to_string())?; if entry.size() > MAX_MANIFEST_BYTES { return Err("The backup manifest is too large".to_string()); } let mut text = Vec::new(); entry .take(MAX_MANIFEST_BYTES + 1) .read_to_end(&mut text) .map_err(|e| format!("Could not read the backup manifest: {e}"))?; if text.len() as u64 > MAX_MANIFEST_BYTES { return Err("The backup manifest is too large".to_string()); } serde_json::from_slice(&text).map_err(|e| format!("The backup manifest is damaged: {e}")) } fn validate_manifest(m: &Manifest) -> Result<(), String> { if m.format != BACKUP_FORMAT { return Err(format!("Unsupported backup format {:?}", m.format)); } if m.schema_version > LATEST_VERSION { return Err(format!( "This backup was made by a newer version of Voiced (database version {}, this app supports up to {}). Update Voiced first.", m.schema_version, LATEST_VERSION )); } if m.schema_version < 1 { return Err("The backup has no valid database version".to_string()); } if m.files.len() > MAX_ENTRIES { return Err("The backup lists too many files".to_string()); } let mut seen = BTreeSet::new(); let mut total = 0u64; for f in &m.files { validate_entry_path(&f.path)?; if !seen.insert(f.path.as_str()) { return Err(format!("The backup lists {:?} twice", f.path)); } if f.size > MAX_FILE_BYTES { return Err(format!("{:?} is larger than the allowed size", f.path)); } if f.sha256.len() != 64 || !f.sha256.bytes().all(|b| b.is_ascii_hexdigit()) { return Err(format!("{:?} has an invalid checksum in the manifest", f.path)); } total = total.saturating_add(f.size); } if total > MAX_TOTAL_BYTES { return Err("The backup is larger than the allowed size".to_string()); } if !seen.contains(DB_ENTRY) { return Err("The backup contains no database".to_string()); } Ok(()) } fn verify_staged_db(path: &Path, expected_version: i64) -> Result<(), String> { let result = (|| -> Result<(), String> { let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY) .map_err(|e| format!("The backed-up database cannot be opened: {e}"))?; let integrity: String = conn .query_row("PRAGMA integrity_check", [], |r| r.get(0)) .map_err(|e| format!("The backed-up database failed its check: {e}"))?; if integrity != "ok" { return Err(format!("The backed-up database is damaged: {integrity}")); } let version = user_version(&conn).map_err(|e| e.to_string())?; if version > LATEST_VERSION { return Err(format!( "The backed-up database is from a newer version of Voiced (version {version}, supported up to {LATEST_VERSION})" )); } if version != expected_version || version < 1 { return Err("The backed-up database version does not match its manifest".to_string()); } if !has_user_tables(&conn).map_err(|e| e.to_string())? { return Err("The backed-up database is empty".to_string()); } Ok(()) })(); // A read-only open of a WAL database can leave sidecars; none may travel with the staged file. for suffix in ["-wal", "-shm"] { if let Ok(side) = sibling_with_suffix(path, suffix) { let _ = fs::remove_file(side); } } result } fn extract_entry( archive: &mut zip::ZipArchive, index: usize, expected: &ManifestFile, target: &Path, ) -> Result<(), String> { let entry = archive.by_index(index).map_err(|e| e.to_string())?; if entry.size() != expected.size { return Err(format!("{:?} does not match the size in the manifest", expected.path)); } if let Some(parent) = target.parent() { fs::create_dir_all(parent).map_err(|e| io_err("Could not create", parent, e))?; } // create_new: never write through something that already exists (a symlink, say). let mut out = OpenOptions::new() .write(true) .create_new(true) .open(target) .map_err(|e| io_err("Could not create", target, e))?; let mut reader = entry.take(expected.size + 1); let mut hasher = Sha256::new(); let mut written = 0u64; let mut buf = vec![0u8; 64 * 1024]; loop { let n = reader.read(&mut buf).map_err(|e| format!("Could not read {:?} from the backup: {e}", expected.path))?; if n == 0 { break; } written += n as u64; if written > expected.size { return Err(format!("{:?} is larger than the manifest says", expected.path)); } hasher.update(&buf[..n]); out.write_all(&buf[..n]).map_err(|e| io_err("Could not write", target, e))?; } out.sync_all().map_err(|e| io_err("Could not flush", target, e))?; if written != expected.size || hex(&hasher.finalize()) != expected.sha256 { return Err(format!("{:?} is damaged: its checksum does not match the manifest", expected.path)); } Ok(()) } fn stage_inner(dirs: &DataDirs, zip_path: &Path, now: DateTime) -> Result { let file = File::open(zip_path).map_err(|e| io_err("Could not open", zip_path, e))?; let mut archive = zip::ZipArchive::new(file).map_err(|_| "This file is not a valid backup (it is not a zip archive)".to_string())?; if archive.len() > MAX_ENTRIES + 1 { return Err("The backup contains too many entries".to_string()); } let manifest = read_manifest(&mut archive)?; validate_manifest(&manifest)?; let expected: BTreeMap<&str, &ManifestFile> = manifest.files.iter().map(|f| (f.path.as_str(), f)).collect(); // The zip must hold exactly the manifest's files: no extras, no symlinks, no duplicates. let mut index_of: BTreeMap = BTreeMap::new(); for i in 0..archive.len() { let entry = archive.by_index_raw(i).map_err(|e| e.to_string())?; let name = entry.name().to_string(); if entry.is_dir() { continue; } if entry.unix_mode().is_some_and(is_symlink_mode) { return Err(format!("The backup contains a symbolic link ({name:?}), which is not allowed")); } if name == MANIFEST_NAME { continue; } validate_entry_path(&name)?; if !expected.contains_key(name.as_str()) { return Err(format!("The backup contains a file that is not in its manifest: {name:?}")); } if index_of.insert(name.clone(), i).is_some() { return Err(format!("The backup contains {name:?} twice")); } } if let Some(missing) = expected.keys().find(|p| !index_of.contains_key(**p)) { return Err(format!("The backup is incomplete: {missing:?} is missing")); } dirs.clear_staging(); let result = (|| -> Result<(), String> { for f in &manifest.files { let first = f.path.split('/').next().unwrap_or(""); let target = f.path.split('/').fold(dirs.staging_for(first), |p, c| p.join(c)); extract_entry(&mut archive, index_of[&f.path], f, &target)?; } verify_staged_db(&dirs.staging_for(DB_ENTRY).join(DB_ENTRY), manifest.schema_version)?; // Keep the manifest beside the staged files so the apply step can re-check them. let manifest_bytes = serde_json::to_vec_pretty(&manifest).map_err(|e| e.to_string())?; write_atomic(&dirs.staging_for(DB_ENTRY).join(MANIFEST_NAME), &manifest_bytes) })(); if let Err(e) = result { dirs.clear_staging(); return Err(e); } let pending = PendingRestore { backup_name: zip_path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), backup_created_at: manifest.created_at.clone(), app_version: manifest.app_version.clone(), schema_version: manifest.schema_version, file_count: manifest.files.len(), total_bytes: manifest.files.iter().map(|f| f.size).sum(), staged_at: now.to_rfc3339(), }; let marker = serde_json::to_vec_pretty(&pending).map_err(|e| e.to_string())?; if let Err(e) = write_atomic(&dirs.marker(), &marker) { dirs.clear_staging(); return Err(e); } Ok(pending) } /// Validate and stage a backup; the swap happens on the next start (`apply_pending_restore`). pub fn stage_restore_impl(dirs: &DataDirs, zip_path: &Path, now: DateTime) -> Result { // A previous staged restore is superseded. let _ = fs::remove_file(dirs.marker()); stage_inner(dirs, zip_path, now) } pub fn cancel_pending_restore_impl(dirs: &DataDirs) -> Result<(), String> { dirs.clear_staging(); match fs::remove_file(dirs.marker()) { Ok(()) => Ok(()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(e) => Err(io_err("Could not remove", &dirs.marker(), e)), } } // ---------------------------------------------------------------- apply (before the DB opens) #[derive(Debug, PartialEq, Eq)] pub enum ApplyOutcome { NothingPending, Applied { safety_dir: PathBuf }, /// The old data is back in place. Failed { message: String }, } fn copy_recursive(src: &Path, dst: &Path) -> std::io::Result<()> { let meta = fs::symlink_metadata(src)?; if meta.is_dir() { fs::create_dir_all(dst)?; for entry in fs::read_dir(src)? { let entry = entry?; copy_recursive(&entry.path(), &dst.join(entry.file_name()))?; } } else if meta.is_file() { fs::copy(src, dst)?; } Ok(()) } /// Rename, falling back to copy-then-remove (a different volume, or a locked directory on Windows). The source /// is only removed after the copy succeeded, so a failure never loses data. fn move_path(src: &Path, dst: &Path) -> std::io::Result<()> { if let Some(parent) = dst.parent() { fs::create_dir_all(parent)?; } if fs::rename(src, dst).is_ok() { return Ok(()); } if let Err(e) = copy_recursive(src, dst) { let _ = if dst.is_dir() { fs::remove_dir_all(dst) } else { fs::remove_file(dst) }; return Err(e); } if src.is_dir() { // A locked or read-only parent can leave the emptied directory itself behind; the data is at `dst`. match fs::remove_dir_all(src) { Ok(()) => Ok(()), Err(e) => match fs::read_dir(src).map(|mut left| left.next().is_none()) { Ok(true) => Ok(()), _ => Err(e), }, } } else { fs::remove_file(src) } } fn unique_safety_dir(dirs: &DataDirs, now: DateTime) -> PathBuf { let base = dirs.backups().join(format!("pre-restore-{}", now.format("%Y%m%d-%H%M%S"))); let mut candidate = base.clone(); let mut n = 1; while candidate.exists() { n += 1; candidate = PathBuf::from(format!("{}-{n}", base.display())); } candidate } fn record_last_restore(dirs: &DataDirs, record: &LastRestore) { if let Ok(bytes) = serde_json::to_vec_pretty(record) { let _ = write_atomic(&dirs.last_restore(), &bytes); } } /// Process a staged restore. Call after the data directories exist and before the database is opened. pub fn apply_pending_restore(dirs: &DataDirs, now: DateTime) -> ApplyOutcome { apply_with_fault(dirs, now, None) } /// `fault_at` makes the n-th file move fail (tests only; production passes None) to exercise the rollback. fn apply_with_fault(dirs: &DataDirs, now: DateTime, fault_at: Option) -> ApplyOutcome { let marker_bytes = match fs::read(dirs.marker()) { Ok(b) => b, Err(_) => return ApplyOutcome::NothingPending, }; let pending: Result = serde_json::from_slice(&marker_bytes); let backup_name = pending.as_ref().map(|p| p.backup_name.clone()).unwrap_or_default(); let finish_failed = |message: String, safety: Option<&Path>| { let _ = fs::remove_file(dirs.marker()); dirs.clear_staging(); record_last_restore( dirs, &LastRestore { ok: false, at: now.to_rfc3339(), backup_name: backup_name.clone(), message: message.clone(), safety_dir: safety.map(|p| p.to_string_lossy().into_owned()), }, ); ApplyOutcome::Failed { message } }; if pending.is_err() { return finish_failed("The pending restore marker is damaged; the restore was skipped.".to_string(), None); } // Re-check the staged files (names, sizes and the DB) before touching anything. let manifest: Manifest = match fs::read(dirs.staging_for(DB_ENTRY).join(MANIFEST_NAME)) .map_err(|e| e.to_string()) .and_then(|b| serde_json::from_slice(&b).map_err(|e| e.to_string())) { Ok(m) => m, Err(e) => return finish_failed(format!("The staged restore is incomplete ({e}); the restore was skipped."), None), }; if let Err(e) = validate_manifest(&manifest) { return finish_failed(format!("The staged restore is invalid: {e}"), None); } for f in &manifest.files { let first = f.path.split('/').next().unwrap_or(""); let path = f.path.split('/').fold(dirs.staging_for(first), |p, c| p.join(c)); match fs::metadata(&path) { Ok(m) if m.is_file() && m.len() == f.size => {} _ => return finish_failed(format!("The staged file {:?} is missing or changed; the restore was skipped.", f.path), None), } } if let Err(e) = verify_staged_db(&dirs.staging_for(DB_ENTRY).join(DB_ENTRY), manifest.schema_version) { return finish_failed(e, None); } // Move the current state aside. Nothing is deleted. let safety = unique_safety_dir(dirs, now); let current: Vec<(PathBuf, PathBuf)> = { let mut v = vec![ (dirs.data.join("voiced.db"), safety.join("voiced.db")), (dirs.data.join("voiced.db-wal"), safety.join("voiced.db-wal")), (dirs.data.join("voiced.db-shm"), safety.join("voiced.db-shm")), ]; for root in ROOTS { v.push((dirs.root_dir(root), safety.join(root))); } v }; let mut moved_aside: Vec<&(PathBuf, PathBuf)> = Vec::new(); let mut installed: Vec<(PathBuf, PathBuf)> = Vec::new(); // (target, staged original) let mut created_empty: Vec = Vec::new(); let mut step = 0usize; let mut check_fault = || -> Result<(), String> { step += 1; if fault_at == Some(step) { return Err("injected failure".to_string()); } Ok(()) }; let swap = (|| -> Result<(), String> { for pair in ¤t { if fs::symlink_metadata(&pair.0).is_ok() { check_fault()?; move_path(&pair.0, &pair.1).map_err(|e| io_err("Could not move aside", &pair.0, e))?; moved_aside.push(pair); } } let staged_db = dirs.staging_for(DB_ENTRY).join(DB_ENTRY); check_fault()?; move_path(&staged_db, &dirs.db()).map_err(|e| io_err("Could not install", &dirs.db(), e))?; installed.push((dirs.db(), staged_db)); for root in ROOTS { let staged = dirs.staging_for(root).join(root); let target = dirs.root_dir(root); if staged.exists() { check_fault()?; move_path(&staged, &target).map_err(|e| io_err("Could not install", &target, e))?; installed.push((target, staged)); } else { fs::create_dir_all(&target).map_err(|e| io_err("Could not create", &target, e))?; created_empty.push(target); } } Ok(()) })(); match swap { Ok(()) => { let _ = fs::remove_file(dirs.marker()); dirs.clear_staging(); record_last_restore( dirs, &LastRestore { ok: true, at: now.to_rfc3339(), backup_name: backup_name.clone(), message: "Restored".to_string(), safety_dir: Some(safety.to_string_lossy().into_owned()), }, ); ApplyOutcome::Applied { safety_dir: safety } } Err(e) => { // Roll back: installed files go back to staging, moved-aside files back to their places. for dir in created_empty.iter().rev() { let _ = fs::remove_dir(dir); } for (target, staged) in installed.iter().rev() { let _ = move_path(target, staged); } let mut stuck = Vec::new(); for (orig, aside) in moved_aside.iter().rev().map(|p| (&p.0, &p.1)) { if move_path(aside, orig).is_err() { stuck.push(orig.display().to_string()); } } let mut message = format!("The restore failed and was rolled back: {e}"); if !stuck.is_empty() { message.push_str(&format!( ". Some files could not be put back; your previous data is in {}", safety.display() )); } finish_failed(message, Some(&safety)) } } } // ---------------------------------------------------------------- automatic backup fn auto_name(day: NaiveDate) -> String { format!("{AUTO_PREFIX}{}{AUTO_SUFFIX}", day.format("%Y%m%d")) } /// Files that are strictly `voiced-auto-YYYYMMDD.zip`, oldest first. fn auto_backups(dir: &Path) -> Vec<(String, PathBuf)> { let mut found = Vec::new(); if let Ok(entries) = fs::read_dir(dir) { for entry in entries.flatten() { let name = entry.file_name().to_string_lossy().into_owned(); let stamp = name.strip_prefix(AUTO_PREFIX).and_then(|n| n.strip_suffix(AUTO_SUFFIX)); if let Some(stamp) = stamp { if stamp.len() == 8 && stamp.bytes().all(|b| b.is_ascii_digit()) { found.push((stamp.to_string(), entry.path())); } } } } found.sort(); found } /// Keep the newest `keep` automatic backups; returns how many were deleted. pub fn prune_auto_backups(dir: &Path, keep: usize) -> usize { let all = auto_backups(dir); let excess = all.len().saturating_sub(keep); all.into_iter().take(excess).filter(|(_, p)| fs::remove_file(p).is_ok()).count() } /// Write today's automatic backup unless it already exists, then prune. Returns the new file, if any. pub fn run_auto_backup(dirs: &DataDirs, now: DateTime) -> Result, String> { let dir = dirs.auto_dir(); fs::create_dir_all(&dir).map_err(|e| io_err("Could not create", &dir, e))?; // Leftovers of an interrupted run (only this job writes into this folder). if let Ok(entries) = fs::read_dir(&dir) { for entry in entries.flatten() { let name = entry.file_name().to_string_lossy().into_owned(); if name.ends_with(".part") || name.ends_with(".db.tmp") { let _ = fs::remove_file(entry.path()); } } } let target = dir.join(auto_name(now.date_naive())); let created = if target.exists() { None } else { create_backup_impl(dirs, &target, now)?; Some(target) }; prune_auto_backups(&dir, AUTO_KEEP); Ok(created) } fn read_auto_backup_flag(db_path: &Path) -> bool { Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY) .and_then(|c| c.query_row("SELECT auto_backup FROM app_settings WHERE id = 1", [], |r| r.get::<_, i64>(0))) .map(|v| v != 0) .unwrap_or(true) } /// Start the background job: first check shortly after startup, then hourly while the app stays open. /// Never panics out of the thread and never blocks startup. pub fn spawn_auto_backup(dirs: DataDirs) { if std::env::var_os("VOICED_SELFTEST_OUT").is_some() { return; } let spawned = std::thread::Builder::new().name("auto-backup".into()).spawn(move || { std::thread::sleep(std::time::Duration::from_secs(8)); loop { if read_auto_backup_flag(&dirs.db()) { let dirs = dirs.clone(); let outcome = std::panic::catch_unwind(move || run_auto_backup(&dirs, Local::now())); match outcome { Ok(Ok(Some(path))) => eprintln!("Automatic backup written to {}", path.display()), Ok(Ok(None)) => {} Ok(Err(e)) => eprintln!("Automatic backup failed: {e}"), Err(_) => eprintln!("Automatic backup panicked"), } } std::thread::sleep(std::time::Duration::from_secs(3600)); } }); if let Err(e) = spawned { eprintln!("Could not start the automatic backup thread: {e}"); } } // ---------------------------------------------------------------- status and restart pub fn backup_status_impl(dirs: &DataDirs, auto_backup: bool) -> BackupStatus { let auto_dir = dirs.auto_dir(); let _ = fs::create_dir_all(&auto_dir); // so "open folder" always has something to open let all = auto_backups(&auto_dir); let last_auto_backup = all.last().and_then(|(_, p)| { let modified = fs::metadata(p).and_then(|m| m.modified()).ok()?; Some(DateTime::::from(modified).to_rfc3339()) }); BackupStatus { auto_backup, auto_dir: auto_dir.to_string_lossy().into_owned(), last_auto_backup, auto_backup_count: all.len(), pending_restore: fs::read(dirs.marker()).ok().and_then(|b| serde_json::from_slice(&b).ok()), last_restore: fs::read(dirs.last_restore()).ok().and_then(|b| serde_json::from_slice(&b).ok()), } } /// Milliseconds a relaunched process waits before starting (set by `restart_app`), capped at 10 s. pub fn restart_delay_from_env(value: Option<&str>) -> Option { value.and_then(|v| v.trim().parse::().ok()).map(|ms| ms.min(10_000)) } /// Call first thing in `run()`: honour and clear the relaunch delay. pub fn wait_if_relaunched() { if let Ok(v) = std::env::var("VOICED_RESTART_DELAY_MS") { std::env::remove_var("VOICED_RESTART_DELAY_MS"); if let Some(ms) = restart_delay_from_env(Some(&v)) { std::thread::sleep(std::time::Duration::from_millis(ms)); } } } // ---------------------------------------------------------------- Tauri commands fn auto_backup_flag(conn: &Connection) -> Result { conn.query_row("SELECT auto_backup FROM app_settings WHERE id = 1", [], |r| r.get::<_, i64>(0)) .map(|v| v != 0) .map_err(|e| e.to_string()) } #[tauri::command] pub async fn create_backup(state: State<'_, AppState>, dest: String) -> Result { let dirs = DataDirs::from_state(&state); tauri::async_runtime::spawn_blocking(move || create_backup_impl(&dirs, Path::new(&dest), Local::now())) .await .map_err(|e| e.to_string())? } /// Phase one of a restore: validate and stage. The app must be restarted to apply it. #[tauri::command] pub async fn restore_backup(state: State<'_, AppState>, path: String) -> Result { let dirs = DataDirs::from_state(&state); tauri::async_runtime::spawn_blocking(move || stage_restore_impl(&dirs, Path::new(&path), Local::now())) .await .map_err(|e| e.to_string())? } #[tauri::command] pub fn cancel_pending_restore(state: State) -> Result<(), String> { cancel_pending_restore_impl(&DataDirs::from_state(&state)) } #[tauri::command] pub fn get_backup_status(state: State) -> Result { let enabled = { let conn = state.db.lock().map_err(|e| e.to_string())?; auto_backup_flag(&conn)? }; Ok(backup_status_impl(&DataDirs::from_state(&state), enabled)) } #[tauri::command] pub fn set_auto_backup(state: State, enabled: bool) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute("UPDATE app_settings SET auto_backup = ?1 WHERE id = 1", [enabled as i64]) .map_err(|e| e.to_string())?; auto_backup_flag(&conn) } /// Relaunch the app so a staged restore is applied. The new process waits briefly (see `wait_if_relaunched`) so /// the single-instance lock of this one is gone, then this process exits normally. #[tauri::command] pub fn restart_app(app: tauri::AppHandle) -> Result<(), String> { let exe = std::env::current_exe().map_err(|e| format!("Could not find the application: {e}"))?; std::process::Command::new(exe) .args(std::env::args_os().skip(1)) .env("VOICED_RESTART_DELAY_MS", "1500") .spawn() .map_err(|e| format!("Could not restart: {e}"))?; app.exit(0); Ok(()) } #[cfg(test)] mod tests { use super::*; use rusqlite::params; use tempfile::{tempdir, TempDir}; const PDF: &[u8] = b"%PDF-1.7 archived invoice bytes"; const FONT: &[u8] = b"\x00\x01\x00\x00fake-ttf-bytes"; const LOGO: &[u8] = b"\x89PNG fake logo"; struct Env { _root: TempDir, dirs: DataDirs, } /// Separate data and local dirs (like Windows) so cross-root handling is exercised. fn env() -> Env { let root = tempdir().unwrap(); let data = root.path().join("data"); let local = root.path().join("local"); fs::create_dir_all(&data).unwrap(); fs::create_dir_all(&local).unwrap(); let dirs = DataDirs::new(&data, &local); Env { _root: root, dirs } } fn sha(bytes: &[u8]) -> String { hex(&Sha256::digest(bytes)) } /// A populated data set: client, issued invoice, payment, user_fonts row, plus asset, archive and font files. fn populate(dirs: &DataDirs, tag: &str) { let conn = crate::db::open(&dirs.db(), &dirs.backups()).unwrap(); conn.execute( "INSERT INTO clients (name, address, gstin, state_code, created_at) VALUES (?1, 'Addr', '29ABCDE1234F1Z5', '29', 'now')", params![format!("Client {tag}")], ) .unwrap(); conn.execute( "INSERT INTO invoices (number, invoice_date, client_name, total, status, created_at, updated_at, archived_pdf_sha256) VALUES (?1, '2026-04-01', ?2, 1180.5, 'issued', 'now', 'now', ?3)", params![format!("INV/{tag}-001"), format!("Client {tag}"), sha(PDF)], ) .unwrap(); let invoice_id = conn.last_insert_rowid(); conn.execute( "INSERT INTO payments (invoice_id, paid_on, amount_paise, tds_paise, mode, created_at) VALUES (?1, '2026-04-20', 100000, 5000, 'upi', 'now')", params![invoice_id], ) .unwrap(); conn.execute( "INSERT INTO user_fonts (face, family_name, full_name, weight, style, sha256, file_name, format, size, licence_ack_at, imported_at) VALUES ('Now', 'Now', 'Now Regular', 400, 'normal', ?1, 'Now.ttf', 'ttf', ?2, 'now', 'now')", params![sha(FONT), FONT.len() as i64], ) .unwrap(); conn.pragma_update(None, "wal_checkpoint", "TRUNCATE").ok(); drop(conn); fs::create_dir_all(dirs.root_dir("assets")).unwrap(); fs::write(dirs.root_dir("assets").join(format!("logo-{tag}.png")), LOGO).unwrap(); fs::create_dir_all(dirs.root_dir("archive")).unwrap(); fs::write(dirs.root_dir("archive").join(format!("{}.pdf", sha(PDF))), PDF).unwrap(); fs::create_dir_all(dirs.root_dir("fonts")).unwrap(); fs::write(dirs.root_dir("fonts").join(format!("{}.ttf", sha(FONT))), FONT).unwrap(); } type Snapshot = (Vec, Vec, Vec, Vec, i64); fn rows(conn: &Connection, sql: &str) -> Vec { let mut stmt = conn.prepare(sql).unwrap(); let n = stmt.column_count(); stmt.query_map([], |r| { Ok((0..n) .map(|i| format!("{:?}", r.get_ref(i).unwrap())) .collect::>() .join("|")) }) .unwrap() .map(|r| r.unwrap()) .collect() } fn snapshot(dirs: &DataDirs) -> Snapshot { let conn = Connection::open_with_flags(dirs.db(), OpenFlags::SQLITE_OPEN_READ_ONLY).unwrap(); ( rows(&conn, "SELECT * FROM clients ORDER BY id"), rows(&conn, "SELECT * FROM invoices ORDER BY id"), rows(&conn, "SELECT * FROM payments ORDER BY id"), rows(&conn, "SELECT * FROM user_fonts ORDER BY id"), user_version(&conn).unwrap(), ) } fn now() -> DateTime { Local::now() } /// Re-write a zip, letting `edit` change or drop each entry. fn rewrite_zip(src: &Path, dst: &Path, mut edit: impl FnMut(&str, Vec) -> Option>) { let mut input = zip::ZipArchive::new(File::open(src).unwrap()).unwrap(); let mut out = zip::ZipWriter::new(File::create(dst).unwrap()); for i in 0..input.len() { let mut entry = input.by_index(i).unwrap(); let name = entry.name().to_string(); let mut bytes = Vec::new(); entry.read_to_end(&mut bytes).unwrap(); if let Some(bytes) = edit(&name, bytes) { out.start_file(&name, zip::write::SimpleFileOptions::default()).unwrap(); out.write_all(&bytes).unwrap(); } } out.finish().unwrap(); } /// Hand-built zip from (name, bytes) pairs plus a manifest listing `listed`. fn handmade(dst: &Path, entries: &[(&str, &[u8])], listed: &[(&str, &[u8])], schema: i64) { let manifest = Manifest { format: BACKUP_FORMAT.into(), app_version: "test".into(), schema_version: schema, created_at: "now".into(), files: listed .iter() .map(|(p, b)| ManifestFile { path: p.to_string(), sha256: sha(b), size: b.len() as u64 }) .collect(), }; let mut out = zip::ZipWriter::new(File::create(dst).unwrap()); for (name, bytes) in entries { out.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap(); out.write_all(bytes).unwrap(); } out.start_file(MANIFEST_NAME, zip::write::SimpleFileOptions::default()).unwrap(); out.write_all(&serde_json::to_vec(&manifest).unwrap()).unwrap(); out.finish().unwrap(); } fn assert_clean(dirs: &DataDirs) { assert!(!dirs.marker().exists(), "no marker may be left behind"); for dir in dirs.staging_dirs() { assert!(!dir.exists(), "no staging may be left behind"); } } #[test] fn round_trip_restores_rows_and_files_byte_for_byte() { let src = env(); populate(&src.dirs, "A"); let before = snapshot(&src.dirs); assert_eq!(before.0.len(), 1); assert_eq!(before.2.len(), 1); assert_eq!(before.3.len(), 1); let zip_path = src.dirs.backups().join("manual.zip"); let summary = create_backup_impl(&src.dirs, &zip_path, now()).unwrap(); assert_eq!(summary.schema_version, LATEST_VERSION); assert_eq!(summary.file_count, 4); // db + logo + pdf + font assert!(!zip_path.with_extension("zip.part").exists()); // The manifest verifies against the zip contents. let mut archive = zip::ZipArchive::new(File::open(&zip_path).unwrap()).unwrap(); let manifest = read_manifest(&mut archive).unwrap(); validate_manifest(&manifest).unwrap(); assert_eq!(manifest.app_version, env!("CARGO_PKG_VERSION")); assert_eq!(manifest.schema_version, LATEST_VERSION); let pdf_entry = format!("archive/{}.pdf", sha(PDF)); let listed = manifest.files.iter().find(|f| f.path == pdf_entry).unwrap(); assert_eq!((listed.sha256.as_str(), listed.size), (sha(PDF).as_str(), PDF.len() as u64)); // A different machine state to restore over: other rows and files, to be set aside. let dst = env(); populate(&dst.dirs, "B"); fs::write(dst.dirs.root_dir("assets").join("only-in-b.png"), b"b").unwrap(); let before_b = snapshot(&dst.dirs); assert_ne!(before_b, before); let staged = stage_restore_impl(&dst.dirs, &zip_path, now()).unwrap(); assert_eq!(staged.file_count, 4); assert_eq!(snapshot(&dst.dirs), before_b, "staging must not touch live data"); assert_eq!(backup_status_impl(&dst.dirs, true).pending_restore.unwrap().file_count, 4); let outcome = apply_pending_restore(&dst.dirs, now()); let ApplyOutcome::Applied { safety_dir } = outcome else { panic!("{outcome:?}") }; assert_clean(&dst.dirs); assert_eq!(snapshot(&dst.dirs), before); assert_eq!(fs::read(dst.dirs.root_dir("assets").join("logo-A.png")).unwrap(), LOGO); assert!(!dst.dirs.root_dir("assets").join("only-in-b.png").exists()); assert_eq!(fs::read(dst.dirs.root_dir("archive").join(format!("{}.pdf", sha(PDF)))).unwrap(), PDF); assert_eq!(fs::read(dst.dirs.root_dir("fonts").join(format!("{}.ttf", sha(FONT)))).unwrap(), FONT); // The old state is intact in the safety copy, not deleted. assert!(safety_dir.starts_with(dst.dirs.backups())); assert_eq!(fs::read(safety_dir.join("assets").join("only-in-b.png")).unwrap(), b"b"); let aside = Connection::open_with_flags(safety_dir.join("voiced.db"), OpenFlags::SQLITE_OPEN_READ_ONLY).unwrap(); assert_eq!(rows(&aside, "SELECT * FROM clients ORDER BY id"), before_b.0); let last = backup_status_impl(&dst.dirs, true).last_restore.unwrap(); assert!(last.ok); // The restored DB opens normally through the app's own open path. drop(crate::db::open(&dst.dirs.db(), &dst.dirs.backups()).unwrap()); assert_eq!(apply_pending_restore(&dst.dirs, now()), ApplyOutcome::NothingPending); } #[test] fn restore_into_empty_dirs_works_and_taking_backup_while_open_is_consistent() { let src = env(); populate(&src.dirs, "A"); // Keep a connection open (as the running app does) while backing up. let live = crate::db::open(&src.dirs.db(), &src.dirs.backups()).unwrap(); live.execute("UPDATE clients SET name = 'Live edit'", []).unwrap(); let zip_path = src.dirs.backups().join("live.zip"); create_backup_impl(&src.dirs, &zip_path, now()).unwrap(); drop(live); let dst = env(); stage_restore_impl(&dst.dirs, &zip_path, now()).unwrap(); assert!(matches!(apply_pending_restore(&dst.dirs, now()), ApplyOutcome::Applied { .. })); let conn = Connection::open_with_flags(dst.dirs.db(), OpenFlags::SQLITE_OPEN_READ_ONLY).unwrap(); let name: String = conn.query_row("SELECT name FROM clients", [], |r| r.get(0)).unwrap(); assert_eq!(name, "Live edit"); } #[test] fn tampered_file_is_rejected_and_nothing_is_staged() { let src = env(); populate(&src.dirs, "A"); let good = src.dirs.backups().join("good.zip"); create_backup_impl(&src.dirs, &good, now()).unwrap(); let bad = src.dirs.backups().join("bad.zip"); let mut changed = false; rewrite_zip(&good, &bad, |name, mut bytes| { if name.starts_with("archive/") { *bytes.last_mut().unwrap() ^= 0xff; // same size, different content changed = true; } Some(bytes) }); assert!(changed); let dst = env(); let err = stage_restore_impl(&dst.dirs, &bad, now()).unwrap_err(); assert!(err.contains("checksum"), "{err}"); assert_clean(&dst.dirs); assert!(!dst.dirs.db().exists()); // A listed file that is missing, and an extra file that is not listed, are both refused. let missing = src.dirs.backups().join("missing.zip"); rewrite_zip(&good, &missing, |name, bytes| (!name.starts_with("fonts/")).then_some(bytes)); assert!(stage_restore_impl(&dst.dirs, &missing, now()).unwrap_err().contains("incomplete")); let extra = src.dirs.backups().join("extra.zip"); let mut input = zip::ZipArchive::new(File::open(&good).unwrap()).unwrap(); let mut out = zip::ZipWriter::new(File::create(&extra).unwrap()); for i in 0..input.len() { let entry = input.by_index_raw(i).unwrap(); out.raw_copy_file(entry).unwrap(); } out.start_file("assets/sneaky.png", zip::write::SimpleFileOptions::default()).unwrap(); out.write_all(b"x").unwrap(); out.finish().unwrap(); assert!(stage_restore_impl(&dst.dirs, &extra, now()).unwrap_err().contains("not in its manifest")); assert_clean(&dst.dirs); } #[test] fn path_traversal_and_foreign_names_are_rejected() { let dst = env(); let outside = dst.dirs.data.parent().unwrap().join("evil.txt"); for name in ["../evil.txt", "assets/../../evil.txt", "/tmp/evil.txt", "assets\\..\\evil.txt", "C:/evil.txt", "other/x", "assets/a:b", "assets//x"] { let zip_path = dst.dirs.data.parent().unwrap().join("evil.zip"); handmade(&zip_path, &[(DB_ENTRY, b"x"), (name, b"x")], &[(DB_ENTRY, b"x"), (name, b"x")], LATEST_VERSION); let err = stage_restore_impl(&dst.dirs, &zip_path, now()).unwrap_err(); assert!(err.contains("Unsafe file name"), "{name}: {err}"); assert!(!outside.exists()); assert_clean(&dst.dirs); } // Names only in the zip (not the manifest) are caught too. let zip_path = dst.dirs.data.parent().unwrap().join("evil2.zip"); handmade(&zip_path, &[(DB_ENTRY, b"x"), ("../evil.txt", b"x")], &[(DB_ENTRY, b"x")], LATEST_VERSION); assert!(stage_restore_impl(&dst.dirs, &zip_path, now()).is_err()); assert!(!outside.exists()); } #[test] fn symlink_entries_are_rejected() { let dst = env(); let zip_path = dst.dirs.data.parent().unwrap().join("link.zip"); let manifest = Manifest { format: BACKUP_FORMAT.into(), app_version: "t".into(), schema_version: LATEST_VERSION, created_at: "now".into(), files: vec![ManifestFile { path: "assets/link".into(), sha256: sha(b"/etc/passwd"), size: 11 }, ManifestFile { path: DB_ENTRY.into(), sha256: sha(b"x"), size: 1 }], }; let mut out = zip::ZipWriter::new(File::create(&zip_path).unwrap()); out.start_file(DB_ENTRY, zip::write::SimpleFileOptions::default()).unwrap(); out.write_all(b"x").unwrap(); out.add_symlink("assets/link", "/etc/passwd", zip::write::SimpleFileOptions::default()).unwrap(); out.start_file(MANIFEST_NAME, zip::write::SimpleFileOptions::default()).unwrap(); out.write_all(&serde_json::to_vec(&manifest).unwrap()).unwrap(); out.finish().unwrap(); let err = stage_restore_impl(&dst.dirs, &zip_path, now()).unwrap_err(); assert!(err.contains("symbolic link"), "{err}"); assert_clean(&dst.dirs); } #[test] fn newer_schema_is_rejected() { let dst = env(); // Manifest says newer. let zip_path = dst.dirs.data.parent().unwrap().join("newer.zip"); handmade(&zip_path, &[(DB_ENTRY, b"x")], &[(DB_ENTRY, b"x")], LATEST_VERSION + 1); let err = stage_restore_impl(&dst.dirs, &zip_path, now()).unwrap_err(); assert!(err.contains("newer version"), "{err}"); assert_clean(&dst.dirs); // Manifest lies, the database itself is newer: caught after extraction, staging removed. let src = env(); populate(&src.dirs, "A"); Connection::open(src.dirs.db()) .unwrap() .pragma_update(None, "user_version", LATEST_VERSION + 1) .unwrap(); let good = src.dirs.backups().join("v.zip"); create_backup_impl(&src.dirs, &good, now()).unwrap(); let lie = src.dirs.backups().join("lie.zip"); rewrite_zip(&good, &lie, |name, bytes| { if name != MANIFEST_NAME { return Some(bytes); } let mut m: Manifest = serde_json::from_slice(&bytes).unwrap(); assert_eq!(m.schema_version, LATEST_VERSION + 1); m.schema_version = LATEST_VERSION; Some(serde_json::to_vec(&m).unwrap()) }); let err = stage_restore_impl(&dst.dirs, &lie, now()).unwrap_err(); assert!(err.contains("newer version") || err.contains("does not match"), "{err}"); assert_clean(&dst.dirs); } #[test] fn corrupt_database_fails_integrity_and_garbage_zip_is_refused() { let dst = env(); let zip_path = dst.dirs.data.parent().unwrap().join("garbage.zip"); handmade(&zip_path, &[(DB_ENTRY, b"this is not sqlite")], &[(DB_ENTRY, b"this is not sqlite")], LATEST_VERSION); assert!(stage_restore_impl(&dst.dirs, &zip_path, now()).is_err()); assert_clean(&dst.dirs); let not_zip = dst.dirs.data.parent().unwrap().join("x.zip"); fs::write(¬_zip, b"hello").unwrap(); assert!(stage_restore_impl(&dst.dirs, ¬_zip, now()).unwrap_err().contains("not a valid backup")); } #[test] fn damaged_staging_is_refused_and_old_data_kept() { let src = env(); populate(&src.dirs, "A"); let zip_path = src.dirs.backups().join("a.zip"); create_backup_impl(&src.dirs, &zip_path, now()).unwrap(); let dst = env(); populate(&dst.dirs, "B"); let before_b = snapshot(&dst.dirs); stage_restore_impl(&dst.dirs, &zip_path, now()).unwrap(); // Sabotage a staged file after staging: the size check refuses before anything is moved. let staged_pdf = dst.dirs.staging_for("archive").join("archive").join(format!("{}.pdf", sha(PDF))); fs::write(&staged_pdf, b"short").unwrap(); let outcome = apply_pending_restore(&dst.dirs, now()); assert!(matches!(outcome, ApplyOutcome::Failed { .. }), "{outcome:?}"); assert_eq!(snapshot(&dst.dirs), before_b); assert!(dst.dirs.root_dir("assets").join("logo-B.png").exists()); assert_clean(&dst.dirs); assert!(!backup_status_impl(&dst.dirs, true).last_restore.unwrap().ok); } #[test] fn a_failure_at_any_step_of_the_swap_rolls_back_to_the_old_data() { let src = env(); populate(&src.dirs, "A"); let zip_path = src.dirs.backups().join("a.zip"); create_backup_impl(&src.dirs, &zip_path, now()).unwrap(); let mut failed_steps = 0; for fault in 1..=12 { let dst = env(); populate(&dst.dirs, "B"); let before_b = snapshot(&dst.dirs); stage_restore_impl(&dst.dirs, &zip_path, now()).unwrap(); match apply_with_fault(&dst.dirs, now(), Some(fault)) { ApplyOutcome::Applied { .. } => { assert!(fault > failed_steps, "steps past the last move succeed"); break; } ApplyOutcome::Failed { message } => { failed_steps = fault; assert!(message.contains("rolled back"), "{message}"); assert_eq!(snapshot(&dst.dirs), before_b, "fault {fault}"); assert_eq!(fs::read(dst.dirs.root_dir("assets").join("logo-B.png")).unwrap(), LOGO); assert_eq!(fs::read(dst.dirs.root_dir("archive").join(format!("{}.pdf", sha(PDF)))).unwrap(), PDF); assert_eq!(fs::read(dst.dirs.root_dir("fonts").join(format!("{}.ttf", sha(FONT)))).unwrap(), FONT); assert!(!dst.dirs.root_dir("assets").join("logo-A.png").exists()); assert_clean(&dst.dirs); assert!(!backup_status_impl(&dst.dirs, true).last_restore.unwrap().ok); } ApplyOutcome::NothingPending => panic!("marker vanished"), } } // 5 moves aside (db, assets, archive, fonts; no wal/shm after checkpoint) + 4 installs. assert!(failed_steps >= 8, "only {failed_steps} steps were exercised"); } #[test] fn cancel_removes_staging_and_marker() { let src = env(); populate(&src.dirs, "A"); let zip_path = src.dirs.backups().join("a.zip"); create_backup_impl(&src.dirs, &zip_path, now()).unwrap(); let dst = env(); stage_restore_impl(&dst.dirs, &zip_path, now()).unwrap(); cancel_pending_restore_impl(&dst.dirs).unwrap(); assert_clean(&dst.dirs); assert_eq!(apply_pending_restore(&dst.dirs, now()), ApplyOutcome::NothingPending); } #[test] fn backup_destination_inside_the_data_folders_is_refused() { let e = env(); populate(&e.dirs, "A"); let inside = e.dirs.root_dir("assets").join("b.zip"); assert!(create_backup_impl(&e.dirs, &inside, now()).is_err()); assert!(create_backup_impl(&e.dirs, Path::new("relative.zip"), now()).is_err()); } #[test] fn auto_retention_keeps_fourteen_and_prunes_the_oldest() { let dir = tempdir().unwrap(); for day in 1..=20 { fs::write(dir.path().join(format!("voiced-auto-202601{day:02}.zip")), b"z").unwrap(); } fs::write(dir.path().join("notes.txt"), b"keep me").unwrap(); fs::write(dir.path().join("voiced-auto-bad.zip"), b"keep me too").unwrap(); assert_eq!(prune_auto_backups(dir.path(), AUTO_KEEP), 6); let names: Vec = auto_backups(dir.path()).into_iter().map(|(s, _)| s).collect(); assert_eq!(names.len(), 14); assert_eq!(names.first().unwrap(), "20260107"); assert_eq!(names.last().unwrap(), "20260120"); assert!(dir.path().join("notes.txt").exists()); assert!(dir.path().join("voiced-auto-bad.zip").exists()); assert_eq!(prune_auto_backups(dir.path(), AUTO_KEEP), 0); } #[test] fn auto_backup_runs_once_per_day_and_prunes() { let e = env(); populate(&e.dirs, "A"); let day = |d: u32| Local.with_ymd_and_hms(2026, 3, d, 10, 0, 0).unwrap(); use chrono::TimeZone; let first = run_auto_backup(&e.dirs, day(1)).unwrap().unwrap(); assert!(first.ends_with("voiced-auto-20260301.zip")); assert_eq!(run_auto_backup(&e.dirs, day(1)).unwrap(), None, "second run the same day does nothing"); for d in 2..=16 { assert!(run_auto_backup(&e.dirs, day(d)).unwrap().is_some()); } let kept = auto_backups(&e.dirs.auto_dir()); assert_eq!(kept.len(), AUTO_KEEP); assert_eq!(kept.first().unwrap().0, "20260303"); // No temp leftovers, and the files are valid backups. let leftovers = fs::read_dir(e.dirs.auto_dir()) .unwrap() .flatten() .filter(|f| !f.file_name().to_string_lossy().ends_with(".zip")) .count(); assert_eq!(leftovers, 0); let dst = env(); stage_restore_impl(&dst.dirs, &kept.last().unwrap().1, now()).unwrap(); let status = backup_status_impl(&e.dirs, true); assert_eq!(status.auto_backup_count, AUTO_KEEP); assert!(status.last_auto_backup.is_some()); } #[test] fn restart_delay_is_parsed_and_capped() { assert_eq!(restart_delay_from_env(Some("1500")), Some(1500)); assert_eq!(restart_delay_from_env(Some("999999")), Some(10_000)); assert_eq!(restart_delay_from_env(Some("abc")), None); assert_eq!(restart_delay_from_env(None), None); } #[test] fn entry_paths_allow_only_data_files() { for ok in ["voiced.db", "assets/logo.png", "archive/ab.pdf", "fonts/x.ttf", "assets/sub/dir/f.png"] { validate_entry_path(ok).unwrap(); } for bad in ["", "voiced.db/", "manifest.json", "backups/x", "assets", "assets/", "assets/./x", "assets/x.", "assets/ "] { assert!(validate_entry_path(bad).is_err(), "{bad:?}"); } } }