Skip to content

Commit 652e540

Browse files
authored
Merge pull request #200 from httpsphl/feat/backup-import
feat(backup): import a backup, swapped in on the next start
2 parents 53611db + 3ac9a9d commit 652e540

12 files changed

Lines changed: 397 additions & 23 deletions

File tree

‎app/src/lib/fakeBackup.ts‎

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,54 @@
11
// The fake daemon's backups (spec 14.2): what an export would hold, from
2-
// the fake crews, at a made-up path. Nothing is sealed or written.
2+
// the fake crews, at a made-up path, and a backup that opens only with
3+
// `FAKE_PASSPHRASE`. Nothing is sealed, written or restored.
34

45
import type { FakeBotloft, Handlers } from "./fake";
5-
import { RpcErrorCode } from "./protocol.gen";
6+
import { type BackupManifest, RpcErrorCode } from "./protocol.gen";
67
import { RpcError } from "./rpc";
78

89
type BackupMethods = Extract<keyof Handlers, `backup.${string}`>;
910

11+
/** The passphrase the fake's backups open with. */
12+
export const FAKE_PASSPHRASE = "correct horse";
13+
14+
function refused(reason: string, message: string): RpcError {
15+
return new RpcError(RpcErrorCode.validation, message, reason);
16+
}
17+
18+
function manifestOf(fake: FakeBotloft): BackupManifest {
19+
return {
20+
format: 1,
21+
createdAt: fake.now,
22+
version: "0.10.0",
23+
crews: [...fake.crews.values()].map((crew) => ({
24+
name: crew.name,
25+
bots: [...fake.bots.values()]
26+
.filter((bot) => bot.crewId === crew.id && bot.archivedAt === null)
27+
.map((bot) => bot.name),
28+
workFolder: crew.workFolderChosen ? crew.workFolder : null,
29+
})),
30+
};
31+
}
32+
1033
export function backupHandlers(fake: FakeBotloft): Pick<Handlers, BackupMethods> {
1134
return {
1235
"backup.export": ({ passphrase }) => {
1336
if ([...passphrase].length < 8) {
14-
throw new RpcError(
15-
RpcErrorCode.validation,
16-
"the passphrase must have at least 8 characters",
17-
"short_passphrase",
18-
);
37+
throw refused("short_passphrase", "the passphrase must have at least 8 characters");
1938
}
20-
const crews = [...fake.crews.values()];
2139
return {
2240
path: "C:\\Users\\owner\\AppData\\Local\\Botloft\\exports\\botloft-2026-10-05-1430.botloft",
2341
size: 1_234_567,
24-
manifest: {
25-
format: 1,
26-
createdAt: fake.now,
27-
version: "0.10.0",
28-
crews: crews.map((crew) => ({
29-
name: crew.name,
30-
bots: [...fake.bots.values()]
31-
.filter((bot) => bot.crewId === crew.id && bot.archivedAt === null)
32-
.map((bot) => bot.name),
33-
workFolder: crew.workFolderChosen ? crew.workFolder : null,
34-
})),
35-
},
42+
manifest: manifestOf(fake),
3643
};
3744
},
45+
"backup.stage": ({ passphrase }) => {
46+
if (passphrase !== FAKE_PASSPHRASE) {
47+
throw refused("wrong_passphrase", "the passphrase is wrong, or the file is damaged");
48+
}
49+
return manifestOf(fake);
50+
},
51+
"backup.confirm": () => null,
52+
"backup.cancel": () => null,
3853
};
3954
}

‎app/src/lib/protocol.gen.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1079,6 +1079,11 @@ export type BackupExported = { path: string,
10791079
*/
10801080
size: number, manifest: BackupManifest, };
10811081

