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..a415784f --- /dev/null +++ b/app/src/lib/fakeBackup.ts @@ -0,0 +1,54 @@ +// The fake daemon's backups (spec 14.2): what an export would hold, from +// 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 { 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 refused("short_passphrase", "the passphrase must have at least 8 characters"); + } + return { + path: "C:\\Users\\owner\\AppData\\Local\\Botloft\\exports\\botloft-2026-10-05-1430.botloft", + size: 1_234_567, + 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 554e472d..793b7949 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -1040,6 +1040,50 @@ 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, }; + +/** + * `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 @@ -1738,6 +1782,10 @@ 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 }; + "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 new file mode 100644 index 00000000..a2d685ce --- /dev/null +++ b/crates/botloft-core/src/protocol/backup.rs @@ -0,0 +1,56 @@ +//! 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, +} + +/// `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")] +#[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..009520b2 --- /dev/null +++ b/crates/botloft-core/src/protocol/export_backup.rs @@ -0,0 +1,27 @@ +//! 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::(); + out.decl::(); +} + +pub(super) fn methods(out: &mut Out) { + out.method( + method::BACKUP_EXPORT, + &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 90de3c07..becfa437 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,10 @@ 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 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/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..0818da0b --- /dev/null +++ b/crates/botloftd/src/backup/mod.rs @@ -0,0 +1,13 @@ +//! 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 restore; +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/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\