From 9c9bfa9085a6a8e2f74196c902a6cc074df58296 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:37:26 -0300 Subject: [PATCH 1/2] feat(backup): export everything to one file sealed with the owner's passphrase backup.export copies the database (VACUUM INTO) and every crew's folder in workspaces_root into a zip, leaving out what Botloft writes again on every start, and seals it: Argon2id turns the passphrase into a key and XChaCha20-Poly1305 in the STREAM construction encrypts it in 64 KiB chunks. Nothing is readable without the passphrase, which is never kept. The app will offer Save as for the file. Spec 14.2. --- Cargo.lock | 169 ++++++++++++- app/src/lib/fake.ts | 2 + app/src/lib/fakeBackup.ts | 39 +++ app/src/lib/protocol.gen.ts | 40 +++ crates/botloft-core/src/protocol/backup.rs | 47 ++++ crates/botloft-core/src/protocol/export.rs | 4 + .../src/protocol/export_backup.rs | 19 ++ crates/botloft-core/src/protocol/mod.rs | 3 + crates/botloft-store/src/plan.rs | 8 + crates/botloftd/Cargo.toml | 3 + crates/botloftd/src/backup/export.rs | 154 ++++++++++++ crates/botloftd/src/backup/mod.rs | 12 + crates/botloftd/src/backup/seal.rs | 234 ++++++++++++++++++ crates/botloftd/src/lib.rs | 1 + crates/botloftd/src/rpc/dispatch.rs | 7 +- crates/botloftd/src/service/backup.rs | 30 +++ crates/botloftd/src/service/mod.rs | 1 + crates/botloftd/tests/backup_export.rs | 96 +++++++ docs/spec.md | 10 + 19 files changed, 874 insertions(+), 5 deletions(-) create mode 100644 app/src/lib/fakeBackup.ts create mode 100644 crates/botloft-core/src/protocol/backup.rs create mode 100644 crates/botloft-core/src/protocol/export_backup.rs create mode 100644 crates/botloftd/src/backup/export.rs create mode 100644 crates/botloftd/src/backup/mod.rs create mode 100644 crates/botloftd/src/backup/seal.rs create mode 100644 crates/botloftd/src/service/backup.rs create mode 100644 crates/botloftd/tests/backup_export.rs diff --git a/Cargo.lock b/Cargo.lock index ac8ad802..4768e33b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -106,6 +116,18 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -345,6 +367,12 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.8.0" @@ -375,6 +403,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -471,11 +508,13 @@ name = "botloftd" version = "0.10.0" dependencies = [ "anyhow", + "argon2", "axum", "base64 0.23.1", "botloft-core", "botloft-store", "bytes", + "chacha20poly1305", "clap", "dirs 7.0.0", "embed-resource", @@ -499,6 +538,7 @@ dependencies = [ "tracing-subscriber", "windows", "windows-core", + "zip 8.6.0", ] [[package]] @@ -680,6 +720,17 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "chacha20" version = "0.10.2" @@ -691,6 +742,19 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.45" @@ -703,6 +767,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", + "zeroize", +] + [[package]] name = "clap" version = "4.6.7" @@ -883,6 +958,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -1061,6 +1137,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "crypto-common 0.1.7", + "subtle", ] [[package]] @@ -2151,6 +2228,15 @@ dependencies = [ "cfb 0.14.0", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.2" @@ -2907,6 +2993,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl-probe" version = "0.2.1" @@ -2997,6 +3089,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3132,6 +3235,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -3285,7 +3399,7 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ - "chacha20", + "chacha20 0.10.2", "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -3300,6 +3414,15 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "rand_core" version = "0.9.5" @@ -4541,7 +4664,7 @@ dependencies = [ "tokio", "url", "windows-sys 0.60.2", - "zip", + "zip 4.6.1", ] [[package]] @@ -5155,6 +5278,12 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typeid" version = "1.0.3" @@ -5200,6 +5329,16 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -6229,6 +6368,20 @@ dependencies = [ "memchr", ] +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap 2.14.2", + "memchr", + "typed-path", + "zopfli", +] + [[package]] name = "zlib-rs" version = "0.6.8" @@ -6241,6 +6394,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zvariant" version = "5.15.0" diff --git a/app/src/lib/fake.ts b/app/src/lib/fake.ts index 30aad84b..fb0171c1 100644 --- a/app/src/lib/fake.ts +++ b/app/src/lib/fake.ts @@ -4,6 +4,7 @@ import type { BotloftApi } from "./api"; import { FakeAllow } from "./fakeAllow"; +import { backupHandlers } from "./fakeBackup"; import { botHandlers } from "./fakeBots"; import { FakeBrowser } from "./fakeBrowser"; import { FakeChat } from "./fakeChat"; @@ -282,6 +283,7 @@ export class FakeBotloft implements BotloftApi { ...this.reactions.handlers(), ...this.lesson.handlers(), ...usageHandlers(this), + ...backupHandlers(this), ...searchHandlers(this), }; } diff --git a/app/src/lib/fakeBackup.ts b/app/src/lib/fakeBackup.ts new file mode 100644 index 00000000..81ec2ea3 --- /dev/null +++ b/app/src/lib/fakeBackup.ts @@ -0,0 +1,39 @@ +// 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. + +import type { FakeBotloft, Handlers } from "./fake"; +import { RpcErrorCode } from "./protocol.gen"; +import { RpcError } from "./rpc"; + +type BackupMethods = Extract; + +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", + ); + } + 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, + })), + }, + }; + }, + }; +} diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index 554e472d..4a584c48 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -1040,6 +1040,45 @@ export type CrewAccessListParams = { botId: BotId, }; export type CrewAccessIdParams = { accessId: CrewAccessId, }; +export type BackupCrew = { name: string, +/** + * The crew's bots, archived ones aside. + */ +bots: Array, +/** + * A work folder the owner chose outside Botloft's folders: not in the + * backup. + */ +workFolder: string | null, }; + +/** + * What a backup holds, as its manifest says. + */ +export type BackupManifest = { +/** + * The archive's layout version. + */ +format: number, +/** + * Unix time in milliseconds. + */ +createdAt: number, +/** + * The Botloft version that made it. + */ +version: string, crews: Array, }; + +export type BackupExportParams = { passphrase: string, }; + +/** + * A backup written and ready for the owner to save elsewhere. + */ +export type BackupExported = { path: string, +/** + * Bytes. + */ +size: number, manifest: BackupManifest, }; + export type ApprovalItem = { approvalId: ApprovalId, toolName: string, summary: string, /** * What the bot says the command is for, as in `ToolItem`. The bot @@ -1738,6 +1777,7 @@ export interface RpcMethods { "desktop.dismissAway": { params: undefined; result: null }; "crewAccess.list": { params: CrewAccessListParams; result: Array }; "crewAccess.revoke": { params: CrewAccessIdParams; result: Array }; + "backup.export": { params: BackupExportParams; result: BackupExported }; "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 new file mode 100644 index 00000000..11d6bd59 --- /dev/null +++ b/crates/botloft-core/src/protocol/backup.rs @@ -0,0 +1,47 @@ +//! Backups the owner exports and imports (spec 14.2). + +use serde::{Deserialize, Serialize}; + +/// What a backup holds, as its manifest says. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct BackupManifest { + /// The archive's layout version. + pub format: u32, + /// Unix time in milliseconds. + pub created_at: i64, + /// The Botloft version that made it. + pub version: String, + pub crews: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct BackupCrew { + pub name: String, + /// The crew's bots, archived ones aside. + pub bots: Vec, + /// A work folder the owner chose outside Botloft's folders: not in the + /// backup. + pub work_folder: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct BackupExportParams { + 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")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct BackupExported { + pub path: String, + /// Bytes. + pub size: u64, + pub manifest: BackupManifest, +} diff --git a/crates/botloft-core/src/protocol/export.rs b/crates/botloft-core/src/protocol/export.rs index 59a83cb3..5431b6c0 100644 --- a/crates/botloft-core/src/protocol/export.rs +++ b/crates/botloft-core/src/protocol/export.rs @@ -14,6 +14,8 @@ use crate::ids::{ MessageId, QuestionId, RoutineId, RoutineRunId, RuleId, TaskId, }; +#[path = "export_backup.rs"] +mod backup; #[path = "export_browser.rs"] mod browser; #[path = "export_crew_access.rs"] @@ -184,6 +186,7 @@ fn export_bindings() { rules::decls(&mut out); desktop::decls(&mut out); crew_access::decls(&mut out); + backup::decls(&mut out); out.decl::(); usage::decls(&mut out); questions::decls(&mut out); @@ -354,6 +357,7 @@ fn export_bindings() { rules::methods(&mut out); desktop::methods(&mut out); crew_access::methods(&mut out); + backup::methods(&mut out); routines::methods(&mut out); questions::methods(&mut out); reactions::methods(&mut out); diff --git a/crates/botloft-core/src/protocol/export_backup.rs b/crates/botloft-core/src/protocol/export_backup.rs new file mode 100644 index 00000000..e6103bc7 --- /dev/null +++ b/crates/botloft-core/src/protocol/export_backup.rs @@ -0,0 +1,19 @@ +//! The backup part of the TypeScript bindings (spec 14.2). + +use super::super::*; +use super::Out; + +pub(super) fn decls(out: &mut Out) { + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); +} + +pub(super) fn methods(out: &mut Out) { + out.method( + method::BACKUP_EXPORT, + &out.name::(), + &out.name::(), + ); +} diff --git a/crates/botloft-core/src/protocol/mod.rs b/crates/botloft-core/src/protocol/mod.rs index 90de3c07..05b0e970 100644 --- a/crates/botloft-core/src/protocol/mod.rs +++ b/crates/botloft-core/src/protocol/mod.rs @@ -49,6 +49,7 @@ macro_rules! text_enum { }; } +mod backup; mod browser; mod browser_input; mod chat; @@ -71,6 +72,7 @@ mod session; mod settings; mod usage; +pub use backup::*; pub use browser::*; pub use browser_input::*; pub use chat::*; @@ -126,6 +128,7 @@ pub mod method { pub const APPROVALS_ANSWER: &str = "approvals.answer"; pub const RULES_LIST: &str = "rules.list"; pub const RULES_DELETE: &str = "rules.delete"; + pub const BACKUP_EXPORT: &str = "backup.export"; 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/botloft-store/src/plan.rs b/crates/botloft-store/src/plan.rs index 0cf223cd..1c3d585e 100644 --- a/crates/botloft-store/src/plan.rs +++ b/crates/botloft-store/src/plan.rs @@ -22,6 +22,14 @@ pub struct PlanReading { } impl Store { + /// A consistent copy of the whole database at `path`, which must not + /// exist yet (spec 14.2). + pub fn snapshot_to(&self, path: &std::path::Path) -> Result<()> { + self.conn + .execute("VACUUM INTO ?1", [path.to_string_lossy().as_ref()])?; + Ok(()) + } + /// What one turn of `bot` cost at `at`. pub fn add_turn_cost(&self, bot: &BotId, at: i64, cost: f64) -> Result<()> { self.conn.execute( diff --git a/crates/botloftd/Cargo.toml b/crates/botloftd/Cargo.toml index 0cfbf430..8e39e0d0 100644 --- a/crates/botloftd/Cargo.toml +++ b/crates/botloftd/Cargo.toml @@ -9,11 +9,13 @@ authors.workspace = true [dependencies] anyhow = "1.0.104" +argon2 = "0.5" axum = { version = "0.8.9", features = ["ws"] } base64 = "0.23.1" botloft-core.workspace = true botloft-store.workspace = true bytes = "1.12.1" +chacha20poly1305 = { version = "0.10", features = ["stream"] } clap = { version = "4.6.7", features = ["derive"] } dirs = "7.0.0" futures-util = "0.3.34" @@ -32,6 +34,7 @@ toml_edit = "0.25.15" tracing = "0.1.44" tracing-appender = "0.2.5" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } +zip = { version = "8.6.0", default-features = false, features = ["deflate"] } [lints] workspace = true diff --git a/crates/botloftd/src/backup/export.rs b/crates/botloftd/src/backup/export.rs new file mode 100644 index 00000000..c2de46bd --- /dev/null +++ b/crates/botloftd/src/backup/export.rs @@ -0,0 +1,154 @@ +//! Exporting a backup (spec 14.2): a copy of the database and of every +//! crew's folder in `workspaces_root`, zipped and sealed with the owner's +//! passphrase. Work folders the owner chose elsewhere are theirs and stay +//! out; so do the files Botloft writes again on every start. + +use std::fs::{self, File}; +use std::io::{self, BufReader, BufWriter, Write}; +use std::path::{Component, Path, PathBuf}; + +use botloft_core::protocol::{BackupCrew, BackupExported, BackupManifest}; +use zip::ZipWriter; +use zip::write::SimpleFileOptions; + +use super::seal::{PASSPHRASE_MIN, SealError, seal}; +use super::{BACKUP_EXTENSION, DATABASE_ENTRY, MANIFEST_ENTRY, WORKSPACES_ENTRY}; +use crate::state::Daemon; + +/// What a bot's folder holds that Botloft writes again on every start +/// (spec 7.5): never worth carrying to another computer. +fn regenerated(relative: &Path) -> bool { + let parts: Vec<_> = relative.components().collect(); + let named = |part: &Component<'_>, name: &str| part.as_os_str().eq_ignore_ascii_case(name); + parts.iter().any(|part| named(part, ".botloft")) + || parts + .windows(2) + .any(|pair| named(&pair[0], ".claude") && named(&pair[1], "settings.json")) +} + +/// Writes the sealed backup in `\exports` and says what it holds. +/// The app then lets the owner save it where they want. +pub fn export(daemon: &Daemon, passphrase: &str) -> Result { + if passphrase.chars().count() < PASSPHRASE_MIN { + return Err(SealError::ShortPassphrase); + } + let dir = daemon.paths.home.join("exports"); + // Only the newest export is kept: the owner saved the earlier ones. + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir)?; + let snapshot = dir.join("snapshot.db"); + let (crews, manifest) = { + let store = daemon.store(); + store + .snapshot_to(&snapshot) + .map_err(|err| io::Error::other(err.to_string()))?; + let crews = store + .crews(true) + .map_err(|err| io::Error::other(err.to_string()))?; + let mut listed = Vec::new(); + for crew in &crews { + let bots = store + .bots(Some(&crew.id), false) + .map_err(|err| io::Error::other(err.to_string()))?; + listed.push(BackupCrew { + name: crew.name.clone(), + bots: bots.into_iter().map(|bot| bot.name).collect(), + work_folder: crew.work_folder_chosen.then(|| crew.work_folder.clone()), + }); + } + let manifest = BackupManifest { + format: 1, + created_at: daemon.clock.now_ms(), + version: env!("CARGO_PKG_VERSION").to_owned(), + crews: listed, + }; + (crews, manifest) + }; + + let archive = dir.join("backup.zip"); + { + let mut zip = ZipWriter::new(BufWriter::new(File::create(&archive)?)); + let options = SimpleFileOptions::default(); + zip.start_file(MANIFEST_ENTRY, options) + .map_err(io::Error::other)?; + zip.write_all(&serde_json::to_vec_pretty(&manifest).map_err(io::Error::other)?)?; + zip.start_file(DATABASE_ENTRY, options) + .map_err(io::Error::other)?; + io::copy(&mut BufReader::new(File::open(&snapshot)?), &mut zip)?; + for crew in &crews { + let folder = daemon.paths.crew_dir(&crew.slug); + add_folder(&mut zip, &folder, &PathBuf::from(&crew.slug), options)?; + } + zip.finish().map_err(io::Error::other)?.flush()?; + } + fs::remove_file(&snapshot)?; + + let name = format!("botloft-{}.{BACKUP_EXTENSION}", stamp(manifest.created_at)); + let sealed = dir.join(name); + { + let mut plain = BufReader::new(File::open(&archive)?); + let mut out = BufWriter::new(File::create(&sealed)?); + seal(&mut plain, &mut out, passphrase)?; + } + fs::remove_file(&archive)?; + Ok(BackupExported { + path: sealed.display().to_string(), + size: fs::metadata(&sealed)?.len(), + manifest, + }) +} + +/// Every file under `folder`, as `workspaces//...`. +fn add_folder( + zip: &mut ZipWriter>, + folder: &Path, + inside: &Path, + options: SimpleFileOptions, +) -> io::Result<()> { + let Ok(entries) = fs::read_dir(folder) else { + return Ok(()); + }; + for entry in entries.flatten() { + let kind = entry.file_type()?; + let relative = inside.join(entry.file_name()); + if kind.is_symlink() || regenerated(&relative) { + continue; + } + if kind.is_dir() { + add_folder(zip, &entry.path(), &relative, options)?; + } else if kind.is_file() { + let name = Path::new(WORKSPACES_ENTRY).join(&relative); + let name = name.to_string_lossy().replace('\\', "/"); + zip.start_file(name, options).map_err(io::Error::other)?; + io::copy(&mut BufReader::new(File::open(entry.path())?), zip)?; + } + } + Ok(()) +} + +/// `2026-10-05-1430`, in local time, for the file name. +fn stamp(ms: i64) -> String { + jiff::Timestamp::from_millisecond(ms) + .map(|at| { + at.to_zoned(jiff::tz::TimeZone::system()) + .strftime("%Y-%m-%d-%H%M") + .to_string() + }) + .unwrap_or_else(|_| "backup".to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn what_botloft_writes_again_stays_out() { + assert!(regenerated(Path::new("site/scout/.botloft/mcp.json"))); + assert!(regenerated(Path::new("site/scout/.claude/settings.json"))); + assert!(!regenerated(Path::new( + "site/scout/.claude/rules/botloft.md" + ))); + assert!(!regenerated(Path::new("site/scout/CLAUDE.md"))); + assert!(!regenerated(Path::new("site/shared/report.pdf"))); + } +} diff --git a/crates/botloftd/src/backup/mod.rs b/crates/botloftd/src/backup/mod.rs new file mode 100644 index 00000000..22f43e56 --- /dev/null +++ b/crates/botloftd/src/backup/mod.rs @@ -0,0 +1,12 @@ +//! Backups the owner keeps (spec 14.2): everything Botloft knows and every +//! crew's folder, in one file sealed with a passphrase only the owner has. + +pub mod export; +pub mod seal; + +/// The extension of a backup file. +pub const BACKUP_EXTENSION: &str = "botloft"; +/// Entries of the archive inside. +pub(crate) const MANIFEST_ENTRY: &str = "manifest.json"; +pub(crate) const DATABASE_ENTRY: &str = "botloft.db"; +pub(crate) const WORKSPACES_ENTRY: &str = "workspaces"; diff --git a/crates/botloftd/src/backup/seal.rs b/crates/botloftd/src/backup/seal.rs new file mode 100644 index 00000000..79203f66 --- /dev/null +++ b/crates/botloftd/src/backup/seal.rs @@ -0,0 +1,234 @@ +//! The sealed form of a backup (spec 14.2): what is written to disk is +//! never readable without the owner's passphrase. +//! +//! Layout: `MAGIC`, a version byte, the Argon2id salt and costs, the stream +//! nonce, then the archive in chunks of `CHUNK` bytes, each encrypted with +//! XChaCha20-Poly1305 in the STREAM construction (big-endian 32-bit +//! counter, last-chunk flag), so a cut or reordered file fails to open. + +use std::io::{self, Read, Write}; + +use argon2::{Algorithm, Argon2, Params, Version}; +use chacha20poly1305::aead::stream::{DecryptorBE32, EncryptorBE32}; +use chacha20poly1305::{KeyInit, XChaCha20Poly1305}; + +const MAGIC: &[u8; 10] = b"BOTLOFTBAK"; +const VERSION: u8 = 1; +const SALT_LEN: usize = 16; +/// XChaCha20's 24-byte nonce less STREAM's 5 bytes of counter and flag. +const NONCE_LEN: usize = 19; +const TAG_LEN: usize = 16; +/// Plaintext per chunk. +const CHUNK: usize = 64 * 1024; +/// Argon2id costs: 64 MiB, three passes, one lane. +const MEMORY_KIB: u32 = if cfg!(test) { 1024 } else { 64 * 1024 }; +const PASSES: u32 = 3; +const LANES: u32 = 1; +/// The shortest passphrase accepted, in characters. +pub const PASSPHRASE_MIN: usize = 8; + +#[derive(Debug, thiserror::Error)] +pub enum SealError { + #[error("this is not a Botloft backup")] + NotABackup, + #[error("this backup was made by a newer Botloft; update Botloft to open it")] + Newer, + #[error("the passphrase is wrong, or the file is damaged")] + WrongPassphrase, + #[error("the passphrase must have at least {PASSPHRASE_MIN} characters")] + ShortPassphrase, + #[error(transparent)] + Io(#[from] io::Error), +} + +struct Header { + salt: [u8; SALT_LEN], + memory: u32, + passes: u32, + lanes: u32, + nonce: [u8; NONCE_LEN], +} + +fn key(passphrase: &str, header: &Header) -> Result<[u8; 32], SealError> { + let params = Params::new(header.memory, header.passes, header.lanes, Some(32)) + .map_err(|_| SealError::NotABackup)?; + let mut key = [0u8; 32]; + Argon2::new(Algorithm::Argon2id, Version::V0x13, params) + .hash_password_into(passphrase.as_bytes(), &header.salt, &mut key) + .map_err(|_| SealError::NotABackup)?; + Ok(key) +} + +fn random() -> io::Result<[u8; N]> { + let mut bytes = [0u8; N]; + getrandom::fill(&mut bytes).map_err(|err| io::Error::other(err.to_string()))?; + Ok(bytes) +} + +/// Reads `plain` to its end and writes it sealed with `passphrase`. +pub fn seal( + plain: &mut impl Read, + out: &mut impl Write, + passphrase: &str, +) -> Result<(), SealError> { + if passphrase.chars().count() < PASSPHRASE_MIN { + return Err(SealError::ShortPassphrase); + } + let header = Header { + salt: random()?, + memory: MEMORY_KIB, + passes: PASSES, + lanes: LANES, + nonce: random()?, + }; + out.write_all(MAGIC)?; + out.write_all(&[VERSION])?; + out.write_all(&header.salt)?; + for cost in [header.memory, header.passes, header.lanes] { + out.write_all(&cost.to_le_bytes())?; + } + out.write_all(&header.nonce)?; + let key = key(passphrase, &header)?; + let cipher = XChaCha20Poly1305::new(key.as_ref().into()); + let mut stream = EncryptorBE32::from_aead(cipher, header.nonce.as_ref().into()); + let mut chunk = read_chunk(plain, CHUNK)?; + loop { + let next = read_chunk(plain, CHUNK)?; + if next.is_empty() { + let last = stream + .encrypt_last(chunk.as_slice()) + .map_err(|_| io::Error::other("could not encrypt"))?; + out.write_all(&last)?; + return Ok(out.flush()?); + } + let sealed = stream + .encrypt_next(chunk.as_slice()) + .map_err(|_| io::Error::other("could not encrypt"))?; + out.write_all(&sealed)?; + chunk = next; + } +} + +/// Reads a sealed backup and writes what it holds, or fails without +/// writing a chunk that does not open. +pub fn open( + sealed: &mut impl Read, + out: &mut impl Write, + passphrase: &str, +) -> Result<(), SealError> { + let mut magic = [0u8; MAGIC.len()]; + sealed + .read_exact(&mut magic) + .map_err(|_| SealError::NotABackup)?; + if &magic != MAGIC { + return Err(SealError::NotABackup); + } + let mut version = [0u8; 1]; + sealed.read_exact(&mut version)?; + if version[0] != VERSION { + return Err(SealError::Newer); + } + let mut salt = [0u8; SALT_LEN]; + sealed.read_exact(&mut salt)?; + let mut costs = [0u32; 3]; + for cost in &mut costs { + let mut bytes = [0u8; 4]; + sealed.read_exact(&mut bytes)?; + *cost = u32::from_le_bytes(bytes); + } + let mut nonce = [0u8; NONCE_LEN]; + sealed.read_exact(&mut nonce)?; + let header = Header { + salt, + memory: costs[0], + passes: costs[1], + lanes: costs[2], + nonce, + }; + let key = key(passphrase, &header)?; + let cipher = XChaCha20Poly1305::new(key.as_ref().into()); + let mut stream = DecryptorBE32::from_aead(cipher, header.nonce.as_ref().into()); + let mut chunk = read_chunk(sealed, CHUNK + TAG_LEN)?; + loop { + let next = read_chunk(sealed, CHUNK + TAG_LEN)?; + if next.is_empty() { + let last = stream + .decrypt_last(chunk.as_slice()) + .map_err(|_| SealError::WrongPassphrase)?; + out.write_all(&last)?; + return Ok(out.flush()?); + } + let plain = stream + .decrypt_next(chunk.as_slice()) + .map_err(|_| SealError::WrongPassphrase)?; + out.write_all(&plain)?; + chunk = next; + } +} + +/// Up to `size` bytes; fewer only at the end. +fn read_chunk(from: &mut impl Read, size: usize) -> io::Result> { + let mut chunk = vec![0u8; size]; + let mut filled = 0; + while filled < size { + match from.read(&mut chunk[filled..])? { + 0 => break, + read => filled += read, + } + } + chunk.truncate(filled); + Ok(chunk) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn round_trip(size: usize) { + let plain: Vec = (0..size).map(|i| (i % 251) as u8).collect(); + let mut sealed = Vec::new(); + seal(&mut plain.as_slice(), &mut sealed, "correct horse").expect("seal"); + assert!( + !sealed + .windows(16) + .any(|w| plain.len() >= 16 && w == &plain[..16]) + ); + let mut opened = Vec::new(); + open(&mut sealed.as_slice(), &mut opened, "correct horse").expect("open"); + assert_eq!(opened, plain); + } + + #[test] + fn what_is_sealed_opens_with_the_passphrase_at_any_size() { + for size in [0, 1, CHUNK - 1, CHUNK, CHUNK + 1, 3 * CHUNK + 7] { + round_trip(size); + } + } + + #[test] + fn a_wrong_passphrase_a_cut_file_and_another_file_do_not_open() { + let plain = vec![7u8; 2 * CHUNK + 10]; + let mut sealed = Vec::new(); + seal(&mut plain.as_slice(), &mut sealed, "correct horse").expect("seal"); + let mut out = Vec::new(); + assert!(matches!( + open(&mut sealed.as_slice(), &mut out, "wrong horse"), + Err(SealError::WrongPassphrase) + )); + // Without its last chunk, the file stops at a chunk not marked last. + let cut = &sealed[..sealed.len() - 20]; + assert!(open(&mut &cut[..], &mut Vec::new(), "correct horse").is_err()); + assert!(matches!( + open( + &mut &b"PK\x03\x04 a zip"[..], + &mut Vec::new(), + "correct horse" + ), + Err(SealError::NotABackup) + )); + assert!(matches!( + seal(&mut &b"x"[..], &mut Vec::new(), "short"), + Err(SealError::ShortPassphrase) + )); + } +} diff --git a/crates/botloftd/src/lib.rs b/crates/botloftd/src/lib.rs index 3031263a..eea00614 100644 --- a/crates/botloftd/src/lib.rs +++ b/crates/botloftd/src/lib.rs @@ -4,6 +4,7 @@ pub mod approvals; pub mod autostart; +pub mod backup; pub mod browser; pub mod chat; pub mod clock; diff --git a/crates/botloftd/src/rpc/dispatch.rs b/crates/botloftd/src/rpc/dispatch.rs index 73758fa7..1282f909 100644 --- a/crates/botloftd/src/rpc/dispatch.rs +++ b/crates/botloftd/src/rpc/dispatch.rs @@ -8,9 +8,9 @@ use serde_json::Value; use super::jsonrpc::{RpcError, empty_params}; use crate::approvals; use crate::service::{ - self, ApiResult, archive, attachments, bots, chat, crew_access, crews, delete, deliveries, - desktop, files, lead, messages, models, modes, questions, reactions, routines, rules, screens, - settings, tasks, usage, + self, ApiResult, archive, attachments, backup, bots, chat, crew_access, crews, delete, + deliveries, desktop, files, lead, messages, models, modes, questions, reactions, routines, + rules, screens, settings, tasks, usage, }; use crate::state::Daemon; @@ -54,6 +54,7 @@ pub fn dispatch(daemon: &Daemon, name: &str, params: Option) -> Result reply(approvals::answer(daemon, parse(params)?)), method::RULES_LIST => reply(rules::list(daemon, parse(params)?)), method::RULES_DELETE => reply(rules::delete(daemon, parse(params)?)), + method::BACKUP_EXPORT => reply(backup::export(daemon, parse(params)?)), method::CREW_ACCESS_LIST => reply(crew_access::list(daemon, parse(params)?)), method::CREW_ACCESS_REVOKE => reply(crew_access::revoke(daemon, parse(params)?)), method::DESKTOP_GRANTS => reply(desktop::grants(daemon, parse(params)?)), diff --git a/crates/botloftd/src/service/backup.rs b/crates/botloftd/src/service/backup.rs new file mode 100644 index 00000000..2d64307f --- /dev/null +++ b/crates/botloftd/src/service/backup.rs @@ -0,0 +1,30 @@ +//! `backup.*` (spec 14.2). + +use botloft_core::protocol::{BackupExportParams, BackupExported}; +use tracing::info; + +use super::{ApiError, ApiResult}; +use crate::backup::{export, seal::SealError}; +use crate::state::Daemon; + +/// A problem the owner can fix, with a `reason` the app words (spec 20.8). +pub(crate) fn api(err: SealError) -> ApiError { + let reason = match &err { + SealError::Io(_) => return ApiError::Workspace(std::io::Error::other(err.to_string())), + SealError::NotABackup => "not_a_backup", + SealError::Newer => "newer_backup", + SealError::WrongPassphrase => "wrong_passphrase", + SealError::ShortPassphrase => "short_passphrase", + }; + ApiError::Rule { + reason, + message: err.to_string(), + } +} + +/// `backup.export`: seals a backup and says where it is. +pub fn export(daemon: &Daemon, params: BackupExportParams) -> ApiResult { + let exported = export::export(daemon, ¶ms.passphrase).map_err(api)?; + info!(bytes = exported.size, "backup exported"); + Ok(exported) +} diff --git a/crates/botloftd/src/service/mod.rs b/crates/botloftd/src/service/mod.rs index ef08e1b7..823f3c0c 100644 --- a/crates/botloftd/src/service/mod.rs +++ b/crates/botloftd/src/service/mod.rs @@ -3,6 +3,7 @@ pub mod archive; pub mod attachments; +pub mod backup; pub mod bots; pub mod chat; pub mod crew_access; diff --git a/crates/botloftd/tests/backup_export.rs b/crates/botloftd/tests/backup_export.rs new file mode 100644 index 00000000..cb1746bc --- /dev/null +++ b/crates/botloftd/tests/backup_export.rs @@ -0,0 +1,96 @@ +//! Exporting a backup (spec 14.2): one file, sealed with the owner's +//! passphrase, with the database and every crew's folder, and without what +//! Botloft writes again on every start. + +mod common; + +use std::fs::{self, File}; +use std::io::Read; + +use botloftd::backup::seal; +use common::TestDaemon; +use serde_json::{Value, json}; + +#[tokio::test] +async fn a_backup_holds_the_database_and_the_crews_folders_sealed() { + let t = TestDaemon::start().await; + let mut app = t.session().await; + let crew = app + .call("crews.create", json!({ "name": "Ops" })) + .await + .expect("crew"); + let bot = json!({ "crewId": crew["id"], "name": "Scout", "role": "Finds", "instructions": "" }); + app.call("bots.create", bot).await.expect("bot"); + let memory = t.paths.bot_workspace("ops", "scout").join("CLAUDE.md"); + fs::write(&memory, "Remember the client likes short reports.").expect("memory"); + fs::write(t.paths.shared_dir("ops").join("report.md"), "# Report").expect("report"); + + let short = app + .call("backup.export", json!({ "passphrase": "short" })) + .await + .expect_err("short"); + assert!(short.message.contains("at least 8"), "{}", short.message); + + let exported = app + .call("backup.export", json!({ "passphrase": "correct horse" })) + .await + .expect("export"); + assert_eq!(exported["manifest"]["crews"][0]["name"], "Ops"); + assert_eq!(exported["manifest"]["crews"][0]["bots"], json!(["Scout"])); + let path = exported["path"].as_str().expect("path"); + assert!(path.ends_with(".botloft"), "{path}"); + let sealed = fs::read(path).expect("file"); + assert_eq!(exported["size"], sealed.len()); + assert!( + !String::from_utf8_lossy(&sealed).contains("short reports"), + "nothing readable without the passphrase" + ); + + let mut zip_bytes = Vec::new(); + seal::open( + &mut File::open(path).expect("open"), + &mut zip_bytes, + "correct horse", + ) + .expect("unseal"); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).expect("zip"); + let names: Vec = zip.file_names().map(str::to_owned).collect(); + for wanted in [ + "manifest.json", + "botloft.db", + "workspaces/ops/scout/CLAUDE.md", + "workspaces/ops/shared/report.md", + "workspaces/ops/scout/.claude/rules/botloft.md", + ] { + assert!( + names.iter().any(|name| name == wanted), + "{wanted} in {names:?}" + ); + } + assert!( + !names + .iter() + .any(|name| name.contains(".botloft/") || name.ends_with(".claude/settings.json")), + "{names:?}" + ); + let mut text = String::new(); + zip.by_name("workspaces/ops/scout/CLAUDE.md") + .expect("memory") + .read_to_string(&mut text) + .expect("read"); + assert_eq!(text, "Remember the client likes short reports."); + let mut manifest = String::new(); + zip.by_name("manifest.json") + .expect("manifest") + .read_to_string(&mut manifest) + .expect("read"); + let manifest: Value = serde_json::from_str(&manifest).expect("json"); + assert_eq!(manifest["format"], 1); + + let wrong = seal::open( + &mut File::open(path).expect("open"), + &mut Vec::new(), + "wrong horse", + ); + assert!(wrong.is_err()); +} diff --git a/docs/spec.md b/docs/spec.md index 984687c6..cfe3ec76 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -809,6 +809,16 @@ Em fatias (18, item 8). O que já vale fora do Windows: | Não suspender | Como no Windows, enquanto houver bot `busy` e `keep_awake = true`, mas com um processo auxiliar que segura o pedido enquanto vive: `caffeinate -i -w ` no macOS; no Linux, `systemd-inhibit --what=idle --mode=block` com um `sh` que espera o daemon sumir (`kill -0` a cada 5 s). Os dois impedem só a suspensão por inatividade, não a pedida pelo dono (tampa, Suspender). O auxiliar sai quando o daemon morre, então uma queda não deixa o computador acordado. Um auxiliar que sai em 200 ms (sem logind, num contêiner) vira erro no log, e o daemon segue | | Lixeira | Ainda não: avisa que só existe no Windows | +### 14.2 Cópia de segurança + +Formatar o computador ou perder a pasta do Botloft não pode levar as equipes junto. O dono exporta tudo para um arquivo `.botloft`, protegido com uma senha dele, e importa esse arquivo em outro computador ou depois de reinstalar. É o mesmo arquivo que um backup na nuvem vai enviar: o servidor guardaria só bytes que não consegue ler. + +- **O que vai:** uma cópia consistente do banco (`VACUUM INTO`): equipes, bots, conversas, rotinas, regras de "permitir sempre", acessos entre equipes e o uso. E a pasta de cada equipe em `workspaces_root`, arquivadas inclusive: a memória (`CLAUDE.md`) e as regras de cada bot, os anexos e a `shared`. Vai também um `manifest.json` com o formato (1), a data, a versão do Botloft e as equipes, com os nomes dos bots. +- **O que fica:** o que o Botloft escreve de novo a cada início (`.botloft\` e `.claude\settings.json` de cada bot, 7.5); os segredos, os logs e os perfis dos navegadores dos bots, que estão na pasta de dados e não em `workspaces_root`; e as pastas de trabalho que o dono escolheu fora do Botloft (5), que são dele. O manifesto lista essas pastas, para a importação avisar. O login do Claude também é de cada computador e se faz de novo. +- **Lacre:** o arquivo começa com `BOTLOFTBAK`, um byte de versão (1), o sal do Argon2id (16 bytes), os custos (64 MiB, 3 passadas, 1 faixa, em `u32` little-endian) e o nonce (19 bytes). Depois vem o zip, em blocos de 64 KiB cifrados com XChaCha20-Poly1305 na construção STREAM (contador big-endian de 32 bits e marca do último bloco), com a chave de 32 bytes que o Argon2id tira da senha. Senha errada, arquivo cortado ou blocos trocados não abrem, e nada de um bloco que não abre é escrito. A senha tem ao menos 8 caracteres e nunca é guardada nem vai para o log: quem a perde perde a cópia. +- **Exportar:** `backup.export {passphrase}` fecha a cópia em `\exports\botloft-AAAA-MM-DD-HHMM.botloft` (só a mais nova fica ali) e devolve `{path, size, manifest}`. O app então oferece "Salvar como…" (`save_file_as`, 15.2) para o dono guardar onde quiser. Os erros que o dono resolve vêm com `reason` (20.8): `short_passphrase`, `wrong_passphrase`, `not_a_backup` e `newer_backup`. +- **Importar** e a tela no app vêm nas próximas partes. + ## 15. App desktop ### 15.1 Estrutura 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 2/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\