Files
Voiced/src-tauri/src/commands/backup.rs
T
xavierk 92f952b136 Add backup/restore, daily auto-backup and history CSV/JSON export (Phase E3)
- create_backup writes a zip (manifest with sha256 per file, a VACUUM INTO database snapshot, assets, archive, fonts) atomically. restore_backup validates the manifest, rejects tampered files, path traversal, symlinks and newer schemas, checks the staged DB with integrity_check, then stages a pending restore. The swap runs on the next start before the database opens, moves the current data to backups/pre-restore-<ts>/ and rolls back on any failure.
- Daily automatic backup (migration M9 auto_backup, default on) keeps the newest 14 and never blocks startup.
- Settings Data tab: back up now, restore with confirmation and restart, auto-backup toggle, last backup time.
- History exports the filtered rows as CSV (UTF-8 BOM, CRLF, formula-injection guard) or JSON (voiced.history.v1) with CA-friendly tax columns from a new list_invoice_ledger command.
- Rust round-trip, rejection, swap-rollback and retention tests; vitest for the export and backup helpers.

Adds the zip 8.6.0 and typed-path 0.12.3 crates (pure Rust); package builds must vendor them. PDF output and fingerprint goldens are unchanged. The new UI has not been run in a webview yet.
2026-10-04 18:13:57 +05:30

1503 lines
63 KiB
Rust

//! 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 `<dir>/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 `<data>/pending_restore.json` written. The live data is not touched.
//! Staging lives in the same directory as its destination (`<data>` for the DB and assets, `<local>` 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
//! `<data>/backups/pre-restore-<timestamp>/` (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
//! `<data>/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 `<data>/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<ManifestFile>,
}
#[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<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BackupStatus {
pub auto_backup: bool,
pub auto_dir: String,
pub last_auto_backup: Option<String>,
pub auto_backup_count: usize,
pub pending_restore: Option<PendingRestore>,
pub last_restore: Option<LastRestore>,
}
// ---------------------------------------------------------------- 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<PathBuf, String> {
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<i64> {
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<W: Write + std::io::Seek>(
zip: &mut zip::ZipWriter<W>,
entry: &str,
src: &Path,
) -> Result<Option<ManifestFile>, 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<Local>) -> Result<BackupSummary, String> {
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 `<dest>.part`, then rename.
let part = sibling_with_suffix(dest, ".part")?;
let part_guard = TempFile(part.clone());
let mut files: Vec<ManifestFile> = 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<File>) -> Result<Manifest, String> {
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<File>,
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<Local>) -> Result<PendingRestore, String> {
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<String, usize> = 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<Local>) -> Result<PendingRestore, String> {
// 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<Local>) -> 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<Local>) -> 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<Local>, fault_at: Option<usize>) -> ApplyOutcome {
let marker_bytes = match fs::read(dirs.marker()) {
Ok(b) => b,
Err(_) => return ApplyOutcome::NothingPending,
};
let pending: Result<PendingRestore, _> = 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<PathBuf> = 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 &current {
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<Local>) -> Result<Option<PathBuf>, 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::<Local>::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<u64> {
value.and_then(|v| v.trim().parse::<u64>().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<bool, String> {
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<BackupSummary, String> {
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<PendingRestore, String> {
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<AppState>) -> Result<(), String> {
cancel_pending_restore_impl(&DataDirs::from_state(&state))
}
#[tauri::command]
pub fn get_backup_status(state: State<AppState>) -> Result<BackupStatus, String> {
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<AppState>, enabled: bool) -> Result<bool, String> {
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<String>, Vec<String>, Vec<String>, Vec<String>, i64);
fn rows(conn: &Connection, sql: &str) -> Vec<String> {
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::<Vec<_>>()
.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> {
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<u8>) -> Option<Vec<u8>>) {
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(&not_zip, b"hello").unwrap();
assert!(stage_restore_impl(&dst.dirs, &not_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<String> = 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:?}");
}
}
}