//! Applies a restore of a backup which the app prepared, before anything opens the data. //! //! While AI Studio runs, its files are open, and on Windows, they cannot be replaced at all. The app //! therefore unpacks a backup next to the data and marks it as ready. The next start puts it in place, //! before the .NET server and the vector database open a single file. //! //! The contract with the app, cf. `DataRestoreFormat` in the app; keep both in sync: //! //! - `.restore-pending/`, below the data folder and below the configuration folder, holds what the //! backup brings for that folder, in the layout of that folder. //! - `.restore-pending/restore.json` in the data folder names the elements to replace, each with the //! paths below it which stay as they are. //! - `.restore-pending/READY` in the data folder comes last. Without it, the restore was not prepared //! completely, and nothing happens. //! //! What the runtime adds to it: //! //! - `.restore-previous/`, below both folders, keeps what the restore replaced, in the layout of that //! folder. The app removes it once it loaded the restored settings. //! - `.restore-pending/journal.json` in the data folder lists every move before the first one happens, //! and how many of them started. A start which finds it next to `READY` knows that an earlier one //! stopped in the middle, and moves back what that one moved. //! - `.restore-result.json` in the data folder tells the app how the restore ended. //! //! Every move renames a file or folder within the same folder, and thus on the same disk: it is atomic, //! it takes no time for any amount of data, and it can be undone. use std::collections::HashSet; use std::fs; use std::io; use std::io::Write; use std::path::{Path, PathBuf}; use std::thread; use std::time::Duration; use log::{error, info, warn}; use serde::{Deserialize, Serialize}; const PENDING_DIRECTORY: &str = ".restore-pending"; const PREVIOUS_DIRECTORY: &str = ".restore-previous"; const PLAN_FILE_NAME: &str = "restore.json"; const READY_FILE_NAME: &str = "READY"; const JOURNAL_FILE_NAME: &str = "journal.json"; const JOURNAL_TEMPORARY_FILE_NAME: &str = "journal.json.tmp"; const RESULT_FILE_NAME: &str = ".restore-result.json"; /// The version of the plan this runtime reads. const PLAN_FORMAT_VERSION: i32 = 1; const SETTINGS_FILE_NAME: &str = "settings.json"; /// The elements a restore may replace: those which the list of the backup contents in the app /// (`DataBackupContents`) includes. The runtime refuses a plan which names anything else, so that /// whatever goes wrong in the app, a restore never replaces the logs, the downloaded tools, or the /// data folder as a whole. const REPLACEABLE_ELEMENTS: [(Location, &str); 10] = [ (Location::ConfigDirectory, SETTINGS_FILE_NAME), (Location::DataDirectory, "workspaces/"), (Location::DataDirectory, "tempChats/"), (Location::DataDirectory, "databases/provenance/"), (Location::DataDirectory, "plugins/"), (Location::DataDirectory, "tokenizers/"), (Location::DataDirectory, "visualBriefings/"), (Location::DataDirectory, "databases/sqlite/"), (Location::DataDirectory, "databases/vector_database/stores/"), (Location::DataDirectory, "mediaTranscripts/"), ]; /// Paths which a restore always keeps, whatever the plan says: the plugins which come with AI Studio /// or from the organization. The plan adds the plugins of the organization which lie among the plugins /// of the user, since only the app knows them. const ALWAYS_KEPT: [&str; 3] = ["plugins/.internal/", "plugins/.config/", "plugins/.config-tests/"]; /// The settings file which every version of AI Studio keeps for itself, `settings.v6.json` for /// example. When `settings.json` is older than the app, the app prefers the file of its own version, /// which would undo a restore of older settings. These files therefore go together with /// `settings.json`. const SETTINGS_OF_ONE_VERSION_PREFIX: &str = "settings.v"; const SETTINGS_OF_ONE_VERSION_SUFFIX: &str = ".json"; /// How long to wait before each further attempt of a move. On Windows, a file stays locked for a /// moment after the process which held it ended, and virus scanners or the search indexer lock files /// briefly, too. const MOVE_RETRY_DELAYS: [Duration; 6] = [ Duration::from_millis(100), Duration::from_millis(200), Duration::from_millis(400), Duration::from_millis(800), Duration::from_millis(1_600), Duration::from_millis(3_200), ]; /// The folder a path lies in. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] enum Location { DataDirectory, ConfigDirectory, } /// What the app prepared, cf. `DataRestorePlan` in the app. #[derive(Debug, Deserialize)] struct RestorePlan { format_version: i32, prepared_at_utc: String, backup_created_at_utc: String, backup_app_version: String, elements: Vec, } /// One element to replace, cf. `DataRestoreElement` in the app. #[derive(Debug, Deserialize)] struct RestoreElement { location: Location, /// The path within its folder, with forward slashes. A folder ends with a slash. relative_path: String, /// The paths below the element which stay as they are, relative to the same folder. keep: Vec, } /// Renames one file or folder. Both paths are relative to the folder, with forward slashes. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] struct Move { location: Location, from: String, to: String, } impl Move { fn new(location: Location, from: &str, to: &str) -> Self { Move { location, from: from.to_string(), to: to.to_string() } } } /// Every move of a restore, written before the first one happens, and written anew before every /// further one and after every one which was moved back. /// /// The file system alone cannot tell which moves happened: a move which brings back a kept plugin finds /// its destination taken before the restore began. Only the last move the journal counts may or may /// not have happened, and for that one, the file system tells. #[derive(Debug, Serialize, Deserialize)] struct Journal { moves: Vec, /// How many moves started. All of them happened, except perhaps the last one. started: usize, } /// How a restore ended. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] enum RestoreOutcome { /// The restored data are in place. What they replaced lies in `.restore-previous`. Applied, /// The restore did not start, since the plan or the folders did not allow it. Nothing changed. Refused, /// The restore started, but could not finish. Everything was moved back. RolledBack, /// Moving back failed as well. The journal stays, and the next start tries again. RollbackFailed, } /// What the app reads at its next start, in `.restore-result.json`. #[derive(Debug, Serialize, Deserialize)] struct RestoreResult { outcome: RestoreOutcome, /// What happened, in English, for the log. The app tells the user in its own words. detail: String, prepared_at_utc: String, backup_created_at_utc: String, backup_app_version: String, finished_at_utc: String, } /// How a restore ended, and why. struct RestoreEnd { outcome: RestoreOutcome, detail: String, } impl RestoreEnd { fn new(outcome: RestoreOutcome, detail: impl Into) -> Self { RestoreEnd { outcome, detail: detail.into() } } } /// The two folders a restore works in. struct Folders<'a> { data: &'a Path, config: &'a Path, } impl Folders<'_> { fn root(&self, location: Location) -> &Path { match location { Location::DataDirectory => self.data, Location::ConfigDirectory => self.config, } } /// The pending directory of the data folder, which holds the plan, the journal, and `READY`. fn pending(&self) -> PathBuf { self.data.join(PENDING_DIRECTORY) } } /// Applies a restore which the app prepared, or moves back one which an earlier start left unfinished. /// /// Call it before anything opens a file in the data folder. In production, the runtime starts the /// .NET server only afterward. In the development environment, the IDE starts the .NET server on its /// own, so start it after the runtime when you try a restore. pub fn apply_pending_restore(data_directory: &Path, config_directory: &Path) { let folders = Folders { data: data_directory, config: config_directory }; apply_pending_restore_in(&folders, &MOVE_RETRY_DELAYS); } /// Applies a pending restore, and returns how it ended, or `None` when there was nothing to do. fn apply_pending_restore_in(folders: &Folders, retry_delays: &[Duration]) -> Option { let pending = folders.pending(); let is_ready = exists(&pending.join(READY_FILE_NAME)); let journal_path = pending.join(JOURNAL_FILE_NAME); if exists(&journal_path) { if !is_ready { info!(Source = "Restore"; "Finishing the clean-up of the restore which an earlier start applied."); clean_up_after_success(folders); return None; } warn!(Source = "Restore"; "An earlier start stopped while it applied a restore. Moving the replaced data back."); let plan = read_plan(&pending).ok(); let end = match read_journal(&journal_path) { Ok(mut journal) => roll_back(folders, &mut journal, retry_delays, "AI Studio stopped while it applied the restore."), Err(error) => RestoreEnd::new(RestoreOutcome::RollbackFailed, format!("The journal of the restore could not be read: {error}")), }; return Some(finish(folders, plan.as_ref(), end, retry_delays)); } if !is_ready { return None; } info!(Source = "Restore"; "Applying the prepared restore of a backup."); let (plan, end) = match read_plan(&pending) { Ok(plan) => { let end = apply(folders, &plan, retry_delays); (Some(plan), end) }, Err(error) => (None, RestoreEnd::new(RestoreOutcome::Refused, error)), }; Some(finish(folders, plan.as_ref(), end, retry_delays)) } /// Moves the restored elements into place, and back again when a move fails. fn apply(folders: &Folders, plan: &RestorePlan, retry_delays: &[Duration]) -> RestoreEnd { let mut journal = match begin(folders, plan, retry_delays) { Ok(journal) => journal, Err(detail) => return RestoreEnd::new(RestoreOutcome::Refused, detail), }; while journal.started < journal.moves.len() { if let Err(detail) = execute_next(folders, &mut journal, retry_delays) { error!(Source = "Restore"; "{detail} Moving the replaced data back."); return roll_back(folders, &mut journal, retry_delays, &detail); } } RestoreEnd::new(RestoreOutcome::Applied, format!("Replaced {} elements with {} moves.", plan.elements.len(), journal.moves.len())) } /// Determines the moves of a restore and writes them into the journal, before any of them happens. fn begin(folders: &Folders, plan: &RestorePlan, retry_delays: &[Duration]) -> Result { let journal = Journal { moves: plan_moves(folders, plan)?, started: 0 }; write_journal(&folders.pending(), &journal, retry_delays)?; Ok(journal) } /// Executes the next move, once the journal counts it. fn execute_next(folders: &Folders, journal: &mut Journal, retry_delays: &[Duration]) -> Result<(), String> { let step = journal.moves[journal.started].clone(); journal.started += 1; write_journal(&folders.pending(), journal, retry_delays)?; execute(folders, &step, retry_delays) .map_err(|error| format!("Could not move '{}' to '{}': {error}", step.from, step.to)) } /// Determines every move of a restore, in the order they happen. For each element: move the current /// one aside, move the restored one in, and move back what stays. fn plan_moves(folders: &Folders, plan: &RestorePlan) -> Result, String> { if plan.format_version != PLAN_FORMAT_VERSION { return Err(format!("The plan has the format version {}, but this runtime reads version {PLAN_FORMAT_VERSION}.", plan.format_version)); } // Empty folders are what moving back a restore may leave behind; files are the data of the user: for root in [folders.data, folders.config] { let previous = root.join(PREVIOUS_DIRECTORY); if !remove_empty_directories(&previous) { return Err(format!("'{}' still keeps the data which an earlier restore replaced.", previous.display())); } } let mut planned_elements = HashSet::new(); let mut moves = Vec::new(); for element in &plan.elements { let is_replaceable = REPLACEABLE_ELEMENTS.iter().any(|(location, path)| *location == element.location && *path == element.relative_path); if !is_replaceable { return Err(format!("A restore may not replace '{}'.", element.relative_path)); } if !planned_elements.insert((element.location, element.relative_path.as_str())) { return Err(format!("The plan names '{}' twice.", element.relative_path)); } let root = folders.root(element.location); let target = element.relative_path.trim_end_matches('/'); let staged = format!("{PENDING_DIRECTORY}/{target}"); let previous = format!("{PREVIOUS_DIRECTORY}/{target}"); if exists(&resolve(root, target)) { moves.push(Move::new(element.location, target, &previous)); } if element.location == Location::ConfigDirectory && target == SETTINGS_FILE_NAME { for name in settings_of_single_versions(root)? { moves.push(Move::new(element.location, &name, &format!("{PREVIOUS_DIRECTORY}/{name}"))); } } if exists(&resolve(root, &staged)) { moves.push(Move::new(element.location, &staged, target)); } for kept in kept_paths(element)? { if !exists(&resolve(root, &kept)) { continue; } let below = &kept[target.len()..]; if exists(&resolve(root, &format!("{staged}{below}"))) { return Err(format!("The backup brings '{kept}', which the restore keeps.")); } moves.push(Move::new(element.location, &format!("{previous}{below}"), &kept)); } } Ok(moves) } /// The paths below an element which stay as they are, without a trailing slash. A path below another /// one moves along with that one, so only the outermost ones remain. fn kept_paths(element: &RestoreElement) -> Result, String> { let element_path = element.relative_path.as_str(); let always_kept = ALWAYS_KEPT.iter().copied().filter(|path| path.starts_with(element_path)); let mut kept = Vec::new(); for path in element.keep.iter().map(String::as_str).chain(always_kept) { let lies_below = element_path.ends_with('/') && path.len() > element_path.len() && path.starts_with(element_path); if !lies_below || !is_safe_relative_path(path) { return Err(format!("A restore cannot keep '{path}' of '{element_path}'.")); } kept.push(path.trim_end_matches('/').to_string()); } kept.sort(); kept.dedup(); let outermost = kept.iter() .filter(|path| !kept.iter().any(|other| path.starts_with(&format!("{other}/")))) .cloned() .collect(); Ok(outermost) } /// Whether a path from the plan stays within its folder: relative, with forward slashes, and without /// `.` or `..`. fn is_safe_relative_path(path: &str) -> bool { !path.is_empty() && !path.starts_with('/') && !path.contains('\\') && !path.contains(':') && path.trim_end_matches('/').split('/').all(|segment| !segment.is_empty() && segment != "." && segment != "..") } /// The settings files of single versions in the configuration folder, cf. SETTINGS_OF_ONE_VERSION_PREFIX. fn settings_of_single_versions(config_directory: &Path) -> Result, String> { let entries = match fs::read_dir(config_directory) { Ok(entries) => entries, Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), Err(error) => return Err(format!("Could not read '{}': {error}", config_directory.display())), }; let mut names = Vec::new(); for entry in entries { let entry = entry.map_err(|error| format!("Could not read '{}': {error}", config_directory.display()))?; let is_file = entry.file_type().map(|file_type| file_type.is_file()).unwrap_or(false); let file_name = entry.file_name(); if let Some(name) = file_name.to_str() && is_file && is_settings_of_single_version(name) { names.push(name.to_string()); } } names.sort(); Ok(names) } fn is_settings_of_single_version(file_name: &str) -> bool { file_name.len() > SETTINGS_OF_ONE_VERSION_PREFIX.len() + SETTINGS_OF_ONE_VERSION_SUFFIX.len() && file_name.starts_with(SETTINGS_OF_ONE_VERSION_PREFIX) && file_name.ends_with(SETTINGS_OF_ONE_VERSION_SUFFIX) } /// Executes one move. fn execute(folders: &Folders, step: &Move, retry_delays: &[Duration]) -> io::Result<()> { let root = folders.root(step.location); move_path(&resolve(root, &step.from), &resolve(root, &step.to), retry_delays) } /// Undoes the last move the journal counts. That one may not have happened: then its source is still in /// place, and its destination is free. fn undo(folders: &Folders, step: &Move, retry_delays: &[Duration]) -> io::Result<()> { let root = folders.root(step.location); let from = resolve(root, &step.from); let to = resolve(root, &step.to); if exists(&from) || !exists(&to) { return Ok(()); } move_path(&to, &from, retry_delays) } /// Undoes the moves the journal counts, in the reverse order. It stops at the first move which cannot /// be undone: every further one could otherwise carry along data which that move left in the wrong /// place. The journal then names that move as the last one, and the next start tries again from there. fn roll_back(folders: &Folders, journal: &mut Journal, retry_delays: &[Duration], cause: &str) -> RestoreEnd { while journal.started > 0 { let step = &journal.moves[journal.started - 1]; if let Err(error) = undo(folders, step, retry_delays) { return RestoreEnd::new(RestoreOutcome::RollbackFailed, format!("{cause} Could not move '{}' back to '{}': {error}", step.to, step.from)); } journal.started -= 1; if let Err(error) = write_journal(&folders.pending(), journal, retry_delays) { return RestoreEnd::new(RestoreOutcome::RollbackFailed, format!("{cause} {error}")); } } RestoreEnd::new(RestoreOutcome::RolledBack, cause) } /// Writes the result and leaves the folders in the state the outcome calls for. fn finish(folders: &Folders, plan: Option<&RestorePlan>, end: RestoreEnd, retry_delays: &[Duration]) -> RestoreOutcome { match end.outcome { RestoreOutcome::Applied => info!(Source = "Restore"; "The restore was applied: {}", end.detail), RestoreOutcome::Refused => warn!(Source = "Restore"; "The restore was refused, nothing changed: {}", end.detail), RestoreOutcome::RolledBack => warn!(Source = "Restore"; "The restore failed and was moved back: {}", end.detail), RestoreOutcome::RollbackFailed => error!(Source = "Restore"; "The restore failed, and moving it back failed as well. The next start tries again: {}", end.detail), } write_result(folders, plan, &end); if end.outcome == RestoreOutcome::RollbackFailed { return end.outcome; } // // Without READY, the next start does not try again. While the journal remains next to READY, it // moves everything back instead, so READY goes first: // if let Err(error) = remove_file_if_present(&folders.pending().join(READY_FILE_NAME), retry_delays) { error!(Source = "Restore"; "Could not remove the mark of the prepared restore: {error}"); return end.outcome; } match end.outcome { RestoreOutcome::Applied => clean_up_after_success(folders), // // The restored data went back to the pending directories, and the app removes them. What // remains of the directories for the replaced data is empty: // RestoreOutcome::RolledBack => { if let Err(error) = remove_file_if_present(&folders.pending().join(JOURNAL_FILE_NAME), retry_delays) { error!(Source = "Restore"; "Could not remove the journal of the restore: {error}"); } for root in [folders.data, folders.config] { remove_empty_directories(&root.join(PREVIOUS_DIRECTORY)); } }, RestoreOutcome::Refused | RestoreOutcome::RollbackFailed => {}, } end.outcome } /// Removes what remains of the pending directories once the restored data are in place: the plan, the /// journal, and empty folders. Anything else stays for the app. fn clean_up_after_success(folders: &Folders) { let pending = folders.pending(); for file_name in [JOURNAL_FILE_NAME, PLAN_FILE_NAME] { if let Err(error) = remove_file_if_present(&pending.join(file_name), &[]) { warn!(Source = "Restore"; "Could not remove '{file_name}' of the applied restore: {error}"); } } for root in [folders.data, folders.config] { let pending = root.join(PENDING_DIRECTORY); if !remove_empty_directories(&pending) { warn!(Source = "Restore"; "'{}' still holds files after the restore. The app removes them.", pending.display()); } } } fn write_result(folders: &Folders, plan: Option<&RestorePlan>, end: &RestoreEnd) { let result = RestoreResult { outcome: end.outcome, detail: end.detail.clone(), prepared_at_utc: plan.map(|plan| plan.prepared_at_utc.clone()).unwrap_or_default(), backup_created_at_utc: plan.map(|plan| plan.backup_created_at_utc.clone()).unwrap_or_default(), backup_app_version: plan.map(|plan| plan.backup_app_version.clone()).unwrap_or_default(), finished_at_utc: chrono::Utc::now().to_rfc3339(), }; let path = folders.data.join(RESULT_FILE_NAME); let written = serde_json::to_vec_pretty(&result) .map_err(io::Error::other) .and_then(|json| fs::write(&path, json)); if let Err(error) = written { error!(Source = "Restore"; "Could not write the result of the restore to '{}': {error}", path.display()); } } fn read_plan(pending: &Path) -> Result { let path = pending.join(PLAN_FILE_NAME); let json = fs::read(&path).map_err(|error| format!("Could not read the plan '{}': {error}", path.display()))?; serde_json::from_slice(&json).map_err(|error| format!("Could not read the plan '{}': {error}", path.display())) } fn read_journal(path: &Path) -> Result { let json = fs::read(path).map_err(|error| error.to_string())?; serde_json::from_slice(&json).map_err(|error| error.to_string()) } /// Writes the journal under another name first, so that it is either complete or the one before. fn write_journal(pending: &Path, journal: &Journal, retry_delays: &[Duration]) -> Result<(), String> { let temporary_path = pending.join(JOURNAL_TEMPORARY_FILE_NAME); let written = serde_json::to_vec_pretty(journal) .map_err(io::Error::other) .and_then(|json| { let mut file = fs::File::create(&temporary_path)?; file.write_all(&json)?; file.sync_all() }) .and_then(|_| with_retries(retry_delays, || fs::rename(&temporary_path, pending.join(JOURNAL_FILE_NAME)))); written.map_err(|error| format!("Could not write the journal of the restore: {error}")) } /// Renames a file or folder, and never replaces anything at the destination. fn move_path(from: &Path, to: &Path, retry_delays: &[Duration]) -> io::Result<()> { if exists(to) { return Err(io::Error::new(io::ErrorKind::AlreadyExists, format!("'{}' exists already", to.display()))); } if let Some(parent) = to.parent() { fs::create_dir_all(parent)?; } with_retries(retry_delays, || fs::rename(from, to)) } fn remove_file_if_present(path: &Path, retry_delays: &[Duration]) -> io::Result<()> { match with_retries(retry_delays, || fs::remove_file(path)) { Err(error) if error.kind() != io::ErrorKind::NotFound => Err(error), _ => Ok(()), } } /// Runs an operation again after each of the given delays, as long as it fails for another reason /// than a missing file. fn with_retries(retry_delays: &[Duration], mut operation: impl FnMut() -> io::Result<()>) -> io::Result<()> { let mut delays = retry_delays.iter(); loop { let error = match operation() { Ok(()) => return Ok(()), Err(error) => error, }; match delays.next() { Some(delay) if error.kind() != io::ErrorKind::NotFound => { warn!(Source = "Restore"; "{error}. Trying again in {delay:?}."); thread::sleep(*delay); }, _ => return Err(error), } } } /// Removes a folder with all folders below it, as long as none of them holds a file. Returns whether /// the folder is gone. fn remove_empty_directories(path: &Path) -> bool { let Ok(metadata) = fs::symlink_metadata(path) else { return true; }; if !metadata.is_dir() { return false; } let Ok(entries) = fs::read_dir(path) else { return false; }; let mut is_empty = true; for entry in entries.flatten() { if !remove_empty_directories(&entry.path()) { is_empty = false; } } is_empty && fs::remove_dir(path).is_ok() } /// Whether a file or folder exists. A link counts as itself, wherever it points to. fn exists(path: &Path) -> bool { fs::symlink_metadata(path).is_ok() } /// The path of a relative path with forward slashes, below the given folder. fn resolve(root: &Path, relative_path: &str) -> PathBuf { relative_path .split('/') .filter(|segment| !segment.is_empty()) .fold(root.to_path_buf(), |path, segment| path.join(segment)) } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; /// The plugins of the organization which lie among the plugins of the user, as the app names them. const ORGANIZATION_PLUGIN: &str = "plugins/company-assistant/"; /// A data folder and a configuration folder in a temporary directory of their own. struct TestFolders { _directory: TempDir, data: PathBuf, config: PathBuf, } impl TestFolders { fn new() -> Self { let directory = tempfile::tempdir().unwrap(); let data = directory.path().join("data"); let config = directory.path().join("config"); fs::create_dir_all(&data).unwrap(); fs::create_dir_all(&config).unwrap(); TestFolders { _directory: directory, data, config } } fn folders(&self) -> Folders<'_> { Folders { data: &self.data, config: &self.config } } fn write(&self, location: Location, relative_path: &str, content: &str) { let path = resolve(self.folders().root(location), relative_path); fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write(path, content).unwrap(); } fn read(&self, location: Location, relative_path: &str) -> Option { fs::read_to_string(resolve(self.folders().root(location), relative_path)).ok() } fn exists(&self, location: Location, relative_path: &str) -> bool { exists(&resolve(self.folders().root(location), relative_path)) } fn result(&self) -> RestoreResult { serde_json::from_slice(&fs::read(self.data.join(RESULT_FILE_NAME)).unwrap()).unwrap() } /// The data of a user who has used AI Studio for a while. fn write_current_data(&self) { self.write(Location::ConfigDirectory, "settings.json", "current settings"); self.write(Location::ConfigDirectory, "settings.v6.json", "current settings of v6"); self.write(Location::DataDirectory, "workspaces/current/chat/thread.json", "current chat"); self.write(Location::DataDirectory, "plugins/my-old-assistant/plugin.lua", "current plugin"); self.write(Location::DataDirectory, "plugins/.internal/languages/plugin.lua", "built-in plugin"); self.write(Location::DataDirectory, "plugins/.config/organization/plugin.lua", "organization plugin"); self.write(Location::DataDirectory, "plugins/company-assistant/plugin.lua", "managed plugin"); self.write(Location::DataDirectory, "events.log", "log"); } /// A restore of a backup with other settings, another chat, and another plugin, as the app /// prepares it. fn prepare_restore(&self, elements: &str) { self.write(Location::ConfigDirectory, ".restore-pending/settings.json", "restored settings"); self.write(Location::DataDirectory, ".restore-pending/workspaces/restored/chat/thread.json", "restored chat"); self.write(Location::DataDirectory, ".restore-pending/plugins/my-assistant/plugin.lua", "restored plugin"); self.write(Location::DataDirectory, ".restore-pending/restore.json", &plan_json(elements)); self.write(Location::DataDirectory, ".restore-pending/READY", "2026-10-15T08:00:00.0000000+00:00"); } } /// A plan as the app writes it. fn plan_json(elements: &str) -> String { format!(r#"{{ "format_version": 1, "prepared_at_utc": "2026-10-15T08:00:00.0000000+00:00", "backup_created_at_utc": "2026-10-01T17:30:00.0000000+00:00", "backup_app_version": "26.10.1", "elements": [{elements}] }}"#) } /// The elements of a full restore, as the app names them. fn all_elements() -> String { format!(r#" {{ "location": "CONFIG_DIRECTORY", "relative_path": "settings.json", "keep": [] }}, {{ "location": "DATA_DIRECTORY", "relative_path": "workspaces/", "keep": [] }}, {{ "location": "DATA_DIRECTORY", "relative_path": "plugins/", "keep": ["plugins/.internal/", "plugins/.config/", "plugins/.config-tests/", "plugins/.lock", "{ORGANIZATION_PLUGIN}"] }}"#) } /// Checks that the data are exactly as before the restore. fn assert_current_data_untouched(test: &TestFolders) { assert_eq!(test.read(Location::ConfigDirectory, "settings.json").as_deref(), Some("current settings")); assert_eq!(test.read(Location::ConfigDirectory, "settings.v6.json").as_deref(), Some("current settings of v6")); assert_eq!(test.read(Location::DataDirectory, "workspaces/current/chat/thread.json").as_deref(), Some("current chat")); assert_eq!(test.read(Location::DataDirectory, "plugins/my-old-assistant/plugin.lua").as_deref(), Some("current plugin")); assert_eq!(test.read(Location::DataDirectory, "plugins/.internal/languages/plugin.lua").as_deref(), Some("built-in plugin")); assert_eq!(test.read(Location::DataDirectory, "plugins/company-assistant/plugin.lua").as_deref(), Some("managed plugin")); assert!(!test.exists(Location::DataDirectory, "workspaces/restored")); assert!(!test.exists(Location::DataDirectory, "plugins/my-assistant")); } #[test] fn a_restore_replaces_the_elements_and_keeps_what_belongs_to_this_computer() { let test = TestFolders::new(); test.write_current_data(); test.prepare_restore(&all_elements()); let outcome = apply_pending_restore_in(&test.folders(), &[]); assert_eq!(outcome, Some(RestoreOutcome::Applied)); assert_eq!(test.read(Location::ConfigDirectory, "settings.json").as_deref(), Some("restored settings")); assert!(!test.exists(Location::ConfigDirectory, "settings.v6.json"), "The app would prefer it over older restored settings."); assert_eq!(test.read(Location::DataDirectory, "workspaces/restored/chat/thread.json").as_deref(), Some("restored chat")); assert!(!test.exists(Location::DataDirectory, "workspaces/current")); assert_eq!(test.read(Location::DataDirectory, "plugins/my-assistant/plugin.lua").as_deref(), Some("restored plugin")); assert!(!test.exists(Location::DataDirectory, "plugins/my-old-assistant")); assert_eq!(test.read(Location::DataDirectory, "plugins/.internal/languages/plugin.lua").as_deref(), Some("built-in plugin")); assert_eq!(test.read(Location::DataDirectory, "plugins/.config/organization/plugin.lua").as_deref(), Some("organization plugin")); assert_eq!(test.read(Location::DataDirectory, "plugins/company-assistant/plugin.lua").as_deref(), Some("managed plugin")); assert_eq!(test.read(Location::DataDirectory, "events.log").as_deref(), Some("log"), "The logs are not part of a restore."); assert_eq!(test.read(Location::DataDirectory, ".restore-previous/workspaces/current/chat/thread.json").as_deref(), Some("current chat")); assert_eq!(test.read(Location::ConfigDirectory, ".restore-previous/settings.json").as_deref(), Some("current settings")); assert_eq!(test.read(Location::ConfigDirectory, ".restore-previous/settings.v6.json").as_deref(), Some("current settings of v6")); assert!(!test.exists(Location::DataDirectory, ".restore-pending")); assert!(!test.exists(Location::ConfigDirectory, ".restore-pending")); let result = test.result(); assert_eq!(result.outcome, RestoreOutcome::Applied); assert_eq!(result.backup_app_version, "26.10.1"); } #[test] fn a_restore_which_is_not_marked_ready_changes_nothing() { let test = TestFolders::new(); test.write_current_data(); test.prepare_restore(&all_elements()); fs::remove_file(test.data.join(".restore-pending").join(READY_FILE_NAME)).unwrap(); let outcome = apply_pending_restore_in(&test.folders(), &[]); assert_eq!(outcome, None); assert_current_data_untouched(&test); } #[test] fn a_restore_replaces_the_media_transcripts_of_the_data_sources() { let test = TestFolders::new(); test.write_current_data(); test.write(Location::DataDirectory, "mediaTranscripts/current/transcript.md", "current transcript"); test.prepare_restore(r#"{ "location": "DATA_DIRECTORY", "relative_path": "mediaTranscripts/", "keep": [] }"#); test.write(Location::DataDirectory, ".restore-pending/mediaTranscripts/restored/transcript.md", "restored transcript"); let outcome = apply_pending_restore_in(&test.folders(), &[]); assert_eq!(outcome, Some(RestoreOutcome::Applied)); assert_eq!(test.read(Location::DataDirectory, "mediaTranscripts/restored/transcript.md").as_deref(), Some("restored transcript")); assert!(!test.exists(Location::DataDirectory, "mediaTranscripts/current")); } #[test] fn a_plan_which_names_another_element_is_refused() { for relative_path in ["", "events.log", "databases/", "../data/", "pandoc/"] { let test = TestFolders::new(); test.write_current_data(); test.prepare_restore(&format!(r#"{{ "location": "DATA_DIRECTORY", "relative_path": "{relative_path}", "keep": [] }}"#)); let outcome = apply_pending_restore_in(&test.folders(), &[]); assert_eq!(outcome, Some(RestoreOutcome::Refused), "'{relative_path}' must not be replaced."); assert_current_data_untouched(&test); assert!(!test.exists(Location::DataDirectory, ".restore-pending/READY"), "A refused restore must not be tried again at every start."); } } #[test] fn a_kept_path_outside_its_element_is_refused() { for kept in ["../events.log", "workspaces/", "plugins/../events.log", "plugins/", "/plugins/x/", "plugins\\\\x"] { let test = TestFolders::new(); test.write_current_data(); test.prepare_restore(&format!(r#"{{ "location": "DATA_DIRECTORY", "relative_path": "plugins/", "keep": ["{kept}"] }}"#)); let outcome = apply_pending_restore_in(&test.folders(), &[]); assert_eq!(outcome, Some(RestoreOutcome::Refused), "'{kept}' must not be kept."); assert_current_data_untouched(&test); } } #[test] fn a_backup_which_brings_a_kept_path_is_refused() { let test = TestFolders::new(); test.write_current_data(); test.prepare_restore(&all_elements()); test.write(Location::DataDirectory, ".restore-pending/plugins/.internal/languages/plugin.lua", "foreign built-in plugin"); let outcome = apply_pending_restore_in(&test.folders(), &[]); assert_eq!(outcome, Some(RestoreOutcome::Refused)); assert_current_data_untouched(&test); } #[test] fn a_restore_is_refused_while_the_data_of_an_earlier_one_are_kept() { let test = TestFolders::new(); test.write_current_data(); test.prepare_restore(&all_elements()); test.write(Location::DataDirectory, ".restore-previous/workspaces/older/chat/thread.json", "older chat"); let outcome = apply_pending_restore_in(&test.folders(), &[]); assert_eq!(outcome, Some(RestoreOutcome::Refused)); assert_current_data_untouched(&test); assert_eq!(test.read(Location::DataDirectory, ".restore-previous/workspaces/older/chat/thread.json").as_deref(), Some("older chat")); } #[test] fn empty_folders_which_an_earlier_restore_left_do_not_block_a_restore() { let test = TestFolders::new(); test.write_current_data(); test.prepare_restore(&all_elements()); fs::create_dir_all(test.data.join(".restore-previous").join("plugins")).unwrap(); let outcome = apply_pending_restore_in(&test.folders(), &[]); assert_eq!(outcome, Some(RestoreOutcome::Applied)); } #[test] fn a_move_which_fails_moves_everything_back() { let test = TestFolders::new(); test.write_current_data(); // A file in the way of the folder of the vector stores lets the last move fail: test.write(Location::DataDirectory, "databases", "not a folder"); test.write(Location::DataDirectory, ".restore-pending/databases/vector_database/stores/store_1/segment", "restored store"); test.prepare_restore(&format!(r#"{}, {{ "location": "DATA_DIRECTORY", "relative_path": "databases/vector_database/stores/", "keep": [] }}"#, all_elements())); let outcome = apply_pending_restore_in(&test.folders(), &[]); assert_eq!(outcome, Some(RestoreOutcome::RolledBack)); assert_eq!(test.result().outcome, RestoreOutcome::RolledBack); assert_current_data_untouched(&test); assert!(!test.exists(Location::DataDirectory, ".restore-previous")); assert!(!test.exists(Location::ConfigDirectory, ".restore-previous")); assert!(!test.exists(Location::DataDirectory, ".restore-pending/READY")); assert!(!test.exists(Location::DataDirectory, ".restore-pending/journal.json")); assert_eq!(test.read(Location::DataDirectory, ".restore-pending/workspaces/restored/chat/thread.json").as_deref(), Some("restored chat"), "The app removes the restored data."); } #[test] fn a_restore_which_stopped_in_the_middle_is_moved_back_at_the_next_start() { let move_count = { let test = TestFolders::new(); test.write_current_data(); test.prepare_restore(&all_elements()); plan_moves(&test.folders(), &read_plan(&test.folders().pending()).unwrap()).unwrap().len() }; for completed_moves in 0..=move_count { let test = TestFolders::new(); test.write_current_data(); test.prepare_restore(&all_elements()); let mut journal = begin(&test.folders(), &read_plan(&test.folders().pending()).unwrap(), &[]).unwrap(); for _ in 0..completed_moves { execute_next(&test.folders(), &mut journal, &[]).unwrap(); } let outcome = apply_pending_restore_in(&test.folders(), &[]); assert_eq!(outcome, Some(RestoreOutcome::RolledBack), "after {completed_moves} of {move_count} moves"); assert_current_data_untouched(&test); assert!(!test.exists(Location::DataDirectory, ".restore-previous"), "after {completed_moves} of {move_count} moves"); assert!(!test.exists(Location::DataDirectory, ".restore-pending/READY")); assert_eq!(apply_pending_restore_in(&test.folders(), &[]), None, "The next start finds nothing to do."); } } #[test] fn a_clean_up_which_stopped_is_finished_at_the_next_start() { let test = TestFolders::new(); test.write(Location::DataDirectory, ".restore-pending/journal.json", r#"{ "moves": [], "started": 0 }"#); test.write(Location::DataDirectory, ".restore-pending/restore.json", &plan_json("")); fs::create_dir_all(test.data.join(".restore-pending").join("databases").join("sqlite")).unwrap(); let outcome = apply_pending_restore_in(&test.folders(), &[]); assert_eq!(outcome, None); assert!(!test.exists(Location::DataDirectory, ".restore-pending")); } #[test] fn a_kept_path_below_another_one_moves_along_with_it() { let element = RestoreElement { location: Location::DataDirectory, relative_path: "plugins/".to_string(), keep: vec!["plugins/.config/".to_string(), "plugins/.config/organization/".to_string(), "plugins/.lock".to_string()], }; assert_eq!(kept_paths(&element).unwrap(), vec!["plugins/.config", "plugins/.config-tests", "plugins/.internal", "plugins/.lock"]); } #[test] fn the_settings_of_single_versions_are_recognized_by_their_name() { assert!(is_settings_of_single_version("settings.v6.json")); assert!(is_settings_of_single_version("settings.v12.json")); assert!(!is_settings_of_single_version("settings.json")); assert!(!is_settings_of_single_version("settings.v.json")); assert!(!is_settings_of_single_version("settings.v6.json.tmp")); } }