- 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.
1503 lines
63 KiB
Rust
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 ¤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<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(¬_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<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:?}");
|
|
}
|
|
}
|
|
}
|