Backup: restore snapshots into ~/bos-restore-<id>
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:
parent
0798fad697
commit
439ef6796b
3 changed files with 193 additions and 13 deletions
|
|
@ -24,6 +24,7 @@
|
|||
has_password: boolean;
|
||||
snapshots: ResticSnapshot[];
|
||||
error: string | null;
|
||||
home: string;
|
||||
}
|
||||
|
||||
let st = $state<BackupStatus | null>(null);
|
||||
|
|
@ -31,10 +32,27 @@
|
|||
let password = $state("");
|
||||
let snapshots = $state<ResticSnapshot[]>([]);
|
||||
let selected = $state("latest");
|
||||
let restoreTarget = $state("");
|
||||
let lastAutoTarget = $state("");
|
||||
let log = $state<string[]>([]);
|
||||
let busy = $state(false);
|
||||
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() {
|
||||
st = await invoke<BackupStatus>("get_backup_config");
|
||||
repo = st.repo;
|
||||
|
|
@ -79,6 +97,26 @@
|
|||
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>
|
||||
|
||||
<ViewScaffold title="Backup">
|
||||
|
|
@ -100,18 +138,29 @@
|
|||
{/if}
|
||||
</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">
|
||||
<button disabled={busy} onclick={() => run("restic_init")}>Init repo</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={() => run("restic_restore_dry_run", { snapshot: selected || "latest" })}>
|
||||
Restore dry-run
|
||||
</button>
|
||||
</div>
|
||||
{#if message}<Hint text={message} />{/if}
|
||||
</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>
|
||||
{#if snapshots.length === 0}
|
||||
<EmptyState icon={Archive} title="No snapshots loaded" hint="Init, backup, then list." />
|
||||
|
|
|
|||
|
|
@ -11,6 +11,12 @@ use super::config;
|
|||
use super::streaming;
|
||||
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 {
|
||||
util::bos_settings_dir().join("backup.toml")
|
||||
}
|
||||
|
|
@ -66,6 +72,7 @@ pub struct BackupStatus {
|
|||
has_password: bool,
|
||||
snapshots: Vec<ResticSnapshot>,
|
||||
error: Option<String>,
|
||||
home: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
|
|
@ -84,6 +91,7 @@ pub fn get_backup_config() -> BackupStatus {
|
|||
has_password: s.password.is_some(),
|
||||
snapshots: Vec::new(),
|
||||
error: None,
|
||||
home: home_dir().to_string_lossy().into_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -161,6 +169,7 @@ fn exclude_args(home: &str) -> Vec<String> {
|
|||
".cache",
|
||||
".local/share/Trash",
|
||||
".local/share/Steam",
|
||||
".local/share/containers",
|
||||
".npm",
|
||||
".cargo/registry",
|
||||
".cargo/git",
|
||||
|
|
@ -210,8 +219,56 @@ pub async fn restic_backup(app: AppHandle, session_id: String) -> bool {
|
|||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn restic_restore_dry_run(app: AppHandle, session_id: String, snapshot: String) -> bool {
|
||||
/// `~/bos-restore-<id>`. Never `$HOME` itself — restore writes into a new
|
||||
/// 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() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
|
|
@ -220,14 +277,38 @@ pub async fn restic_restore_dry_run(app: AppHandle, session_id: String, snapshot
|
|||
}
|
||||
};
|
||||
let snap = snapshot.trim();
|
||||
if !valid_snapshot_id(snap) {
|
||||
streaming::emit_line(&app, &session_id, "Error: invalid snapshot id");
|
||||
return false;
|
||||
}
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
|
||||
let dest = match resolve_restore_target(snap, target.as_deref()) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
streaming::emit_line(&app, &session_id, &format!("Error: {e}"));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let dest_s = dest.to_string_lossy().into_owned();
|
||||
let password = s.password.clone().unwrap_or_default();
|
||||
let extra = ["restore", snap, "--target", home.as_str(), "--dry-run"];
|
||||
let args = restic_args(&s.repo, &extra);
|
||||
let mut extra = vec![
|
||||
"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(
|
||||
app,
|
||||
session_id,
|
||||
|
|
@ -238,6 +319,26 @@ pub async fn restic_restore_dry_run(app: AppHandle, session_id: String, snapshot
|
|||
.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 {
|
||||
if id == "latest" {
|
||||
return true;
|
||||
|
|
@ -355,4 +456,33 @@ mod tests {
|
|||
assert_eq!(v[0].id, "abc123");
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -141,6 +141,7 @@ pub fn run() {
|
|||
commands::backup::restic_init,
|
||||
commands::backup::restic_backup,
|
||||
commands::backup::restic_restore_dry_run,
|
||||
commands::backup::restic_restore,
|
||||
commands::backup::list_restic_snapshots,
|
||||
commands::optional::get_optional_software,
|
||||
commands::optional::enable_flathub,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue