use std::fs;
use std::io::{Read, Write};
use std::iter;
use std::path::Path;
use std::time::Duration;
use age::secrecy::SecretString;
use rusqlite::{Connection, OptionalExtension, backup::Backup};
use serde::Serialize;
use crate::error::{Result, WorthweaveError};
use crate::{db, market, projections};
const MAX_BACKUP_BYTES: u64 = 1024 * 1024 * 1024;
fn validate(path: &Path, password: &str) -> Result<()> {
if password.chars().count() < 12 {
return Err(WorthweaveError::Backup(
"password must contain at least 12 characters".into(),
));
}
if path.as_os_str().is_empty() {
return Err(WorthweaveError::Backup("backup path is required".into()));
}
let valid_extension = path
.extension()
.and_then(|value| value.to_str())
.is_some_and(|value| {
value.eq_ignore_ascii_case("age") || value.eq_ignore_ascii_case("worthweave-age")
});
if !valid_extension {
return Err(WorthweaveError::Backup(
"encrypted backups must use the .age extension".into(),
));
}
Ok(())
}
fn database_snapshot(connection: &Connection) -> Result<tempfile::NamedTempFile> {
let file = tempfile::NamedTempFile::new()?;
let mut destination = Connection::open(file.path())?;
Backup::new(connection, &mut destination)?.run_to_completion(
128,
Duration::from_millis(5),
None,
)?;
drop(destination);
Ok(file)
}
fn destination_temp(path: &Path) -> Result<tempfile::NamedTempFile> {
let parent = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
tempfile::Builder::new()
.prefix(".worthweave-")
.tempfile_in(parent)
.map_err(Into::into)
}
fn persist(temp: tempfile::NamedTempFile, path: &Path) -> Result<()> {
temp.persist(path)
.map(|_| ())
.map_err(|error| WorthweaveError::Io(error.error))
}
pub fn create(connection: &Connection, path: &Path, password: String) -> Result<()> {
validate(path, &password)?;
let plaintext = database_snapshot(connection)?;
let encryptor = age::Encryptor::with_user_passphrase(SecretString::from(password));
let mut encrypted = destination_temp(path)?;
let mut writer = encryptor
.wrap_output(encrypted.as_file_mut())
.map_err(|error| WorthweaveError::Backup(error.to_string()))?;
let mut source = fs::File::open(plaintext.path())?;
std::io::copy(&mut source, &mut writer)?;
writer
.finish()
.map_err(|error| WorthweaveError::Backup(error.to_string()))?;
encrypted.as_file().sync_all()?;
persist(encrypted, path)
}
pub fn restore(connection: &mut Connection, path: &Path, password: String) -> Result<()> {
validate(path, &password)?;
if fs::metadata(path)?.len() > MAX_BACKUP_BYTES {
return Err(WorthweaveError::Backup(
"backup exceeds the 1 GiB restore limit".into(),
));
}
let encrypted = fs::File::open(path)?;
let decryptor = age::Decryptor::new(encrypted)
.map_err(|error| WorthweaveError::Backup(error.to_string()))?;
let identity = age::scrypt::Identity::new(SecretString::from(password));
let reader = decryptor.decrypt(iter::once(&identity as _)).map_err(|_| {
WorthweaveError::Backup("password is incorrect or backup is damaged".into())
})?;
let mut file = tempfile::NamedTempFile::new()?;
let copied = std::io::copy(&mut reader.take(MAX_BACKUP_BYTES + 1), file.as_file_mut())?;
if copied > MAX_BACKUP_BYTES {
return Err(WorthweaveError::Backup(
"decrypted backup exceeds the restore limit".into(),
));
}
file.as_file_mut().flush()?;
let source = Connection::open(file.path())?;
let integrity: String = source.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
if integrity != "ok" {
return Err(WorthweaveError::Backup(
"backup database failed integrity validation".into(),
));
}
let version: i64 = source.query_row("PRAGMA user_version", [], |row| row.get(0))?;
if !(1..=db::SCHEMA_VERSION).contains(&version) {
return Err(WorthweaveError::Backup(
"backup schema version is not supported by this Worthweave release".into(),
));
}
let foreign_key_violation: Option<i64> = source
.query_row(
"SELECT 1 FROM pragma_foreign_key_check LIMIT 1",
[],
|row| row.get(0),
)
.optional()?;
if foreign_key_violation.is_some() {
return Err(WorthweaveError::Backup(
"backup contains invalid account relationships".into(),
));
}
source
.query_row("SELECT id FROM app_settings WHERE id = 1", [], |row| {
row.get::<_, i64>(0)
})
.map_err(|_| WorthweaveError::Backup("backup schema is not recognized".into()))?;
Backup::new(&source, connection)?.run_to_completion(128, Duration::from_millis(5), None)?;
connection.execute_batch("PRAGMA foreign_keys = ON; PRAGMA wal_checkpoint(TRUNCATE);")?;
Ok(())
}
#[derive(Serialize)]
struct PortfolioExport {
format: &'static str,
version: u32,
exported_at: String,
settings: crate::models::AppSettings,
accounts: Vec<crate::models::Account>,
holdings: Vec<crate::models::Holding>,
income: Vec<crate::models::IncomeSummary>,
valuation: crate::models::ValuationSummary,
snapshots: Vec<crate::models::PortfolioSnapshot>,
}
pub fn export_json(connection: &Connection, path: &Path) -> Result<()> {
if path.as_os_str().is_empty() {
return Err(WorthweaveError::Backup("export path is required".into()));
}
if !path
.extension()
.and_then(|value| value.to_str())
.is_some_and(|value| value.eq_ignore_ascii_case("json"))
{
return Err(WorthweaveError::Backup(
"portfolio exports must use the .json extension".into(),
));
}
let export = PortfolioExport {
format: "worthweave-portfolio-export",
version: 1,
exported_at: chrono::Utc::now().to_rfc3339(),
settings: db::settings(connection)?,
accounts: db::accounts(connection)?,
holdings: projections::holdings(connection)?,
income: projections::income(connection)?,
valuation: market::valuation(connection)?,
snapshots: market::snapshots(connection)?,
};
let mut temporary = destination_temp(path)?;
serde_json::to_writer_pretty(temporary.as_file_mut(), &export)
.map_err(|error| WorthweaveError::Backup(error.to_string()))?;
temporary.as_file_mut().flush()?;
temporary.as_file().sync_all()?;
persist(temporary, path)
}