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