Backup: restore snapshots into ~/bos-restore-<id>
Some checks failed
dev release / build (push) Successful in 3m12s
beta (rc) release / build (push) Has been skipped
release / build (push) Failing after 2m59s

Add a real restic restore that writes into a new directory instead of
$HOME, keep dry-run, confirm in the UI, and exclude container storage.
This commit is contained in:
Breadway 2026-08-16 00:53:00 +08:00
parent 73589ae36d
commit 11c4ecb3cc
3 changed files with 193 additions and 13 deletions

View file

@ -24,6 +24,7 @@
has_password: boolean; has_password: boolean;
snapshots: ResticSnapshot[]; snapshots: ResticSnapshot[];
error: string | null; error: string | null;
home: string;
} }
let st = $state<BackupStatus | null>(null); let st = $state<BackupStatus | null>(null);
@ -31,10 +32,27 @@
let password = $state(""); let password = $state("");
let snapshots = $state<ResticSnapshot[]>([]); let snapshots = $state<ResticSnapshot[]>([]);
let selected = $state("latest"); let selected = $state("latest");
let restoreTarget = $state("");
let lastAutoTarget = $state("");
let log = $state<string[]>([]); let log = $state<string[]>([]);
let busy = $state(false); let busy = $state(false);
let message = $state(""); let message = $state("");
function defaultTarget(id: string): string {
const home = st?.home ?? "";
if (!home) return "";
return `${home}/bos-restore-${id || "latest"}`;
}
$effect(() => {
if (!st?.home) return;
const auto = defaultTarget(selected || "latest");
if (restoreTarget === "" || restoreTarget === lastAutoTarget) {
if (restoreTarget !== auto) restoreTarget = auto;
if (lastAutoTarget !== auto) lastAutoTarget = auto;
}
});
async function refresh() { async function refresh() {
st = await invoke<BackupStatus>("get_backup_config"); st = await invoke<BackupStatus>("get_backup_config");
repo = st.repo; repo = st.repo;
@ -79,6 +97,26 @@
snapshots = []; snapshots = [];
} }
} }
async function restore(dryRun: boolean) {
const snap = selected || "latest";
const target = restoreTarget.trim() || defaultTarget(snap);
const home = st?.home ?? "";
if (!dryRun && home && (target === home || target === `${home}/`)) {
message = "Refusing to restore onto $HOME. Leave the default ~/bos-restore-<id> or pick another folder.";
return;
}
if (!dryRun) {
const ok = confirm(
`Restore snapshot ${snap} into ${target}?\n\nFiles go into that directory. Your live home is not overwritten.`,
);
if (!ok) return;
}
await run(dryRun ? "restic_restore_dry_run" : "restic_restore", {
snapshot: snap,
target,
});
}
</script> </script>
<ViewScaffold title="Backup"> <ViewScaffold title="Backup">
@ -100,18 +138,29 @@
{/if} {/if}
</Group> </Group>
<Group title="Actions" hint="Backup covers $HOME and skips caches, Trash, Steam, cargo/rustup, node_modules, target, and .git. Restore is dry-run only."> <Group
title="Actions"
hint="This backs up your @home life — documents, configs, the stuff snapper does not. Snapshots on the Snapshots page are root (@) only. Skips caches, Trash, Steam, containers, cargo/rustup, Flatpak, node_modules, target, and .git."
>
<div class="btn-row"> <div class="btn-row">
<button disabled={busy} onclick={() => run("restic_init")}>Init repo</button> <button disabled={busy} onclick={() => run("restic_init")}>Init repo</button>
<button class="primary" disabled={busy} onclick={() => run("restic_backup")}>Backup home</button> <button class="primary" disabled={busy} onclick={() => run("restic_backup")}>Backup home</button>
<button disabled={busy} onclick={listSnaps}>List snapshots</button> <button disabled={busy} onclick={listSnaps}>List snapshots</button>
<button disabled={busy} onclick={() => run("restic_restore_dry_run", { snapshot: selected || "latest" })}>
Restore dry-run
</button>
</div> </div>
{#if message}<Hint text={message} />{/if} {#if message}<Hint text={message} />{/if}
</Group> </Group>
<Group
title="Restore"
hint="Writes into a new folder (default ~/bos-restore-<id>). Does not overwrite $HOME. Dry-run previews the same target."
>
<FileField label="Restore into" bind:value={restoreTarget} mode="folder" placeholder="/home/you/bos-restore-latest" />
<div class="btn-row">
<button disabled={busy || !st?.restic_installed} onclick={() => restore(true)}>Restore dry-run</button>
<button class="primary" disabled={busy || !st?.restic_installed} onclick={() => restore(false)}>Restore</button>
</div>
</Group>
<Group title="Snapshots" wide> <Group title="Snapshots" wide>
{#if snapshots.length === 0} {#if snapshots.length === 0}
<EmptyState icon={Archive} title="No snapshots loaded" hint="Init, backup, then list." /> <EmptyState icon={Archive} title="No snapshots loaded" hint="Init, backup, then list." />

View file

@ -11,6 +11,12 @@ use super::config;
use super::streaming; use super::streaming;
use super::util::{self, command_exists, fail_output}; use super::util::{self, command_exists, fail_output};
fn home_dir() -> PathBuf {
std::env::var("HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("/root"))
}
fn backup_toml() -> PathBuf { fn backup_toml() -> PathBuf {
util::bos_settings_dir().join("backup.toml") util::bos_settings_dir().join("backup.toml")
} }
@ -66,6 +72,7 @@ pub struct BackupStatus {
has_password: bool, has_password: bool,
snapshots: Vec<ResticSnapshot>, snapshots: Vec<ResticSnapshot>,
error: Option<String>, error: Option<String>,
home: String,
} }
#[derive(Serialize, Clone)] #[derive(Serialize, Clone)]
@ -84,6 +91,7 @@ pub fn get_backup_config() -> BackupStatus {
has_password: s.password.is_some(), has_password: s.password.is_some(),
snapshots: Vec::new(), snapshots: Vec::new(),
error: None, error: None,
home: home_dir().to_string_lossy().into_owned(),
} }
} }
@ -161,6 +169,7 @@ fn exclude_args(home: &str) -> Vec<String> {
".cache", ".cache",
".local/share/Trash", ".local/share/Trash",
".local/share/Steam", ".local/share/Steam",
".local/share/containers",
".npm", ".npm",
".cargo/registry", ".cargo/registry",
".cargo/git", ".cargo/git",
@ -210,8 +219,56 @@ pub async fn restic_backup(app: AppHandle, session_id: String) -> bool {
.await .await
} }
#[tauri::command] /// `~/bos-restore-<id>`. Never `$HOME` itself — restore writes into a new
pub async fn restic_restore_dry_run(app: AppHandle, session_id: String, snapshot: String) -> bool { /// directory so a bad snapshot cannot clobber the live home.
pub fn default_restore_dir(snapshot: &str) -> PathBuf {
home_dir().join(format!("bos-restore-{snapshot}"))
}
fn normalize_abs(path: &Path) -> PathBuf {
path.components().collect()
}
/// Absolute path, not `$HOME` and not `/`. Empty target means the default.
pub fn valid_restore_target(path: &Path) -> bool {
if !path.is_absolute() {
return false;
}
let s = path.to_string_lossy();
if s.is_empty() || s.len() > 512 || s.contains('\n') || s.contains('\0') {
return false;
}
let normalized = normalize_abs(path);
if normalized == PathBuf::from("/") {
return false;
}
normalized != normalize_abs(&home_dir())
}
fn resolve_restore_target(snapshot: &str, target: Option<&str>) -> Result<PathBuf, String> {
if !valid_snapshot_id(snapshot) {
return Err("invalid snapshot id".into());
}
let dest = match target.map(str::trim).filter(|s| !s.is_empty()) {
Some(t) => PathBuf::from(t),
None => default_restore_dir(snapshot),
};
if !valid_restore_target(&dest) {
return Err(
"restore target must be an absolute path that is not $HOME (default is ~/bos-restore-<id>)"
.into(),
);
}
Ok(dest)
}
async fn run_restic_restore(
app: AppHandle,
session_id: String,
snapshot: String,
target: Option<String>,
dry_run: bool,
) -> bool {
let s = match require_ready() { let s = match require_ready() {
Ok(s) => s, Ok(s) => s,
Err(e) => { Err(e) => {
@ -220,14 +277,38 @@ pub async fn restic_restore_dry_run(app: AppHandle, session_id: String, snapshot
} }
}; };
let snap = snapshot.trim(); let snap = snapshot.trim();
if !valid_snapshot_id(snap) { let dest = match resolve_restore_target(snap, target.as_deref()) {
streaming::emit_line(&app, &session_id, "Error: invalid snapshot id"); Ok(p) => p,
Err(e) => {
streaming::emit_line(&app, &session_id, &format!("Error: {e}"));
return false; return false;
} }
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into()); };
let dest_s = dest.to_string_lossy().into_owned();
let password = s.password.clone().unwrap_or_default(); let password = s.password.clone().unwrap_or_default();
let extra = ["restore", snap, "--target", home.as_str(), "--dry-run"]; let mut extra = vec![
let args = restic_args(&s.repo, &extra); "restore".to_string(),
snap.to_string(),
"--target".into(),
dest_s.clone(),
];
if dry_run {
extra.push("--dry-run".into());
}
streaming::emit_line(
&app,
&session_id,
&format!(
"{} {snap} → {dest_s}",
if dry_run {
"Dry-run restore"
} else {
"Restoring"
}
),
);
let extra_refs: Vec<&str> = extra.iter().map(String::as_str).collect();
let args = restic_args(&s.repo, &extra_refs);
streaming::run_hardcoded_env( streaming::run_hardcoded_env(
app, app,
session_id, session_id,
@ -238,6 +319,26 @@ pub async fn restic_restore_dry_run(app: AppHandle, session_id: String, snapshot
.await .await
} }
#[tauri::command]
pub async fn restic_restore_dry_run(
app: AppHandle,
session_id: String,
snapshot: String,
target: Option<String>,
) -> bool {
run_restic_restore(app, session_id, snapshot, target, true).await
}
#[tauri::command]
pub async fn restic_restore(
app: AppHandle,
session_id: String,
snapshot: String,
target: Option<String>,
) -> bool {
run_restic_restore(app, session_id, snapshot, target, false).await
}
fn valid_snapshot_id(id: &str) -> bool { fn valid_snapshot_id(id: &str) -> bool {
if id == "latest" { if id == "latest" {
return true; return true;
@ -355,4 +456,33 @@ mod tests {
assert_eq!(v[0].id, "abc123"); assert_eq!(v[0].id, "abc123");
assert_eq!(v[0].paths[0], "/home/a"); assert_eq!(v[0].paths[0], "/home/a");
} }
#[test]
fn restore_defaults_to_bos_restore_id_not_home() {
let dest = default_restore_dir("a1b2c3d4");
let home = home_dir();
assert_eq!(dest, home.join("bos-restore-a1b2c3d4"));
assert_ne!(dest, home);
assert!(valid_restore_target(&dest));
assert!(!valid_restore_target(&home));
assert!(!valid_restore_target(Path::new("/")));
assert!(!valid_restore_target(Path::new("relative/path")));
assert!(valid_restore_target(Path::new("/tmp/bos-restore-custom")));
let resolved = resolve_restore_target("latest", None).unwrap();
assert_eq!(resolved, home.join("bos-restore-latest"));
assert!(resolve_restore_target("latest", Some(home.to_str().unwrap())).is_err());
}
#[test]
fn exclude_covers_caches_and_containers() {
let args = exclude_args("/home/a");
let joined = args.join(" ");
assert!(joined.contains("/home/a/.cache"));
assert!(joined.contains("/home/a/.local/share/Trash"));
assert!(joined.contains("/home/a/.local/share/Steam"));
assert!(joined.contains("/home/a/.local/share/containers"));
assert!(joined.contains("node_modules"));
assert!(joined.contains("target"));
assert!(joined.contains(".git"));
}
} }

View file

@ -142,6 +142,7 @@ pub fn run() {
commands::backup::restic_init, commands::backup::restic_init,
commands::backup::restic_backup, commands::backup::restic_backup,
commands::backup::restic_restore_dry_run, commands::backup::restic_restore_dry_run,
commands::backup::restic_restore,
commands::backup::list_restic_snapshots, commands::backup::list_restic_snapshots,
commands::optional::get_optional_software, commands::optional::get_optional_software,
commands::optional::enable_flathub, commands::optional::enable_flathub,