1082+
/**
1083+
* `backup.stage`: a backup file the owner picked, and its passphrase.
1084+
*/
1085+
export type BackupStageParams = { path: string, passphrase: string, };
1086+
10821087
export type ApprovalItem = { approvalId: ApprovalId, toolName: string, summary: string,
10831088
/**
10841089
* What the bot says the command is for, as in `ToolItem`. The bot
@@ -1778,6 +1783,9 @@ export interface RpcMethods {
17781783
"crewAccess.list": { params: CrewAccessListParams; result: Array<CrewAccess> };
17791784
"crewAccess.revoke": { params: CrewAccessIdParams; result: Array<CrewAccess> };
17801785
"backup.export": { params: BackupExportParams; result: BackupExported };
1786+
"backup.stage": { params: BackupStageParams; result: BackupManifest };
1787+
"backup.confirm": { params: undefined; result: null };
1788+
"backup.cancel": { params: undefined; result: null };
17811789
"routines.list": { params: RoutinesListParams; result: Array<Routine> };
17821790
"routines.create": { params: RoutinesCreateParams; result: Routine };
17831791
"routines.update": { params: RoutinesUpdateParams; result: Routine };

‎crates/botloft-core/src/protocol/backup.rs‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,15 @@ pub struct BackupExportParams {
3535
pub passphrase: String,
3636
}
3737

38+
/// `backup.stage`: a backup file the owner picked, and its passphrase.
39+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40+
#[serde(rename_all = "camelCase")]
41+
#[cfg_attr(test, derive(ts_rs::TS))]
42+
pub struct BackupStageParams {
43+
pub path: String,
44+
pub passphrase: String,
45+
}
46+
3847
/// A backup written and ready for the owner to save elsewhere.
3948
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
4049
#[serde(rename_all = "camelCase")]

‎crates/botloft-core/src/protocol/export_backup.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ pub(super) fn decls(out: &mut Out) {
88
out.decl::<BackupManifest>();
99
out.decl::<BackupExportParams>();
1010
out.decl::<BackupExported>();
11+
out.decl::<BackupStageParams>();
1112
}
1213

1314
pub(super) fn methods(out: &mut Out) {
@@ -16,4 +17,11 @@ pub(super) fn methods(out: &mut Out) {
1617
&out.name::<BackupExportParams>(),
1718
&out.name::<BackupExported>(),
1819
);
20+
out.method(
21+
method::BACKUP_STAGE,
22+
&out.name::<BackupStageParams>(),
23+
&out.name::<BackupManifest>(),
24+
);
25+
out.method(method::BACKUP_CONFIRM, "undefined", "null");
26+
out.method(method::BACKUP_CANCEL, "undefined", "null");
1927
}

‎crates/botloft-core/src/protocol/mod.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,9 @@ pub mod method {
129129
pub const RULES_LIST: &str = "rules.list";
130130
pub const RULES_DELETE: &str = "rules.delete";
131131
pub const BACKUP_EXPORT: &str = "backup.export";
132+
pub const BACKUP_STAGE: &str = "backup.stage";
133+
pub const BACKUP_CONFIRM: &str = "backup.confirm";
134+
pub const BACKUP_CANCEL: &str = "backup.cancel";
132135
pub const CREW_ACCESS_LIST: &str = "crewAccess.list";
133136
pub const CREW_ACCESS_REVOKE: &str = "crewAccess.revoke";
134137
pub const DESKTOP_GRANTS: &str = "desktop.grants";

‎crates/botloftd/src/backup/mod.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
//! crew's folder, in one file sealed with a passphrase only the owner has.
33
44
pub mod export;
5+
pub mod restore;
56
pub mod seal;
67

78
/// The extension of a backup file.
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
//! Importing a backup (spec 14.2). The file is opened and staged while
2+
//! Botloft runs, and only swapped in on the next start, before the
3+
//! database opens: nothing has the old data open then. What was there goes
4+
//! aside, never deleted.
5+
6+
use std::fs::{self, File};
7+
use std::io::{self, BufReader, BufWriter, Read};
8+
use std::path::{Path, PathBuf};
9+
10+
use botloft_core::protocol::BackupManifest;
11+
use tracing::{info, warn};
12+
use zip::ZipArchive;
13+
14+
use super::seal::{SealError, open};
15+
use super::{DATABASE_ENTRY, MANIFEST_ENTRY, WORKSPACES_ENTRY};
16+
use crate::paths::Paths;
17+
18+
const STAGED: &str = "staged.zip";
19+
/// Written when the owner confirms: the next start applies the backup.
20+
const READY: &str = "READY";
21+
/// What the archive unpacks into before the swap.
22+
const UNPACKED: &str = "unpacked";
23+
24+
fn restore_dir(paths: &Paths) -> PathBuf {
25+
paths.home.join("restore")
26+
}
27+
28+
fn other(err: impl std::fmt::Display) -> io::Error {
29+
io::Error::other(err.to_string())
30+
}
31+
32+
/// "0.10.0" as numbers, to compare.
33+
fn version(text: &str) -> Vec<u64> {
34+
text.split('.')
35+
.map(|part| part.trim().parse().unwrap_or(0))
36+
.collect()
37+
}
38+
39+
/// Opens the backup with the passphrase, checks it, and keeps it staged.
40+
/// Nothing changes until `confirm` and a restart.
41+
pub fn stage(paths: &Paths, file: &Path, passphrase: &str) -> Result<BackupManifest, SealError> {
42+
let dir = restore_dir(paths);
43+
let _ = fs::remove_dir_all(&dir);
44+
fs::create_dir_all(&dir)?;
45+
let staged = dir.join(STAGED);
46+
let opened = {
47+
let mut sealed = BufReader::new(File::open(file)?);
48+
let mut out = BufWriter::new(File::create(&staged)?);
49+
open(&mut sealed, &mut out, passphrase)
50+
};
51+
if let Err(err) = opened {
52+
let _ = fs::remove_dir_all(&dir);
53+
return Err(err);
54+
}
55+
let checked = check(&staged);
56+
if checked.is_err() {
57+
let _ = fs::remove_dir_all(&dir);
58+
}
59+
checked
60+
}
61+
62+
fn check(staged: &Path) -> Result<BackupManifest, SealError> {
63+
let mut zip = ZipArchive::new(File::open(staged)?).map_err(|_| SealError::NotABackup)?;
64+
let mut text = String::new();
65+
zip.by_name(MANIFEST_ENTRY)
66+
.map_err(|_| SealError::NotABackup)?
67+
.read_to_string(&mut text)?;
68+
let manifest: BackupManifest =
69+
serde_json::from_str(&text).map_err(|_| SealError::NotABackup)?;
70+
if manifest.format != 1 || version(&manifest.version) > version(env!("CARGO_PKG_VERSION")) {
71+
return Err(SealError::Newer);
72+
}
73+
zip.by_name(DATABASE_ENTRY)
74+
.map_err(|_| SealError::NotABackup)?;
75+
Ok(manifest)
76+
}
77+
78+
/// The owner said yes: the staged backup replaces everything on the next
79+
/// start.
80+
pub fn confirm(paths: &Paths) -> io::Result<()> {
81+
let dir = restore_dir(paths);
82+
if !dir.join(STAGED).is_file() {
83+
return Err(io::Error::new(
84+
io::ErrorKind::NotFound,
85+
"no backup is ready to restore; open it again",
86+
));
87+
}
88+
fs::write(dir.join(READY), b"")
89+
}
90+
91+
/// The owner changed their mind: the staged backup goes.
92+
pub fn cancel(paths: &Paths) {
93+
let _ = fs::remove_dir_all(restore_dir(paths));
94+
}
95+
96+
/// On start, before the database opens: swaps in a confirmed backup. The
97+
/// database and the crews' folders that were there move aside, to
98+
/// `<home>\before-restore\<time>` and `<workspaces_root>-before-restore-<time>`.
99+
/// Returns where the old data went, if a backup was applied.
100+
pub fn apply_pending(paths: &Paths, stamp: &str) -> io::Result<Option<PathBuf>> {
101+
let dir = restore_dir(paths);
102+
if !dir.join(READY).is_file() {
103+
return Ok(None);
104+
}
105+
// Once only: a backup that cannot be applied is not tried forever.
106+
fs::remove_file(dir.join(READY))?;
107+
let unpacked = dir.join(UNPACKED);
108+
let _ = fs::remove_dir_all(&unpacked);
109+
unpack(&dir.join(STAGED), &unpacked)?;
110+
111+
let aside = paths.home.join("before-restore").join(stamp);
112+
fs::create_dir_all(&aside)?;
113+
let db = paths.db();
114+
for suffix in ["", "-wal", "-shm"] {
115+
let file = PathBuf::from(format!("{}{suffix}", db.display()));
116+
if file.exists() {
117+
let name = file.file_name().ok_or_else(|| other("no file name"))?;
118+
fs::rename(&file, aside.join(name))?;
119+
}
120+
}
121+
let root = &paths.workspaces_root;
122+
if root.exists() {
123+
let name = root.file_name().ok_or_else(|| other("no folder name"))?;
124+
let moved =
125+
root.with_file_name(format!("{}-before-restore-{stamp}", name.to_string_lossy()));
126+
move_dir(root, &moved)?;
127+
info!(to = %moved.display(), "the crews' folders were moved aside");
128+
}
129+
let workspaces = unpacked.join(WORKSPACES_ENTRY);
130+
if workspaces.exists() {
131+
move_dir(&workspaces, root)?;
132+
} else {
133+
fs::create_dir_all(root)?;
134+
}
135+
fs::rename(unpacked.join(DATABASE_ENTRY), &db)?;
136+
if let Err(err) = fs::remove_dir_all(&dir) {
137+
warn!("could not clean up after restoring: {err}");
138+
}
139+
Ok(Some(aside))
140+
}
141+
142+
/// Every entry of the archive under `to`, refusing any that would land
143+
/// outside it.
144+
fn unpack(archive: &Path, to: &Path) -> io::Result<()> {
145+
let mut zip = ZipArchive::new(File::open(archive)?).map_err(other)?;
146+
for index in 0..zip.len() {
147+
let mut entry = zip.by_index(index).map_err(other)?;
148+
let Some(relative) = entry.enclosed_name() else {
149+
return Err(other("the backup has a file outside its folders"));
150+
};
151+
let target = to.join(relative);
152+
if entry.is_dir() {
153+
fs::create_dir_all(&target)?;
154+
continue;
155+
}
156+
if let Some(parent) = target.parent() {
157+
fs::create_dir_all(parent)?;
158+
}
159+
io::copy(&mut entry, &mut BufWriter::new(File::create(&target)?))?;
160+
}
161+
Ok(())
162+
}
163+
164+
/// Renames, or copies and removes when the two are on different drives.
165+
fn move_dir(from: &Path, to: &Path) -> io::Result<()> {
166+
if fs::rename(from, to).is_ok() {
167+
return Ok(());
168+
}
169+
copy_dir(from, to)?;
170+
fs::remove_dir_all(from)
171+
}
172+
173+
fn copy_dir(from: &Path, to: &Path) -> io::Result<()> {
174+
fs::create_dir_all(to)?;
175+
for entry in fs::read_dir(from)? {
176+
let entry = entry?;
177+
let kind = entry.file_type()?;
178+
let target = to.join(entry.file_name());
179+
if kind.is_dir() {
180+
copy_dir(&entry.path(), &target)?;
181+
} else if kind.is_file() {
182+
fs::copy(entry.path(), target)?;
183+
}
184+
}
185+
Ok(())
186+
}
187+
188+
#[cfg(test)]
189+
mod tests {
190+
use super::*;
191+
192+
#[test]
193+
fn versions_compare_by_number() {
194+
assert!(version("0.10.0") > version("0.9.1"));
195+
assert!(version("1.0.0") > version("0.10.0"));
196+
assert_eq!(version("0.10.0"), version("0.10.0"));
197+
}
198+
}

‎crates/botloftd/src/main.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,14 @@ fn run(paths: Paths, config: Config, config_path: PathBuf, scheduled: bool) -> a
114114

115115
let owner_token = secrets::load_or_create_owner_token(&paths.secrets())
116116
.context("cannot prepare the owner token")?;
117+
// A backup the owner confirmed replaces the data before anything opens
118+
// it (14.2); what was there goes aside.
119+
let stamp = jiff::Zoned::now().strftime("%Y-%m-%d-%H%M%S").to_string();
120+
match botloftd::backup::restore::apply_pending(&paths, &stamp) {
121+
Ok(Some(aside)) => info!(aside = %aside.display(), "a backup was restored"),
122+
Ok(None) => {}
123+
Err(err) => error!("could not restore the backup, so nothing changed: {err}"),
124+
}
117125
let store = Store::open(&paths.db()).context("cannot open the database")?;
118126
std::fs::create_dir_all(&paths.workspaces_root)
119127
.with_context(|| format!("cannot create {}", paths.workspaces_root.display()))?;

‎crates/botloftd/src/rpc/dispatch.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ pub fn dispatch(daemon: &Daemon, name: &str, params: Option<Value>) -> Result<Va
5555
method::RULES_LIST => reply(rules::list(daemon, parse(params)?)),
5656
method::RULES_DELETE => reply(rules::delete(daemon, parse(params)?)),
5757
method::BACKUP_EXPORT => reply(backup::export(daemon, parse(params)?)),
58+
method::BACKUP_STAGE => reply(backup::stage(daemon, parse(params)?)),
59+
method::BACKUP_CONFIRM => reply(backup::confirm(daemon).map(|()| Value::Null)),
60+
method::BACKUP_CANCEL => reply(backup::cancel(daemon).map(|()| Value::Null)),
5861
method::CREW_ACCESS_LIST => reply(crew_access::list(daemon, parse(params)?)),
5962
method::CREW_ACCESS_REVOKE => reply(crew_access::revoke(daemon, parse(params)?)),
6063
method::DESKTOP_GRANTS => reply(desktop::grants(daemon, parse(params)?)),

0 commit comments

Comments
 (0)