From 3ac9a9d7c8a9df4ce6e20d4b2cfc6cb25a079f2e Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:49:20 -0300 Subject: [PATCH 1/2] feat(backup): import a backup, swapped in on the next start backup.stage opens a backup with the passphrase into /restore and checks its manifest and database; backup.confirm marks it, and on the next start, before the database opens, the daemon unpacks it, moves the current database and the whole workspaces_root aside (never deleted), and puts the backup's in place. backup.cancel drops it. A marker is cleared first, so a failing backup is not tried forever. Spec 14.2. --- app/src/lib/fakeBackup.ts | 55 +++-- app/src/lib/protocol.gen.ts | 8 + crates/botloft-core/src/protocol/backup.rs | 9 + .../src/protocol/export_backup.rs | 8 + crates/botloft-core/src/protocol/mod.rs | 3 + crates/botloftd/src/backup/mod.rs | 1 + crates/botloftd/src/backup/restore.rs | 198 ++++++++++++++++++ crates/botloftd/src/main.rs | 8 + crates/botloftd/src/rpc/dispatch.rs | 3 + crates/botloftd/src/service/backup.rs | 33 ++- crates/botloftd/tests/backup_restore.rs | 89 ++++++++ docs/spec.md | 5 +- 12 files changed, 397 insertions(+), 23 deletions(-) create mode 100644 crates/botloftd/src/backup/restore.rs create mode 100644 crates/botloftd/tests/backup_restore.rs diff --git a/app/src/lib/fakeBackup.ts b/app/src/lib/fakeBackup.ts index 81ec2ea3..a415784f 100644 --- a/app/src/lib/fakeBackup.ts +++ b/app/src/lib/fakeBackup.ts @@ -1,39 +1,54 @@ // The fake daemon's backups (spec 14.2): what an export would hold, from -// the fake crews, at a made-up path. Nothing is sealed or written. +// the fake crews, at a made-up path, and a backup that opens only with +// `FAKE_PASSPHRASE`. Nothing is sealed, written or restored. import type { FakeBotloft, Handlers } from "./fake"; -import { RpcErrorCode } from "./protocol.gen"; +import { type BackupManifest, RpcErrorCode } from "./protocol.gen"; import { RpcError } from "./rpc"; type BackupMethods = Extract; +/** The passphrase the fake's backups open with. */ +export const FAKE_PASSPHRASE = "correct horse"; + +function refused(reason: string, message: string): RpcError { + return new RpcError(RpcErrorCode.validation, message, reason); +} + +function manifestOf(fake: FakeBotloft): BackupManifest { + return { + format: 1, + createdAt: fake.now, + version: "0.10.0", + crews: [...fake.crews.values()].map((crew) => ({ + name: crew.name, + bots: [...fake.bots.values()] + .filter((bot) => bot.crewId === crew.id && bot.archivedAt === null) + .map((bot) => bot.name), + workFolder: crew.workFolderChosen ? crew.workFolder : null, + })), + }; +} + export function backupHandlers(fake: FakeBotloft): Pick { return { "backup.export": ({ passphrase }) => { if ([...passphrase].length < 8) { - throw new RpcError( - RpcErrorCode.validation, - "the passphrase must have at least 8 characters", - "short_passphrase", - ); + throw refused("short_passphrase", "the passphrase must have at least 8 characters"); } - const crews = [...fake.crews.values()]; return { path: "C:\\Users\\owner\\AppData\\Local\\Botloft\\exports\\botloft-2026-10-05-1430.botloft", size: 1_234_567, - manifest: { - format: 1, - createdAt: fake.now, - version: "0.10.0", - crews: crews.map((crew) => ({ - name: crew.name, - bots: [...fake.bots.values()] - .filter((bot) => bot.crewId === crew.id && bot.archivedAt === null) - .map((bot) => bot.name), - workFolder: crew.workFolderChosen ? crew.workFolder : null, - })), - }, + manifest: manifestOf(fake), }; }, + "backup.stage": ({ passphrase }) => { + if (passphrase !== FAKE_PASSPHRASE) { + throw refused("wrong_passphrase", "the passphrase is wrong, or the file is damaged"); + } + return manifestOf(fake); + }, + "backup.confirm": () => null, + "backup.cancel": () => null, }; } diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index 4a584c48..793b7949 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -1079,6 +1079,11 @@ export type BackupExported = { path: string, */ size: number, manifest: BackupManifest, }; +/** + * `backup.stage`: a backup file the owner picked, and its passphrase. + */ +export type BackupStageParams = { path: string, passphrase: string, }; + export type ApprovalItem = { approvalId: ApprovalId, toolName: string, summary: string, /** * What the bot says the command is for, as in `ToolItem`. The bot @@ -1778,6 +1783,9 @@ export interface RpcMethods { "crewAccess.list": { params: CrewAccessListParams; result: Array }; "crewAccess.revoke": { params: CrewAccessIdParams; result: Array }; "backup.export": { params: BackupExportParams; result: BackupExported }; + "backup.stage": { params: BackupStageParams; result: BackupManifest }; + "backup.confirm": { params: undefined; result: null }; + "backup.cancel": { params: undefined; result: null }; "routines.list": { params: RoutinesListParams; result: Array }; "routines.create": { params: RoutinesCreateParams; result: Routine }; "routines.update": { params: RoutinesUpdateParams; result: Routine }; diff --git a/crates/botloft-core/src/protocol/backup.rs b/crates/botloft-core/src/protocol/backup.rs index 11d6bd59..a2d685ce 100644 --- a/crates/botloft-core/src/protocol/backup.rs +++ b/crates/botloft-core/src/protocol/backup.rs @@ -35,6 +35,15 @@ pub struct BackupExportParams { pub passphrase: String, } +/// `backup.stage`: a backup file the owner picked, and its passphrase. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct BackupStageParams { + pub path: String, + pub passphrase: String, +} + /// A backup written and ready for the owner to save elsewhere. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/botloft-core/src/protocol/export_backup.rs b/crates/botloft-core/src/protocol/export_backup.rs index e6103bc7..009520b2 100644 --- a/crates/botloft-core/src/protocol/export_backup.rs +++ b/crates/botloft-core/src/protocol/export_backup.rs @@ -8,6 +8,7 @@ pub(super) fn decls(out: &mut Out) { out.decl::(); out.decl::(); out.decl::(); + out.decl::(); } pub(super) fn methods(out: &mut Out) { @@ -16,4 +17,11 @@ pub(super) fn methods(out: &mut Out) { &out.name::(), &out.name::(), ); + out.method( + method::BACKUP_STAGE, + &out.name::(), + &out.name::(), + ); + out.method(method::BACKUP_CONFIRM, "undefined", "null"); + out.method(method::BACKUP_CANCEL, "undefined", "null"); } diff --git a/crates/botloft-core/src/protocol/mod.rs b/crates/botloft-core/src/protocol/mod.rs index 05b0e970..becfa437 100644 --- a/crates/botloft-core/src/protocol/mod.rs +++ b/crates/botloft-core/src/protocol/mod.rs @@ -129,6 +129,9 @@ pub mod method { pub const RULES_LIST: &str = "rules.list"; pub const RULES_DELETE: &str = "rules.delete"; pub const BACKUP_EXPORT: &str = "backup.export"; + pub const BACKUP_STAGE: &str = "backup.stage"; + pub const BACKUP_CONFIRM: &str = "backup.confirm"; + pub const BACKUP_CANCEL: &str = "backup.cancel"; pub const CREW_ACCESS_LIST: &str = "crewAccess.list"; pub const CREW_ACCESS_REVOKE: &str = "crewAccess.revoke"; pub const DESKTOP_GRANTS: &str = "desktop.grants"; diff --git a/crates/botloftd/src/backup/mod.rs b/crates/botloftd/src/backup/mod.rs index 22f43e56..0818da0b 100644 --- a/crates/botloftd/src/backup/mod.rs +++ b/crates/botloftd/src/backup/mod.rs @@ -2,6 +2,7 @@ //! crew's folder, in one file sealed with a passphrase only the owner has. pub mod export; +pub mod restore; pub mod seal; /// The extension of a backup file. diff --git a/crates/botloftd/src/backup/restore.rs b/crates/botloftd/src/backup/restore.rs new file mode 100644 index 00000000..2d8ae2bc --- /dev/null +++ b/crates/botloftd/src/backup/restore.rs @@ -0,0 +1,198 @@ +//! Importing a backup (spec 14.2). The file is opened and staged while +//! Botloft runs, and only swapped in on the next start, before the +//! database opens: nothing has the old data open then. What was there goes +//! aside, never deleted. + +use std::fs::{self, File}; +use std::io::{self, BufReader, BufWriter, Read}; +use std::path::{Path, PathBuf}; + +use botloft_core::protocol::BackupManifest; +use tracing::{info, warn}; +use zip::ZipArchive; + +use super::seal::{SealError, open}; +use super::{DATABASE_ENTRY, MANIFEST_ENTRY, WORKSPACES_ENTRY}; +use crate::paths::Paths; + +const STAGED: &str = "staged.zip"; +/// Written when the owner confirms: the next start applies the backup. +const READY: &str = "READY"; +/// What the archive unpacks into before the swap. +const UNPACKED: &str = "unpacked"; + +fn restore_dir(paths: &Paths) -> PathBuf { + paths.home.join("restore") +} + +fn other(err: impl std::fmt::Display) -> io::Error { + io::Error::other(err.to_string()) +} + +/// "0.10.0" as numbers, to compare. +fn version(text: &str) -> Vec { + text.split('.') + .map(|part| part.trim().parse().unwrap_or(0)) + .collect() +} + +/// Opens the backup with the passphrase, checks it, and keeps it staged. +/// Nothing changes until `confirm` and a restart. +pub fn stage(paths: &Paths, file: &Path, passphrase: &str) -> Result { + let dir = restore_dir(paths); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir)?; + let staged = dir.join(STAGED); + let opened = { + let mut sealed = BufReader::new(File::open(file)?); + let mut out = BufWriter::new(File::create(&staged)?); + open(&mut sealed, &mut out, passphrase) + }; + if let Err(err) = opened { + let _ = fs::remove_dir_all(&dir); + return Err(err); + } + let checked = check(&staged); + if checked.is_err() { + let _ = fs::remove_dir_all(&dir); + } + checked +} + +fn check(staged: &Path) -> Result { + let mut zip = ZipArchive::new(File::open(staged)?).map_err(|_| SealError::NotABackup)?; + let mut text = String::new(); + zip.by_name(MANIFEST_ENTRY) + .map_err(|_| SealError::NotABackup)? + .read_to_string(&mut text)?; + let manifest: BackupManifest = + serde_json::from_str(&text).map_err(|_| SealError::NotABackup)?; + if manifest.format != 1 || version(&manifest.version) > version(env!("CARGO_PKG_VERSION")) { + return Err(SealError::Newer); + } + zip.by_name(DATABASE_ENTRY) + .map_err(|_| SealError::NotABackup)?; + Ok(manifest) +} + +/// The owner said yes: the staged backup replaces everything on the next +/// start. +pub fn confirm(paths: &Paths) -> io::Result<()> { + let dir = restore_dir(paths); + if !dir.join(STAGED).is_file() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "no backup is ready to restore; open it again", + )); + } + fs::write(dir.join(READY), b"") +} + +/// The owner changed their mind: the staged backup goes. +pub fn cancel(paths: &Paths) { + let _ = fs::remove_dir_all(restore_dir(paths)); +} + +/// On start, before the database opens: swaps in a confirmed backup. The +/// database and the crews' folders that were there move aside, to +/// `\before-restore\