use std::collections::HashSet;
use std::ffi::OsStr;
use std::fs::{self, File};
use std::io;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use anyhow::{Context, Result, anyhow, bail};
use chrono::Local;
use serde::{Deserialize, Serialize};
use zip::write::SimpleFileOptions;
use zip::{AesMode, CompressionMethod, ZipArchive, ZipWriter};
use crate::shared::i18n::Locale;
use crate::shared::paths::Paths;
use crate::shared::storage::db;
use crate::shared::storage::schema::{CHAT_SCHEMA, DB_SCHEMA, PROFILES_SCHEMA, SETTINGS_SCHEMA};
const MANIFEST_NAME: &str = "manifest.json";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SchemaVersions {
pub settings: u32,
pub profiles: u32,
pub chat: u32,
pub db: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BackupManifest {
pub app_version: String,
pub schemas: SchemaVersions,
pub created_at: String,
}
impl BackupManifest {
fn current() -> Self {
Self {
app_version: env!("CARGO_PKG_VERSION").to_string(),
schemas: SchemaVersions {
settings: SETTINGS_SCHEMA,
profiles: PROFILES_SCHEMA,
chat: CHAT_SCHEMA,
db: DB_SCHEMA,
},
created_at: Local::now().to_rfc3339(),
}
}
pub fn is_newer_than_current(&self) -> bool {
self.schemas.settings > SETTINGS_SCHEMA
|| self.schemas.profiles > PROFILES_SCHEMA
|| self.schemas.chat > CHAT_SCHEMA
|| self.schemas.db > DB_SCHEMA
}
}
pub fn read_manifest(archive: &Path) -> Result<Option<BackupManifest>> {
let file = File::open(archive)?;
let mut zip = ZipArchive::new(file)?;
match zip.by_name(MANIFEST_NAME) {
Ok(mut entry) => {
let mut buf = String::new();
io::Read::read_to_string(&mut entry, &mut buf)?;
Ok(Some(serde_json::from_str(&buf)?))
}
Err(zip::result::ZipError::FileNotFound) => Ok(None),
Err(err) => Err(err.into()),
}
}
const TOP_FILES: &[&str] = &["settings.json", "profiles.json", "personal_dictionary.txt"];
const DB_FILES: &[&str] = &["data.db", "data.db-wal", "data.db-shm"];
const TOP_DIRS: &[&str] = &["chats", "dictionaries", "locales", "files", "workspace"];
struct Entry {
abs: PathBuf,
name: String,
}
pub enum RestoreOutcome {
Restored { pre_restore: Option<PathBuf> },
RolledBack {
pre_restore: PathBuf,
restore_error: anyhow::Error,
},
Failed {
pre_restore: Option<PathBuf>,
restore_error: anyhow::Error,
rollback_error: Option<anyhow::Error>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArchivePassword {
NotNeeded,
Ok,
Required,
Wrong,
}
fn normalize(password: Option<&str>) -> Option<&str> {
password.filter(|p| !p.is_empty())
}
pub fn check_password(archive: &Path, password: Option<&str>) -> Result<ArchivePassword> {
let password = normalize(password);
let mut zip = ZipArchive::new(File::open(archive)?)?;
let encrypted: Vec<usize> = (0..zip.len())
.filter(|&i| zip.by_index_raw(i).is_ok_and(|e| e.encrypted()))
.collect();
let Some(&first) = encrypted.first() else {
return Ok(ArchivePassword::NotNeeded);
};
let Some(password) = password else {
return Ok(ArchivePassword::Required);
};
match zip.by_index_decrypt(first, password.as_bytes()) {
Ok(_) => Ok(ArchivePassword::Ok),
Err(_) => Ok(ArchivePassword::Wrong),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArchiveEntry {
pub name: String,
pub size: u64,
}
pub struct ArchiveReader {
zip: ZipArchive<File>,
password: Option<String>,
}
impl ArchiveReader {
pub fn open(archive: &Path, password: Option<&str>, loc: &Locale) -> Result<Self> {
let file = File::open(archive).with_context(|| {
loc.tf(
"backup.ctx.open_archive",
&[("path", &archive.display().to_string())],
)
})?;
let zip = ZipArchive::new(file).with_context(|| loc.t("backup.ctx.corrupt").to_string())?;
match check_password(archive, password)
.with_context(|| loc.t("backup.ctx.corrupt").to_string())?
{
ArchivePassword::NotNeeded | ArchivePassword::Ok => {}
ArchivePassword::Required => bail!("{}", loc.t("backup.err.password_required")),
ArchivePassword::Wrong => bail!("{}", loc.t("backup.err.wrong_password")),
}
Ok(Self {
zip,
password: normalize(password).map(str::to_string),
})
}
pub fn entries(&mut self) -> Vec<ArchiveEntry> {
(0..self.zip.len())
.filter_map(|i| {
let entry = self.zip.by_index_raw(i).ok()?;
(!entry.is_dir()).then(|| ArchiveEntry {
name: entry.name().to_string(),
size: entry.size(),
})
})
.collect()
}
pub fn with_entry<T>(
&mut self,
name: &str,
read: impl FnOnce(&mut dyn io::Read, u64) -> Result<T>,
) -> Result<Option<T>> {
let opened = match &self.password {
Some(pw) => self.zip.by_name_decrypt(name, pw.as_bytes()),
None => self.zip.by_name(name),
};
match opened {
Ok(mut entry) => {
let size = entry.size();
read(&mut entry, size).map(Some)
}
Err(zip::result::ZipError::FileNotFound) => Ok(None),
Err(err) => Err(err.into()),
}
}
}
const PROGRESS_INTERVAL: Duration = Duration::from_millis(500);
struct EntryProgress {
total: usize,
done: usize,
last: Instant,
interval: Duration,
}
impl EntryProgress {
fn new(total: usize) -> Self {
Self::every(total, PROGRESS_INTERVAL)
}
fn every(total: usize, interval: Duration) -> Self {
Self {
total,
done: 0,
last: Instant::now(),
interval,
}
}
fn tick(&mut self, loc: &Locale, progress: &mut impl FnMut(&str)) {
self.done += 1;
if self.last.elapsed() < self.interval {
return;
}
self.last = Instant::now();
progress(&loc.tf(
"backup.progress.entries",
&[
("done", &self.done.to_string()),
("total", &self.total.to_string()),
],
));
}
}
pub fn create_backup(
paths: &Paths,
output: Option<PathBuf>,
level: i64,
fs_root: Option<&Path>,
password: Option<&str>,
loc: &Locale,
mut progress: impl FnMut(&str),
) -> Result<PathBuf> {
let out_path = match output {
Some(p) => p,
None => default_backup_path(paths, "mindfork-backup"),
};
if paths.data_db().is_file() {
progress(loc.t("backup.progress.compacting"));
}
let compact = compacted_db(paths, &out_path);
let entries = gather_entries(paths, fs_root, compact.as_ref().map(TempDb::path), loc)?;
progress(loc.t("backup.progress.packing"));
write_zip(
&out_path,
&entries,
level,
normalize(password),
loc,
&mut progress,
)
.with_context(|| {
loc.tf(
"backup.ctx.create_archive",
&[("path", &out_path.display().to_string())],
)
})?;
Ok(out_path)
}
pub fn restore_backup(
paths: &Paths,
archive: &Path,
fs_root: Option<&Path>,
password: Option<&str>,
loc: &Locale,
mut progress: impl FnMut(&str),
) -> Result<RestoreOutcome> {
let password = normalize(password);
progress(loc.t("backup.progress.checking"));
validate_archive(archive, password, loc).with_context(|| {
loc.tf(
"backup.ctx.validate",
&[("path", &archive.display().to_string())],
)
})?;
let pre_restore = if has_existing_data(paths) {
progress(loc.t("backup.progress.pre_restore"));
let path = create_backup(
paths,
Some(default_backup_path(paths, "pre-restore")),
9,
fs_root,
password,
loc,
&mut progress,
)
.with_context(|| loc.t("backup.ctx.pre_restore").to_string())?;
Some(path)
} else {
None
};
let attempt = (|| -> Result<()> {
progress(loc.t("backup.progress.clearing"));
clear_user_data(paths, fs_root, loc)?;
progress(loc.t("backup.progress.extracting"));
extract_archive(paths, archive, password, loc, &mut progress)
})();
match attempt {
Ok(()) => {
if paths.data_db().is_file() {
progress(loc.t("backup.progress.compacting"));
}
compact_restored_db(paths);
Ok(RestoreOutcome::Restored { pre_restore })
}
Err(restore_error) => match &pre_restore {
Some(backup) => {
let rollback = (|| -> Result<()> {
progress(loc.t("backup.progress.rolling_back"));
clear_user_data(paths, fs_root, loc)?;
extract_archive(paths, backup, password, loc, &mut progress)
})();
match rollback {
Ok(()) => Ok(RestoreOutcome::RolledBack {
pre_restore: backup.clone(),
restore_error,
}),
Err(rollback_error) => Ok(RestoreOutcome::Failed {
pre_restore: pre_restore.clone(),
restore_error,
rollback_error: Some(rollback_error),
}),
}
}
None => Ok(RestoreOutcome::Failed {
pre_restore: None,
restore_error,
rollback_error: None,
}),
},
}
}
fn gather_entries(
paths: &Paths,
fs_root: Option<&Path>,
compact_db: Option<&Path>,
loc: &Locale,
) -> Result<Vec<Entry>> {
let root = paths.root();
let mut out: Vec<Entry> = Vec::new();
let raw_db: &[&str] = if compact_db.is_some() { &[] } else { DB_FILES };
for f in TOP_FILES.iter().chain(raw_db) {
let abs = root.join(f);
if abs.is_file() {
out.push(Entry {
abs,
name: (*f).to_string(),
});
}
}
if let Some(compact) = compact_db {
out.push(Entry {
abs: compact.to_path_buf(),
name: "data.db".to_string(),
});
}
if let Ok(rd) = fs::read_dir(root) {
for entry in rd.flatten() {
let path = entry.path();
if path.is_file()
&& path.extension().is_some_and(|e| e == "bak")
&& let Some(name) = path.file_name().and_then(OsStr::to_str)
{
out.push(Entry {
abs: path.clone(),
name: name.to_string(),
});
}
}
}
for d in TOP_DIRS {
collect_dir(&root.join(d), d, &mut out, loc)?;
}
if let Some((abs, prefix)) = fs_root_under_root(root, fs_root) {
collect_dir(&abs, &prefix, &mut out, loc)?;
}
let mut seen = HashSet::new();
out.retain(|e| seen.insert(e.name.clone()));
Ok(out)
}
struct TempDb(PathBuf);
impl TempDb {
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDb {
fn drop(&mut self) {
let _ = fs::remove_file(&self.0);
}
}
fn compacted_db(paths: &Paths, out_path: &Path) -> Option<TempDb> {
let src = paths.data_db();
if !src.is_file() {
return None;
}
let dir = out_path.parent().unwrap_or_else(|| Path::new("."));
if let Err(e) = fs::create_dir_all(dir) {
tracing::warn!(error = %e, dir = %dir.display(), "backup: no scratch directory for compaction");
return None;
}
let temp = TempDb(dir.join(format!("data.db.compact-{}.tmp", std::process::id())));
match db::vacuum_into(&src, temp.path()) {
Ok(()) => {
tracing::info!(
before = file_len(&src),
after = file_len(temp.path()),
"backup: database compacted"
);
Some(temp)
}
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "backup: packing the database uncompacted");
None
}
}
}
fn compact_restored_db(paths: &Paths) {
let path = paths.data_db();
if !path.is_file() {
return;
}
let before = file_len(&path);
match db::vacuum(&path) {
Ok(()) => tracing::info!(
before,
after = file_len(&path),
"restore: database compacted"
),
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "restore: database left uncompacted")
}
}
}
fn file_len(path: &Path) -> u64 {
fs::metadata(path).map(|m| m.len()).unwrap_or(0)
}
fn collect_dir(abs: &Path, prefix: &str, out: &mut Vec<Entry>, loc: &Locale) -> Result<()> {
if !abs.is_dir() {
return Ok(());
}
let rd = fs::read_dir(abs).with_context(|| {
loc.tf(
"backup.ctx.read_dir",
&[("path", &abs.display().to_string())],
)
})?;
for entry in rd {
let entry = entry?;
let ft = entry.file_type()?;
let child_abs = entry.path();
let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
continue; };
let child_name = if prefix.is_empty() {
name
} else {
format!("{prefix}/{name}")
};
if ft.is_dir() {
collect_dir(&child_abs, &child_name, out, loc)?;
} else if ft.is_file() {
out.push(Entry {
abs: child_abs,
name: child_name,
});
}
}
Ok(())
}
fn write_zip(
out_path: &Path,
entries: &[Entry],
level: i64,
password: Option<&str>,
loc: &Locale,
progress: &mut impl FnMut(&str),
) -> Result<()> {
if let Some(parent) = out_path.parent() {
fs::create_dir_all(parent).with_context(|| {
loc.tf(
"backup.ctx.create_dir",
&[("path", &parent.display().to_string())],
)
})?;
}
let file = File::create(out_path).with_context(|| {
loc.tf(
"backup.ctx.create_file",
&[("path", &out_path.display().to_string())],
)
})?;
let mut zip = ZipWriter::new(file);
let level = level.clamp(0, 9);
let plain = if level == 0 {
SimpleFileOptions::default().compression_method(CompressionMethod::Stored)
} else {
SimpleFileOptions::default()
.compression_method(CompressionMethod::Deflated)
.compression_level(Some(level))
};
let options = match password {
Some(pw) => plain.with_aes_encryption(AesMode::Aes256, pw),
None => plain,
};
let mut ticker = EntryProgress::new(entries.len());
for e in entries {
ticker.tick(loc, progress);
zip.start_file(e.name.as_str(), options)
.with_context(|| loc.tf("backup.ctx.write_entry", &[("name", &e.name)]))?;
let mut src = File::open(&e.abs).with_context(|| {
loc.tf("backup.ctx.open", &[("path", &e.abs.display().to_string())])
})?;
io::copy(&mut src, &mut zip).with_context(|| {
loc.tf("backup.ctx.pack", &[("path", &e.abs.display().to_string())])
})?;
}
let manifest = serde_json::to_vec_pretty(&BackupManifest::current())
.context("serializing backup manifest")?;
zip.start_file(MANIFEST_NAME, plain)
.with_context(|| loc.tf("backup.ctx.write_entry", &[("name", MANIFEST_NAME)]))?;
io::copy(&mut manifest.as_slice(), &mut zip)
.with_context(|| loc.tf("backup.ctx.pack", &[("path", MANIFEST_NAME)]))?;
zip.finish()
.with_context(|| loc.t("backup.ctx.finalize").to_string())?;
Ok(())
}
fn validate_archive(archive: &Path, password: Option<&str>, loc: &Locale) -> Result<()> {
let file = File::open(archive).with_context(|| {
loc.tf(
"backup.ctx.open_archive",
&[("path", &archive.display().to_string())],
)
})?;
let mut zip = ZipArchive::new(file).with_context(|| loc.t("backup.ctx.corrupt").to_string())?;
for i in 0..zip.len() {
let entry = zip.by_index_raw(i)?;
if entry.enclosed_name().is_none() {
bail!(
"{}",
loc.tf("backup.err.unsafe_entry", &[("name", entry.name())])
);
}
}
match check_password(archive, password)
.with_context(|| loc.t("backup.ctx.corrupt").to_string())?
{
ArchivePassword::NotNeeded | ArchivePassword::Ok => Ok(()),
ArchivePassword::Required => bail!("{}", loc.t("backup.err.password_required")),
ArchivePassword::Wrong => bail!("{}", loc.t("backup.err.wrong_password")),
}
}
fn extract_archive(
paths: &Paths,
archive: &Path,
password: Option<&str>,
loc: &Locale,
progress: &mut impl FnMut(&str),
) -> Result<()> {
let file = File::open(archive).with_context(|| {
loc.tf(
"backup.ctx.open_archive",
&[("path", &archive.display().to_string())],
)
})?;
let mut zip =
ZipArchive::new(file).with_context(|| loc.t("backup.ctx.read_archive").to_string())?;
let mut ticker = EntryProgress::new(zip.len());
for i in 0..zip.len() {
ticker.tick(loc, progress);
let mut entry = match password {
Some(pw) => zip.by_index_decrypt(i, pw.as_bytes())?,
None => zip.by_index(i)?,
};
let rel = entry.enclosed_name().ok_or_else(|| {
anyhow!(
"{}",
loc.tf("backup.err.unsafe_entry", &[("name", entry.name())])
)
})?;
if rel == Path::new(MANIFEST_NAME) {
continue;
}
let dest = paths.root().join(&rel);
if entry.is_dir() {
fs::create_dir_all(&dest).with_context(|| {
loc.tf(
"backup.ctx.create_dir",
&[("path", &dest.display().to_string())],
)
})?;
continue;
}
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).with_context(|| {
loc.tf(
"backup.ctx.create_dir",
&[("path", &parent.display().to_string())],
)
})?;
}
let mut out = File::create(&dest).with_context(|| {
loc.tf(
"backup.ctx.create_file",
&[("path", &dest.display().to_string())],
)
})?;
io::copy(&mut entry, &mut out).with_context(|| {
loc.tf(
"backup.ctx.extract",
&[("path", &dest.display().to_string())],
)
})?;
if rel.starts_with("files") {
drop(out);
crate::features::chat_files::mark(&dest, Some(crate::shared::os_open::FROM_ELSEWHERE));
}
}
Ok(())
}
fn clear_user_data(paths: &Paths, fs_root: Option<&Path>, loc: &Locale) -> Result<()> {
let root = paths.root();
for f in TOP_FILES.iter().chain(DB_FILES) {
remove_file_if_exists(&root.join(f), loc)?;
}
if let Ok(rd) = fs::read_dir(root) {
for entry in rd.flatten() {
let path = entry.path();
if path.is_file() && path.extension().is_some_and(|e| e == "bak") {
remove_file_if_exists(&path, loc)?;
}
}
}
for d in TOP_DIRS {
remove_dir_if_exists(&root.join(d), loc)?;
}
if let Some((abs, _)) = fs_root_under_root(root, fs_root) {
remove_dir_if_exists(&abs, loc)?;
}
Ok(())
}
fn remove_file_if_exists(path: &Path, loc: &Locale) -> Result<()> {
match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e).with_context(|| {
loc.tf(
"backup.ctx.remove_file",
&[("path", &path.display().to_string())],
)
}),
}
}
fn remove_dir_if_exists(path: &Path, loc: &Locale) -> Result<()> {
match fs::remove_dir_all(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e).with_context(|| {
loc.tf(
"backup.ctx.remove_dir",
&[("path", &path.display().to_string())],
)
}),
}
}
fn has_existing_data(paths: &Paths) -> bool {
["settings.json", "profiles.json", "data.db"]
.iter()
.any(|f| paths.root().join(f).exists())
|| dir_non_empty(&paths.chats_dir())
}
fn dir_non_empty(dir: &Path) -> bool {
fs::read_dir(dir).is_ok_and(|mut rd| rd.next().is_some())
}
fn fs_root_under_root(root: &Path, fs_root: Option<&Path>) -> Option<(PathBuf, String)> {
let fs_root = fs_root?;
let root_c = fs::canonicalize(root).ok()?;
let fs_c = fs::canonicalize(fs_root).ok()?;
let rel = fs_c.strip_prefix(&root_c).ok()?;
if rel.as_os_str().is_empty() {
return None;
}
let prefix = rel
.components()
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/");
if prefix.is_empty() {
return None;
}
Some((fs_c, prefix))
}
pub(crate) fn default_backup_path(paths: &Paths, prefix: &str) -> PathBuf {
let stamp = Local::now().format("%Y%m%d-%H%M%S");
paths.backups_dir().join(format!("{prefix}-{stamp}.zip"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shared::i18n::{Lang, locale};
fn ru() -> &'static Locale {
locale(Lang::Ru)
}
fn seed_data(root: &Path) {
fs::write(root.join("settings.json"), b"{\"v\":1}").unwrap();
fs::write(root.join("settings.bak"), b"{\"v\":0}").unwrap();
fs::write(root.join("profiles.json"), b"[]").unwrap();
fs::write(root.join("data.db"), b"SQLITE").unwrap();
fs::write(root.join("personal_dictionary.txt"), b"foo\n").unwrap();
fs::create_dir_all(root.join("chats")).unwrap();
fs::write(root.join("chats").join("a.json"), b"{}").unwrap();
fs::write(root.join("chats").join("a.bak"), b"{}").unwrap();
fs::create_dir_all(root.join("dictionaries")).unwrap();
fs::write(root.join("dictionaries").join("en.dic"), b"x").unwrap();
fs::create_dir_all(root.join("locales")).unwrap();
fs::write(root.join("locales").join("en.json"), b"{}").unwrap();
fs::create_dir_all(root.join("files").join("c1")).unwrap();
fs::write(root.join("files").join("c1").join("chart.png"), b"png").unwrap();
fs::create_dir_all(root.join("workspace").join("c1")).unwrap();
fs::write(
root.join("workspace").join("c1").join("journal.json"),
b"{\"entries\":[]}",
)
.unwrap();
fs::create_dir_all(root.join("logs")).unwrap();
fs::write(root.join("logs").join("mindfork.log"), b"log").unwrap();
fs::create_dir_all(root.join("backups")).unwrap();
fs::write(root.join("location.json"), b"{\"mode\":\"portable\"}").unwrap();
fs::write(
root.join("defaults.json"),
b"{\"mode\":\"portable\",\"default_language\":\"ru\"}",
)
.unwrap();
}
fn seed_fragmented_db(root: &Path) -> uuid::Uuid {
use crate::entities::rag::RagDocument;
use crate::shared::storage::db::Db;
let profile = uuid::Uuid::new_v4();
let path = root.join("data.db");
let _ = fs::remove_file(&path);
let db = Db::open(&path).unwrap();
db.batch(|| {
for i in 0..200 {
let text = format!("scratch {i} {}", "x".repeat(500));
db.rag_insert(&RagDocument::new(profile, "scratch", text, vec![0.0, 1.0]))
.unwrap();
}
db.rag_insert(&RagDocument::new(
profile,
"keep",
"the kept chunk",
vec![1.0, 0.0],
))
.unwrap();
});
db.batch(|| db.rag_delete_by_source(profile, "scratch").unwrap());
profile
}
fn extract_entry(archive: &Path, name: &str, dest: &Path) {
let mut zip = ZipArchive::new(File::open(archive).unwrap()).unwrap();
let mut entry = zip.by_name(name).unwrap();
let mut out = File::create(dest).unwrap();
io::copy(&mut entry, &mut out).unwrap();
}
fn archive_names(archive: &Path) -> Vec<String> {
let mut zip = ZipArchive::new(File::open(archive).unwrap()).unwrap();
(0..zip.len())
.map(|i| zip.by_index(i).unwrap().name().to_string())
.collect()
}
#[test]
fn backup_includes_expected_and_excludes_logs_marker() {
let dir = tempfile::tempdir().unwrap();
seed_data(dir.path());
let paths = Paths::with_root(dir.path());
let out = create_backup(&paths, None, 9, None, None, ru(), |_| {}).unwrap();
assert!(out.starts_with(paths.backups_dir()));
let names = archive_names(&out);
for expected in [
"settings.json",
"settings.bak",
"profiles.json",
"data.db",
"personal_dictionary.txt",
"chats/a.json",
"chats/a.bak",
"dictionaries/en.dic",
"locales/en.json",
"files/c1/chart.png",
"workspace/c1/journal.json",
] {
assert!(
names.contains(&expected.to_string()),
"missing {expected} in {names:?}"
);
}
assert!(!names.iter().any(|n| n.starts_with("logs/")));
assert!(!names.iter().any(|n| n.starts_with("backups/")));
assert!(!names.contains(&"location.json".to_string()));
assert!(!names.contains(&"defaults.json".to_string()));
}
#[test]
fn fs_root_included_only_when_inside_root() {
let dir = tempfile::tempdir().unwrap();
seed_data(dir.path());
let inside = dir.path().join("sandbox");
fs::create_dir_all(&inside).unwrap();
fs::write(inside.join("note.txt"), b"hi").unwrap();
let paths = Paths::with_root(dir.path());
let out = create_backup(&paths, None, 9, Some(&inside), None, ru(), |_| {}).unwrap();
assert!(archive_names(&out).contains(&"sandbox/note.txt".to_string()));
let outside_root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
fs::write(outside.path().join("secret.txt"), b"no").unwrap();
seed_data(outside_root.path());
let paths2 = Paths::with_root(outside_root.path());
let out2 =
create_backup(&paths2, None, 0, Some(outside.path()), None, ru(), |_| {}).unwrap();
assert!(
!archive_names(&out2)
.iter()
.any(|n| n.contains("secret.txt"))
);
}
#[test]
fn restore_round_trip_replaces_data() {
let src = tempfile::tempdir().unwrap();
seed_data(src.path());
fs::write(src.path().join("settings.json"), b"{\"v\":42}").unwrap();
let src_paths = Paths::with_root(src.path());
let archive_path = src.path().join("backups").join("snap.zip");
create_backup(
&src_paths,
Some(archive_path.clone()),
9,
None,
None,
ru(),
|_| {},
)
.unwrap();
let dst = tempfile::tempdir().unwrap();
seed_data(dst.path());
fs::write(dst.path().join("settings.json"), b"{\"v\":999}").unwrap();
fs::write(dst.path().join("chats").join("stale.json"), b"{}").unwrap();
fs::create_dir_all(dst.path().join("workspace").join("other")).unwrap();
fs::write(
dst.path().join("workspace").join("other").join("j.json"),
b"{}",
)
.unwrap();
let dst_paths = Paths::with_root(dst.path());
let outcome = restore_backup(&dst_paths, &archive_path, None, None, ru(), |_| {}).unwrap();
match outcome {
RestoreOutcome::Restored { pre_restore } => {
let pre = pre_restore.expect("a pre-restore copy should have been created");
assert!(pre.exists());
assert!(pre.starts_with(dst_paths.backups_dir()));
}
_ => panic!("expected a successful Restored"),
}
assert_eq!(
fs::read(dst.path().join("settings.json")).unwrap(),
b"{\"v\":42}"
);
assert!(!dst.path().join("chats").join("stale.json").exists());
assert!(
dst.path()
.join("workspace")
.join("c1")
.join("journal.json")
.exists(),
"the archive's workspace journal was not restored"
);
assert!(
!dst.path().join("workspace").join("other").exists(),
"a journal the archive does not carry survived the restore"
);
assert!(dst.path().join("backups").exists());
}
#[cfg(windows)]
#[test]
fn a_restored_stored_file_is_marked_as_come_from_elsewhere() {
use crate::shared::os_open::{FROM_ELSEWHERE, zone_of};
let src = tempfile::tempdir().unwrap();
seed_data(src.path());
let archive_path = src.path().join("backups").join("snap.zip");
create_backup(
&Paths::with_root(src.path()),
Some(archive_path.clone()),
9,
None,
None,
ru(),
|_| {},
)
.unwrap();
let dst = tempfile::tempdir().unwrap();
restore_backup(
&Paths::with_root(dst.path()),
&archive_path,
None,
None,
ru(),
|_| {},
)
.unwrap();
let chart = dst.path().join("files").join("c1").join("chart.png");
assert_eq!(fs::read(&chart).unwrap(), b"png");
assert_eq!(zone_of(&chart).as_deref(), Some(FROM_ELSEWHERE));
assert_eq!(
zone_of(&dst.path().join("chats").join("a.json")),
None,
"only files/ is marked"
);
}
#[test]
fn restore_rejects_corrupt_archive_without_touching_data() {
let dst = tempfile::tempdir().unwrap();
seed_data(dst.path());
let paths = Paths::with_root(dst.path());
let bad = dst.path().join("bad.zip");
fs::write(&bad, b"this is not a zip file").unwrap();
let err = restore_backup(&paths, &bad, None, None, ru(), |_| {});
assert!(
err.is_err(),
"a corrupted archive should give Err before any cleanup"
);
assert!(dst.path().join("settings.json").exists());
assert!(dst.path().join("chats").join("a.json").exists());
}
#[test]
fn restore_into_empty_root_makes_no_pre_restore() {
let src = tempfile::tempdir().unwrap();
seed_data(src.path());
let archive = src.path().join("snap.zip");
create_backup(
&Paths::with_root(src.path()),
Some(archive.clone()),
9,
None,
None,
ru(),
|_| {},
)
.unwrap();
let dst = tempfile::tempdir().unwrap();
let paths = Paths::with_root(dst.path());
let outcome = restore_backup(&paths, &archive, None, None, ru(), |_| {}).unwrap();
match outcome {
RestoreOutcome::Restored { pre_restore } => assert!(pre_restore.is_none()),
_ => panic!("expected a Restored with no pre-restore"),
}
assert!(dst.path().join("settings.json").exists());
}
#[test]
fn restore_rolls_back_on_extraction_failure() {
use std::io::Write;
let work = tempfile::tempdir().unwrap();
let archive = work.path().join("evil.zip");
{
let f = File::create(&archive).unwrap();
let mut zip = ZipWriter::new(f);
let opts = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
zip.start_file("settings.json", opts).unwrap();
zip.write_all(b"{\"from\":\"archive\"}").unwrap();
zip.start_file("blocker", opts).unwrap();
zip.write_all(b"x").unwrap();
zip.finish().unwrap();
}
let dst = tempfile::tempdir().unwrap();
seed_data(dst.path());
fs::write(dst.path().join("settings.json"), b"{\"from\":\"original\"}").unwrap();
fs::create_dir_all(dst.path().join("blocker")).unwrap();
let paths = Paths::with_root(dst.path());
let outcome = restore_backup(&paths, &archive, None, None, ru(), |_| {}).unwrap();
match outcome {
RestoreOutcome::RolledBack { pre_restore, .. } => assert!(pre_restore.exists()),
_ => panic!("expected RolledBack on an unpack failure"),
}
assert_eq!(
fs::read(dst.path().join("settings.json")).unwrap(),
b"{\"from\":\"original\"}"
);
assert!(dst.path().join("chats").join("a.json").exists());
}
fn step(log: &[String], key: &str) -> Option<usize> {
let text = ru().t(key);
log.iter().position(|line| line == text)
}
fn step_at(log: &[String], key: &str) -> usize {
step(log, key).unwrap_or_else(|| panic!("{key} was never reported; log: {log:#?}"))
}
#[test]
fn restore_announces_every_phase_in_order() {
let src = tempfile::tempdir().unwrap();
seed_data(src.path());
let archive = src.path().join("snap.zip");
create_backup(
&Paths::with_root(src.path()),
Some(archive.clone()),
9,
None,
None,
ru(),
|_| {},
)
.unwrap();
let dst = tempfile::tempdir().unwrap();
seed_data(dst.path());
let paths = Paths::with_root(dst.path());
let mut log = Vec::new();
let outcome = restore_backup(&paths, &archive, None, None, ru(), |m| {
log.push(m.to_string())
})
.unwrap();
assert!(matches!(outcome, RestoreOutcome::Restored { .. }));
let order = [
"backup.progress.checking",
"backup.progress.pre_restore",
"backup.progress.packing",
"backup.progress.clearing",
"backup.progress.extracting",
]
.map(|key| step_at(&log, key));
assert!(
order.windows(2).all(|w| w[0] < w[1]),
"phases out of order: {log:#?}"
);
assert_eq!(
log.iter()
.filter(|l| *l == ru().t("backup.progress.compacting"))
.count(),
2,
"{log:#?}"
);
}
#[test]
fn restore_into_an_empty_root_does_not_announce_a_pre_restore_copy() {
let src = tempfile::tempdir().unwrap();
seed_data(src.path());
let archive = src.path().join("snap.zip");
create_backup(
&Paths::with_root(src.path()),
Some(archive.clone()),
9,
None,
None,
ru(),
|_| {},
)
.unwrap();
let dst = tempfile::tempdir().unwrap();
let mut log = Vec::new();
restore_backup(
&Paths::with_root(dst.path()),
&archive,
None,
None,
ru(),
|m| log.push(m.to_string()),
)
.unwrap();
assert!(
step(&log, "backup.progress.pre_restore").is_none(),
"{log:#?}"
);
assert!(
step(&log, "backup.progress.extracting").is_some(),
"{log:#?}"
);
}
#[test]
fn a_rollback_says_so() {
use std::io::Write;
let work = tempfile::tempdir().unwrap();
let archive = work.path().join("evil.zip");
{
let f = File::create(&archive).unwrap();
let mut zip = ZipWriter::new(f);
let opts = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
zip.start_file("settings.json", opts).unwrap();
zip.write_all(b"{}").unwrap();
zip.start_file("blocker", opts).unwrap();
zip.write_all(b"x").unwrap();
zip.finish().unwrap();
}
let dst = tempfile::tempdir().unwrap();
seed_data(dst.path());
fs::create_dir_all(dst.path().join("blocker")).unwrap();
let mut log = Vec::new();
let outcome = restore_backup(
&Paths::with_root(dst.path()),
&archive,
None,
None,
ru(),
|m| log.push(m.to_string()),
)
.unwrap();
assert!(matches!(outcome, RestoreOutcome::RolledBack { .. }));
assert!(
step_at(&log, "backup.progress.rolling_back")
> step_at(&log, "backup.progress.extracting"),
"{log:#?}"
);
}
#[test]
fn backup_announces_compaction_and_packing() {
let dir = tempfile::tempdir().unwrap();
seed_data(dir.path());
let mut log = Vec::new();
create_backup(
&Paths::with_root(dir.path()),
None,
9,
None,
None,
ru(),
|m| log.push(m.to_string()),
)
.unwrap();
assert!(
step_at(&log, "backup.progress.compacting") < step_at(&log, "backup.progress.packing"),
"{log:#?}"
);
}
#[test]
fn the_entry_counter_stays_quiet_until_its_interval_passes() {
let mut log = Vec::new();
let mut ticker = EntryProgress::every(3, Duration::from_secs(3600));
for _ in 0..3 {
ticker.tick(ru(), &mut |m: &str| log.push(m.to_string()));
}
assert!(log.is_empty(), "{log:#?}");
}
#[test]
fn the_entry_counter_reports_progress_out_of_the_total() {
let mut log = Vec::new();
let mut ticker = EntryProgress::every(3, Duration::ZERO);
for _ in 0..3 {
ticker.tick(ru(), &mut |m: &str| log.push(m.to_string()));
}
assert_eq!(log.len(), 3, "{log:#?}");
assert!(log[0].contains('1') && log[0].contains('3'), "{}", log[0]);
assert!(log[2].contains('3'), "{}", log[2]);
for lang in Lang::ALL {
let mut ticker = EntryProgress::every(7, Duration::ZERO);
let mut line = String::new();
ticker.tick(locale(*lang), &mut |m: &str| line = m.to_string());
assert!(!line.contains('{'), "{lang:?}: {line}");
}
}
#[test]
fn corrupt_archive_error_is_localized() {
let dir = tempfile::tempdir().unwrap();
let bad = dir.path().join("bad.zip");
fs::write(&bad, b"this is not a zip file").unwrap();
let en = validate_archive(&bad, None, locale(Lang::En))
.unwrap_err()
.to_string();
assert!(en.contains("corrupted"), "{en}");
assert!(!en.chars().any(|c| ('а'..='я').contains(&c)), "{en}");
let r = validate_archive(&bad, None, ru()).unwrap_err().to_string();
assert!(r.contains("повреждён"), "{r}");
}
#[test]
fn store_level_zero_produces_readable_archive() {
let dir = tempfile::tempdir().unwrap();
seed_data(dir.path());
let paths = Paths::with_root(dir.path());
let out = create_backup(&paths, None, 0, None, None, ru(), |_| {}).unwrap();
validate_archive(&out, None, ru()).unwrap();
}
#[test]
fn backup_writes_manifest_and_read_manifest_roundtrips() {
let dir = tempfile::tempdir().unwrap();
seed_data(dir.path());
let paths = Paths::with_root(dir.path());
let out = create_backup(&paths, None, 9, None, None, ru(), |_| {}).unwrap();
assert!(archive_names(&out).contains(&MANIFEST_NAME.to_string()));
let m = read_manifest(&out)
.unwrap()
.expect("a manifest should be present");
assert_eq!(m.app_version, env!("CARGO_PKG_VERSION"));
assert_eq!(m.schemas.settings, SETTINGS_SCHEMA);
assert_eq!(m.schemas.db, DB_SCHEMA);
assert!(
!m.is_newer_than_current(),
"current schemas are not newer than themselves"
);
}
#[test]
fn manifest_detects_newer_schema() {
let m = BackupManifest {
app_version: "9.9.9".into(),
schemas: SchemaVersions {
settings: SETTINGS_SCHEMA + 1,
profiles: PROFILES_SCHEMA,
chat: CHAT_SCHEMA,
db: DB_SCHEMA,
},
created_at: "2030-01-01T00:00:00+00:00".into(),
};
assert!(m.is_newer_than_current());
}
#[test]
fn read_manifest_none_for_archive_without_it() {
let dir = tempfile::tempdir().unwrap();
let archive = dir.path().join("old.zip");
{
let mut zip = ZipWriter::new(File::create(&archive).unwrap());
zip.start_file("settings.json", SimpleFileOptions::default())
.unwrap();
io::copy(&mut b"{}".as_slice(), &mut zip).unwrap();
zip.finish().unwrap();
}
assert!(read_manifest(&archive).unwrap().is_none());
}
#[test]
fn backup_packs_a_compacted_database_without_sidecars() {
let dir = tempfile::tempdir().unwrap();
seed_data(dir.path());
let profile = seed_fragmented_db(dir.path());
fs::write(dir.path().join("data.db-wal"), b"stale wal").unwrap();
let live_len = fs::metadata(dir.path().join("data.db")).unwrap().len();
let paths = Paths::with_root(dir.path());
let out = create_backup(&paths, None, 9, None, None, ru(), |_| {}).unwrap();
let names = archive_names(&out);
assert!(names.contains(&"data.db".to_string()), "{names:?}");
assert!(!names.contains(&"data.db-wal".to_string()), "{names:?}");
assert!(!names.contains(&"data.db-shm".to_string()), "{names:?}");
let packed = dir.path().join("unpacked.db");
extract_entry(&out, "data.db", &packed);
assert!(
fs::metadata(&packed).unwrap().len() < live_len,
"the packed copy should be smaller than the live file"
);
let db = crate::shared::storage::db::Db::open(&packed).unwrap();
let hits = db.rag_search(profile, &[1.0, 0.0], 5).unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].chunk_text, "the kept chunk");
let leftovers: Vec<String> = fs::read_dir(paths.backups_dir())
.unwrap()
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.ends_with(".tmp"))
.collect();
assert!(leftovers.is_empty(), "{leftovers:?}");
}
#[test]
fn backup_packs_the_raw_database_when_it_cannot_be_compacted() {
let dir = tempfile::tempdir().unwrap();
seed_data(dir.path());
fs::write(dir.path().join("data.db-wal"), b"wal bytes").unwrap();
let paths = Paths::with_root(dir.path());
let out = create_backup(&paths, None, 9, None, None, ru(), |_| {}).unwrap();
let names = archive_names(&out);
assert!(names.contains(&"data.db-wal".to_string()), "{names:?}");
let packed = dir.path().join("unpacked.db");
extract_entry(&out, "data.db", &packed);
assert_eq!(fs::read(&packed).unwrap(), b"SQLITE");
}
#[test]
fn restore_compacts_the_database() {
let src = tempfile::tempdir().unwrap();
seed_data(src.path());
let profile = seed_fragmented_db(src.path());
let fragmented = fs::read(src.path().join("data.db")).unwrap();
let archive = src.path().join("old.zip");
{
let mut zip = ZipWriter::new(File::create(&archive).unwrap());
let opts = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
zip.start_file("settings.json", opts).unwrap();
io::copy(&mut b"{}".as_slice(), &mut zip).unwrap();
zip.start_file("data.db", opts).unwrap();
io::copy(&mut fragmented.as_slice(), &mut zip).unwrap();
zip.finish().unwrap();
}
let dst = tempfile::tempdir().unwrap();
let paths = Paths::with_root(dst.path());
let outcome = restore_backup(&paths, &archive, None, None, ru(), |_| {}).unwrap();
assert!(matches!(outcome, RestoreOutcome::Restored { .. }));
let restored = dst.path().join("data.db");
assert!(
fs::metadata(&restored).unwrap().len() < fragmented.len() as u64,
"the restored database should be compacted"
);
let db = crate::shared::storage::db::Db::open(&restored).unwrap();
assert_eq!(db.rag_search(profile, &[1.0, 0.0], 5).unwrap().len(), 1);
}
#[test]
fn restore_leaves_an_uncompactable_database_alone() {
let src = tempfile::tempdir().unwrap();
seed_data(src.path());
let archive = src.path().join("snap.zip");
create_backup(
&Paths::with_root(src.path()),
Some(archive.clone()),
0,
None,
None,
ru(),
|_| {},
)
.unwrap();
let dst = tempfile::tempdir().unwrap();
let paths = Paths::with_root(dst.path());
let outcome = restore_backup(&paths, &archive, None, None, ru(), |_| {}).unwrap();
assert!(matches!(outcome, RestoreOutcome::Restored { .. }));
assert_eq!(fs::read(dst.path().join("data.db")).unwrap(), b"SQLITE");
}
#[test]
fn restore_does_not_extract_manifest_into_root() {
let src = tempfile::tempdir().unwrap();
seed_data(src.path());
let out = create_backup(
&Paths::with_root(src.path()),
None,
9,
None,
None,
ru(),
|_| {},
)
.unwrap();
let dst = tempfile::tempdir().unwrap();
let paths = Paths::with_root(dst.path());
let outcome = restore_backup(&paths, &out, None, None, ru(), |_| {}).unwrap();
assert!(matches!(outcome, RestoreOutcome::Restored { .. }));
assert!(dst.path().join("settings.json").exists());
assert!(!dst.path().join(MANIFEST_NAME).exists());
}
const PW: &str = "correct horse battery staple";
#[test]
fn an_encrypted_backup_does_not_carry_readable_data() {
let dir = tempfile::tempdir().unwrap();
seed_data(dir.path());
fs::write(
dir.path().join("settings.json"),
b"{\"secret\":\"HUNTER2-MARKER\"}",
)
.unwrap();
let paths = Paths::with_root(dir.path());
let out = create_backup(&paths, None, 9, None, Some(PW), ru(), |_| {}).unwrap();
let plain = create_backup(
&paths,
Some(dir.path().join("plain.zip")),
0,
None,
None,
ru(),
|_| {},
)
.unwrap();
let has_marker = |p: &Path| {
fs::read(p)
.unwrap()
.windows(15)
.any(|w| w == b"HUNTER2-MARKER\"".get(..15).unwrap_or(b"HUNTER2-MARKER"))
};
assert!(has_marker(&plain), "the control archive should be readable");
assert!(
!has_marker(&out),
"plaintext leaked into the encrypted archive"
);
assert_eq!(check_password(&out, Some(PW)).unwrap(), ArchivePassword::Ok);
}
#[test]
fn password_matrix_covers_both_kinds_of_archive() {
let dir = tempfile::tempdir().unwrap();
seed_data(dir.path());
let paths = Paths::with_root(dir.path());
let enc = create_backup(
&paths,
Some(dir.path().join("enc.zip")),
9,
None,
Some(PW),
ru(),
|_| {},
)
.unwrap();
let plain = create_backup(
&paths,
Some(dir.path().join("plain.zip")),
9,
None,
None,
ru(),
|_| {},
)
.unwrap();
use ArchivePassword::*;
for (archive, password, expected) in [
(&enc, Some(PW), Ok),
(&enc, None, Required),
(&enc, Some("wrong"), Wrong),
(&plain, Some(PW), NotNeeded),
(&plain, None, NotNeeded),
] {
assert_eq!(
check_password(archive, password).unwrap(),
expected,
"archive={} password={password:?}",
archive.display()
);
}
}
#[test]
fn restore_accepts_an_encrypted_and_an_unencrypted_archive() {
for password in [Some(PW), None] {
let src = tempfile::tempdir().unwrap();
seed_data(src.path());
fs::write(src.path().join("settings.json"), b"{\"v\":42}").unwrap();
let archive = src.path().join("snap.zip");
create_backup(
&Paths::with_root(src.path()),
Some(archive.clone()),
9,
None,
password,
ru(),
|_| {},
)
.unwrap();
let dst = tempfile::tempdir().unwrap();
let paths = Paths::with_root(dst.path());
let outcome = restore_backup(&paths, &archive, None, Some(PW), ru(), |_| {}).unwrap();
assert!(
matches!(outcome, RestoreOutcome::Restored { .. }),
"password={password:?}"
);
assert_eq!(
fs::read(dst.path().join("settings.json")).unwrap(),
b"{\"v\":42}",
"password={password:?}"
);
assert!(dst.path().join("chats").join("a.json").exists());
}
}
#[test]
fn a_bad_password_is_refused_without_touching_data() {
let src = tempfile::tempdir().unwrap();
seed_data(src.path());
let archive = src.path().join("enc.zip");
create_backup(
&Paths::with_root(src.path()),
Some(archive.clone()),
9,
None,
Some(PW),
ru(),
|_| {},
)
.unwrap();
for password in [None, Some("wrong")] {
let dst = tempfile::tempdir().unwrap();
seed_data(dst.path());
fs::write(dst.path().join("settings.json"), b"{\"from\":\"original\"}").unwrap();
let paths = Paths::with_root(dst.path());
let err = restore_backup(&paths, &archive, None, password, ru(), |_| {});
assert!(err.is_err(), "password={password:?} should be refused");
assert_eq!(
fs::read(dst.path().join("settings.json")).unwrap(),
b"{\"from\":\"original\"}"
);
assert!(dst.path().join("chats").join("a.json").exists());
assert!(
fs::read_dir(paths.backups_dir()).is_ok_and(|mut d| d.next().is_none()),
"a refused restore should not leave a pre-restore copy"
);
}
}
#[test]
fn manifest_is_readable_without_the_password() {
let dir = tempfile::tempdir().unwrap();
seed_data(dir.path());
let out = create_backup(
&Paths::with_root(dir.path()),
None,
9,
None,
Some(PW),
ru(),
|_| {},
)
.unwrap();
let m = read_manifest(&out)
.unwrap()
.expect("the manifest should be readable with no password");
assert_eq!(m.app_version, env!("CARGO_PKG_VERSION"));
assert_eq!(
check_password(&out, None).unwrap(),
ArchivePassword::Required
);
}
#[test]
fn an_empty_password_produces_a_plain_archive() {
let dir = tempfile::tempdir().unwrap();
seed_data(dir.path());
let out = create_backup(
&Paths::with_root(dir.path()),
None,
9,
None,
Some(""),
ru(),
|_| {},
)
.unwrap();
assert_eq!(
check_password(&out, None).unwrap(),
ArchivePassword::NotNeeded
);
}
#[test]
fn the_pre_restore_copy_inherits_the_password() {
let src = tempfile::tempdir().unwrap();
seed_data(src.path());
let archive = src.path().join("enc.zip");
create_backup(
&Paths::with_root(src.path()),
Some(archive.clone()),
9,
None,
Some(PW),
ru(),
|_| {},
)
.unwrap();
let dst = tempfile::tempdir().unwrap();
seed_data(dst.path());
let paths = Paths::with_root(dst.path());
let RestoreOutcome::Restored {
pre_restore: Some(pre),
} = restore_backup(&paths, &archive, None, Some(PW), ru(), |_| {}).unwrap()
else {
panic!("expected a Restored with a pre-restore copy");
};
assert_eq!(
check_password(&pre, None).unwrap(),
ArchivePassword::Required
);
assert_eq!(check_password(&pre, Some(PW)).unwrap(), ArchivePassword::Ok);
}
#[test]
fn a_corrupted_encrypted_entry_is_detected() {
let dir = tempfile::tempdir().unwrap();
seed_data(dir.path());
let out = create_backup(
&Paths::with_root(dir.path()),
None,
0,
None,
Some(PW),
ru(),
|_| {},
)
.unwrap();
let mut bytes = fs::read(&out).unwrap();
let second = (4..bytes.len() - 4)
.find(|&i| &bytes[i..i + 4] == b"PK\x03\x04")
.expect("more than one entry");
bytes[second - 5] ^= 0xff;
let corrupt = dir.path().join("corrupt.zip");
fs::write(&corrupt, &bytes).unwrap();
let dst = tempfile::tempdir().unwrap();
let paths = Paths::with_root(dst.path());
let refused = match restore_backup(&paths, &corrupt, None, Some(PW), ru(), |_| {}) {
Err(_) => true,
Ok(RestoreOutcome::Restored { .. }) => false,
Ok(_) => true,
};
assert!(refused, "corrupted ciphertext was accepted");
}
}