fix(restore): stage and verify the tarball before replacing the live data dir

`ai-memory restore --force` deleted the live `wiki/` and `db/` before the
tarball had been opened, validated or extracted, and before the restored
store had been opened. A truncated or corrupt archive, an entry outside
the allowed layout, or a snapshot the current binary could not open (a
backup taken by a newer release, a torn file) therefore left an empty or
half-extracted data dir with nothing to fall back to — at the one moment
the operator has no other copy.

The tarball is now extracted and validated into a staging directory
beside the live data, the staged store is opened there so pending
migrations run and the snapshot is verified, and only then are the live
`wiki/`, `db/` and (when the archive carries one) `config.toml` renamed
aside, the staged copies renamed into place, and the previous data
deleted. Every move is a same-filesystem rename; a failed move reverses
the moves already made, and a failed reversal reports the directory that
still holds the pre-restore data. Scratch directories are removed in
every outcome.

A successful restore behaves as before: `wiki/` and `db/` become what
the archive holds, `config.toml` is replaced only when the archive has
one, `logs/`, `models/` and `raw/` are never touched, `--force` is still
required for a populated data dir, and the sibling-process guard runs
first.

Regression tests drive the new `restore_data_dir` directly: the four
"keeps live data" cases fail under the previous order of operations and
pass here; success-path cases cover the populated, empty and
config-less archives and reopen the swapped-in store.
This commit is contained in:
Vinícius Andrade
2026-09-25 18:05:36 -03:00
parent 6c5692f0fa
commit 198f5e0aea
3 changed files with 502 additions and 41 deletions
+10
View File
@@ -55,6 +55,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
unchanged — this only makes the existing warning reliably visible. (#903)
### Fixed
- `ai-memory restore --force` no longer deletes the live `wiki/` and `db/`
before the tarball has been read. The archive is now extracted and
validated into a staging directory beside the data, the restored store is
opened there so pending migrations run and the snapshot is verified, and
only then are the live directories swapped out by rename (reversed if a
move fails). A truncated or corrupt tarball, an entry outside the allowed
layout, or a snapshot the current binary cannot open — a backup taken by
a newer release, say — previously left an empty or half-extracted data
dir with nothing to fall back to; it now leaves the existing data exactly
as it was. (#923)
- OMP (OpenClaw) tool calls are recorded again. OMP was missing from the
closed-tool-agent set, so its tool events fell through the OpenCode-only
legacy body reader and produced an empty excerpt — nothing reached session
+469 -36
View File
@@ -1,9 +1,17 @@
//! `ai-memory restore --from <tarball>` — restore a backup tarball.
//!
//! Refuses to overwrite a non-empty data dir unless `--force` is given.
//! Refuses while another `ai-memory` process is alive. After extraction,
//! re-opens the store so any pending migrations run (and a corrupt
//! snapshot fails loudly).
//! Refuses while another `ai-memory` process is alive.
//!
//! The live data is never touched before the archive has proven usable:
//! the tarball is extracted and validated into a staging directory beside
//! the live `wiki/` and `db/`, the restored store is opened there so any
//! pending migrations run (and a corrupt snapshot fails loudly), and only
//! then are the live directories swapped out by rename. A truncated
//! archive, an entry outside the allowed layout, or a snapshot the current
//! binary cannot open therefore leaves the existing data exactly as it was
//! — the moment a restore fails is the moment the operator has no other
//! copy, so the previous state must survive it.
//!
//! # Exception to invariant §16
//!
@@ -18,18 +26,29 @@ use ai_memory_store::Store;
use anyhow::{Context, Result, bail};
use flate2::read::GzDecoder;
use std::path::{Component, Path};
use tracing::info;
use tracing::{info, warn};
use crate::cli::RestoreArgs;
use crate::config::Config;
use crate::process_guard::{busy_message, sibling_processes};
/// Data-dir directories a restore replaces wholesale: whatever the archive
/// holds for each takes the live one's place, and a directory the archive
/// lacks is retired rather than merged with the archive's state. `logs/`,
/// `models/`, `raw/` and anything else beside them are never touched.
const REPLACED_DIRS: &[&str] = &["wiki", "db"];
/// The one file a restore replaces, and only when the archive carries it;
/// otherwise the live copy stays.
const CONFIG_FILE: &str = "config.toml";
/// Run the `restore` subcommand.
///
/// # Errors
/// Returns an error if another `ai-memory` process is running, the
/// data dir is non-empty without `--force`, the tarball cannot be
/// extracted, or the restored store fails to open.
/// extracted, or the restored store fails to open. In every one of those
/// cases the live data dir is left as it was.
pub fn run(config: &Config, args: RestoreArgs) -> Result<()> {
let siblings = sibling_processes();
if !siblings.is_empty() {
@@ -40,37 +59,7 @@ pub fn run(config: &Config, args: RestoreArgs) -> Result<()> {
bail!("source tarball {} not found", args.from.display());
}
let wiki = config.data_dir.join("wiki");
let db = config.data_dir.join("db").join("memory.sqlite");
if (wiki.is_dir() && std::fs::read_dir(&wiki)?.next().is_some()) || db.is_file() {
if !args.force {
bail!(
"refusing to restore: data dir at {} is non-empty (pass --force to overwrite)",
config.data_dir.display(),
);
}
// Force path: drop the existing wiki + db so the tarball can
// populate them cleanly. Keep config.toml, logs/, models/.
for sub in ["wiki", "db"] {
let path = config.data_dir.join(sub);
if path.exists() {
std::fs::remove_dir_all(&path)?;
}
}
}
std::fs::create_dir_all(&config.data_dir)?;
let file = std::fs::File::open(&args.from)
.with_context(|| format!("opening {}", args.from.display()))?;
let decoder = GzDecoder::new(file);
let mut archive = tar::Archive::new(decoder);
unpack_checked_archive(&mut archive, &config.data_dir)
.with_context(|| format!("extracting into {}", config.data_dir.display()))?;
info!(from = %args.from.display(), into = %config.data_dir.display(), "tarball extracted");
// Open + drop the store so refinery applies any pending migrations
// and the SQLite file is validated.
let _store = Store::open(&config.data_dir).context("opening restored store")?;
restore_data_dir(&config.data_dir, &args.from, args.force)?;
info!("restore complete");
println!(
"restored {} -> {}",
@@ -80,6 +69,170 @@ pub fn run(config: &Config, args: RestoreArgs) -> Result<()> {
Ok(())
}
/// Stage, validate, then swap. Nothing under `data_dir` changes until the
/// archive has been fully extracted into a staging directory and the
/// staged store has opened; the live `wiki/`, `db/` and (when the archive
/// carries one) `config.toml` are then exchanged for the staged copies by
/// rename and rolled back if any step of the exchange fails.
fn restore_data_dir(data_dir: &Path, from: &Path, force: bool) -> Result<()> {
let wiki = data_dir.join("wiki");
let db = data_dir.join("db").join("memory.sqlite");
let occupied = (wiki.is_dir() && std::fs::read_dir(&wiki)?.next().is_some()) || db.is_file();
if occupied && !force {
bail!(
"refusing to restore: data dir at {} is non-empty (pass --force to overwrite)",
data_dir.display(),
);
}
std::fs::create_dir_all(data_dir)?;
// Both scratch directories live inside the data dir so every move below
// is a rename on one filesystem, never a copy that could half-complete.
let stamp = format!(
"{}-{}",
jiff::Timestamp::now().strftime("%Y%m%d-%H%M%S"),
std::process::id()
);
let staging = data_dir.join(format!(".restore-staging-{stamp}"));
let previous = data_dir.join(format!(".restore-previous-{stamp}"));
let outcome = stage_then_swap(data_dir, from, &staging, &previous);
// The staging dir is scratch in every outcome: on success its contents
// were moved into place, on failure the live data was never touched.
if staging.exists()
&& let Err(e) = std::fs::remove_dir_all(&staging)
{
warn!(path = %staging.display(), error = %e, "could not remove restore staging dir");
eprintln!(
"warning: could not remove staging dir {} ({e}); delete it by hand",
staging.display()
);
}
outcome
}
fn stage_then_swap(data_dir: &Path, from: &Path, staging: &Path, previous: &Path) -> Result<()> {
std::fs::create_dir(staging)
.with_context(|| format!("creating staging dir {}", staging.display()))?;
// 1. Extract into staging, validating every entry on the way. A
// truncated gzip stream, an unreadable member or a path outside the
// allowed layout fails here, with the live data still untouched.
let file = std::fs::File::open(from).with_context(|| format!("opening {}", from.display()))?;
let decoder = GzDecoder::new(file);
let mut archive = tar::Archive::new(decoder);
unpack_checked_archive(&mut archive, staging)
.with_context(|| format!("extracting {} into {}", from.display(), staging.display()))?;
info!(from = %from.display(), into = %staging.display(), "tarball extracted into staging");
// 2. Open + drop the staged store so refinery applies any pending
// migrations and the SQLite file is validated — still before anything
// live is touched. Dropping the store joins the writer thread and
// closes every connection, so the directory can be renamed afterwards
// on Windows as well.
drop(Store::open(staging).context("opening restored store")?);
// 3. Exchange the live directories for the staged ones.
swap_into_place(data_dir, staging, previous)?;
info!(into = %data_dir.display(), "restored data swapped into place");
Ok(())
}
/// Move the live entries aside into `previous`, move the staged entries
/// into place, then discard `previous`. Every step is a same-filesystem
/// rename; if one fails, the moves already made are reversed so the data
/// dir ends up as it started.
fn swap_into_place(data_dir: &Path, staging: &Path, previous: &Path) -> Result<()> {
std::fs::create_dir(previous).with_context(|| format!("creating {}", previous.display()))?;
// Entries moved from the data dir into `previous`, and staged entries
// already placed live: the two lists a rollback has to undo.
let mut moved_aside: Vec<&str> = Vec::new();
let mut placed: Vec<&str> = Vec::new();
let exchange = (|| -> Result<()> {
for name in REPLACED_DIRS {
let live = data_dir.join(name);
if live.exists() {
std::fs::rename(&live, previous.join(name))
.with_context(|| format!("moving {} aside", live.display()))?;
moved_aside.push(name);
}
}
let live_config = data_dir.join(CONFIG_FILE);
if staging.join(CONFIG_FILE).is_file() && live_config.exists() {
std::fs::rename(&live_config, previous.join(CONFIG_FILE))
.with_context(|| format!("moving {} aside", live_config.display()))?;
moved_aside.push(CONFIG_FILE);
}
for name in REPLACED_DIRS.iter().chain(std::iter::once(&CONFIG_FILE)) {
let staged = staging.join(name);
if staged.exists() {
std::fs::rename(&staged, data_dir.join(name))
.with_context(|| format!("moving {} into place", staged.display()))?;
placed.push(name);
}
}
Ok(())
})();
if let Err(e) = exchange {
if let Err(rollback_err) = roll_back(data_dir, previous, &placed, &moved_aside) {
bail!(
"INCONSISTENT STATE: restore swap failed ({e:#}) and moving the previous data \
back also failed ({rollback_err:#}); the pre-restore wiki/ and db/ are under {} \
— move them back into {} by hand",
previous.display(),
data_dir.display(),
);
}
return Err(e.context("restore swap failed; the previous data was moved back into place"));
}
// The previous data is only discarded once the restored copy is live —
// the documented `--force` semantics, now applied last instead of first.
if let Err(e) = std::fs::remove_dir_all(previous) {
warn!(path = %previous.display(), error = %e, "could not remove pre-restore data");
eprintln!(
"warning: restore succeeded but the pre-restore data under {} could not be \
removed ({e}); delete it by hand",
previous.display()
);
}
Ok(())
}
/// Undo a partial swap: remove whatever staged entries were already placed
/// live, then move the previous entries back. Placed entries are removed
/// before their predecessors return so a rename never finds its target
/// occupied.
fn roll_back(
data_dir: &Path,
previous: &Path,
placed: &[&str],
moved_aside: &[&str],
) -> Result<()> {
for name in placed {
let live = data_dir.join(name);
remove_path(&live).with_context(|| format!("removing half-placed {}", live.display()))?;
}
for name in moved_aside {
let parked = previous.join(name);
std::fs::rename(&parked, data_dir.join(name))
.with_context(|| format!("moving {} back", parked.display()))?;
}
Ok(())
}
fn remove_path(path: &Path) -> std::io::Result<()> {
match std::fs::symlink_metadata(path) {
Ok(meta) if meta.is_dir() => std::fs::remove_dir_all(path),
Ok(_) => std::fs::remove_file(path),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
fn unpack_checked_archive<R: std::io::Read>(
archive: &mut tar::Archive<R>,
data_dir: &Path,
@@ -354,4 +507,284 @@ mod tests {
.is_file()
);
}
// ---- stage → validate → swap: a failed restore leaves the live data alone ----
/// A populated data dir as an operator has it: a wiki page, a database
/// file (never opened by these tests, so any bytes do), the config, and
/// the neighbours `logs/` and `raw/` that a restore must never touch.
fn seed_live_data(dir: &Path) {
std::fs::create_dir_all(dir.join("wiki/default/project/notes")).unwrap();
std::fs::write(dir.join("wiki/default/project/notes/old.md"), b"old page").unwrap();
std::fs::create_dir_all(dir.join("db")).unwrap();
std::fs::write(dir.join("db/memory.sqlite"), b"old database bytes").unwrap();
std::fs::write(dir.join("config.toml"), b"# old config\n").unwrap();
std::fs::create_dir_all(dir.join("logs")).unwrap();
std::fs::write(dir.join("logs/app.log"), b"log").unwrap();
std::fs::create_dir_all(dir.join("raw")).unwrap();
std::fs::write(dir.join("raw/segment.jsonl"), b"{}").unwrap();
}
/// Scratch directories a restore leaves behind only when its cleanup
/// failed: none may survive a run, successful or not.
fn restore_scratch_dirs(data_dir: &Path) -> Vec<std::path::PathBuf> {
std::fs::read_dir(data_dir)
.unwrap()
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with(".restore-"))
})
.collect()
}
fn assert_live_data_untouched(dir: &Path) {
assert_eq!(
std::fs::read(dir.join("wiki/default/project/notes/old.md")).unwrap(),
b"old page"
);
assert_eq!(
std::fs::read(dir.join("db/memory.sqlite")).unwrap(),
b"old database bytes"
);
assert_eq!(
std::fs::read(dir.join("config.toml")).unwrap(),
b"# old config\n"
);
assert_eq!(std::fs::read(dir.join("logs/app.log")).unwrap(), b"log");
assert_eq!(std::fs::read(dir.join("raw/segment.jsonl")).unwrap(), b"{}");
let scratch = restore_scratch_dirs(dir);
assert!(scratch.is_empty(), "scratch dirs left behind: {scratch:?}");
}
/// Bytes of a real, migrated `memory.sqlite` — what a `backup` tarball
/// carries.
fn migrated_sqlite_bytes() -> Vec<u8> {
let tmp = tempfile::TempDir::new().unwrap();
drop(Store::open(tmp.path()).unwrap());
std::fs::read(tmp.path().join("db/memory.sqlite")).unwrap()
}
/// A gzipped tarball holding the given regular files.
fn gz_tarball(entries: &[(&str, &[u8])]) -> Vec<u8> {
use std::io::Write as _;
let mut tar_bytes = Vec::new();
{
let mut builder = tar::Builder::new(&mut tar_bytes);
for (path, body) in entries {
let mut header = tar::Header::new_gnu();
header.set_path(path).unwrap();
header.set_size(body.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder.append(&header, *body).unwrap();
}
builder.finish().unwrap();
}
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
gz.write_all(&tar_bytes).unwrap();
gz.finish().unwrap()
}
fn good_archive() -> Vec<u8> {
let db = migrated_sqlite_bytes();
gz_tarball(&[
("wiki/default/project/notes/new.md", b"new page"),
("db/memory.sqlite", db.as_slice()),
("config.toml", b"# restored config\n"),
])
}
fn write_tarball(dir: &Path, name: &str, bytes: &[u8]) -> std::path::PathBuf {
let path = dir.join(name);
std::fs::write(&path, bytes).unwrap();
path
}
#[test]
fn restore_force_keeps_live_data_when_the_tarball_is_not_a_gzip_stream() {
let data = tempfile::TempDir::new().unwrap();
seed_live_data(data.path());
let scratch = tempfile::TempDir::new().unwrap();
let from = write_tarball(scratch.path(), "bad.tar.gz", b"this is not a gzip stream");
let err = restore_data_dir(data.path(), &from, true).unwrap_err();
assert!(
format!("{err:#}").contains("extracting"),
"unexpected error: {err:#}"
);
assert_live_data_untouched(data.path());
}
#[test]
fn restore_force_keeps_live_data_when_the_tarball_is_truncated() {
let data = tempfile::TempDir::new().unwrap();
seed_live_data(data.path());
let scratch = tempfile::TempDir::new().unwrap();
let whole = good_archive();
let from = write_tarball(scratch.path(), "cut.tar.gz", &whole[..whole.len() / 2]);
let err = restore_data_dir(data.path(), &from, true).unwrap_err();
assert!(
format!("{err:#}").contains("extracting"),
"unexpected error: {err:#}"
);
assert_live_data_untouched(data.path());
}
#[test]
fn restore_force_keeps_live_data_when_an_entry_is_outside_the_allowed_layout() {
let data = tempfile::TempDir::new().unwrap();
seed_live_data(data.path());
let scratch = tempfile::TempDir::new().unwrap();
// Valid entries first, so extraction is already under way when the
// stray one is met — the failure must still leave nothing behind.
let db = migrated_sqlite_bytes();
let from = write_tarball(
scratch.path(),
"stray.tar.gz",
&gz_tarball(&[
("wiki/default/project/notes/new.md", b"new page"),
("db/memory.sqlite", db.as_slice()),
("db/extra.sqlite", b"stray"),
]),
);
let err = restore_data_dir(data.path(), &from, true).unwrap_err();
assert!(
format!("{err:#}").contains("unexpected path"),
"unexpected error: {err:#}"
);
assert_live_data_untouched(data.path());
}
#[test]
fn restore_force_keeps_live_data_when_the_restored_store_cannot_open() {
let data = tempfile::TempDir::new().unwrap();
seed_live_data(data.path());
let scratch = tempfile::TempDir::new().unwrap();
let from = write_tarball(
scratch.path(),
"notadb.tar.gz",
&gz_tarball(&[
("wiki/default/project/notes/new.md", b"new page"),
("db/memory.sqlite", &[0xFF; 4096]),
]),
);
let err = restore_data_dir(data.path(), &from, true).unwrap_err();
assert!(
format!("{err:#}").contains("opening restored store"),
"unexpected error: {err:#}"
);
assert_live_data_untouched(data.path());
}
#[test]
fn restore_refuses_a_populated_data_dir_without_force() {
let data = tempfile::TempDir::new().unwrap();
seed_live_data(data.path());
let scratch = tempfile::TempDir::new().unwrap();
let from = write_tarball(scratch.path(), "good.tar.gz", &good_archive());
let err = restore_data_dir(data.path(), &from, false).unwrap_err();
assert!(
err.to_string().contains("pass --force"),
"unexpected error: {err:#}"
);
assert_live_data_untouched(data.path());
}
#[test]
fn restore_force_replaces_the_live_data_with_the_archive() {
let data = tempfile::TempDir::new().unwrap();
seed_live_data(data.path());
let scratch = tempfile::TempDir::new().unwrap();
let from = write_tarball(scratch.path(), "good.tar.gz", &good_archive());
restore_data_dir(data.path(), &from, true).unwrap();
assert_eq!(
std::fs::read(data.path().join("wiki/default/project/notes/new.md")).unwrap(),
b"new page"
);
assert!(
!data
.path()
.join("wiki/default/project/notes/old.md")
.exists(),
"the previous wiki must not be merged into the restored one"
);
assert_eq!(
std::fs::read(data.path().join("config.toml")).unwrap(),
b"# restored config\n"
);
assert_eq!(
std::fs::read(data.path().join("logs/app.log")).unwrap(),
b"log"
);
assert_eq!(
std::fs::read(data.path().join("raw/segment.jsonl")).unwrap(),
b"{}"
);
let scratch_dirs = restore_scratch_dirs(data.path());
assert!(
scratch_dirs.is_empty(),
"scratch dirs left behind: {scratch_dirs:?}"
);
// The swapped-in store is the migrated one and opens cleanly in place.
drop(Store::open(data.path()).unwrap());
}
#[test]
fn restore_keeps_the_live_config_when_the_archive_carries_none() {
let data = tempfile::TempDir::new().unwrap();
seed_live_data(data.path());
let scratch = tempfile::TempDir::new().unwrap();
let db = migrated_sqlite_bytes();
let from = write_tarball(
scratch.path(),
"noconfig.tar.gz",
&gz_tarball(&[
("wiki/default/project/notes/new.md", b"new page"),
("db/memory.sqlite", db.as_slice()),
]),
);
restore_data_dir(data.path(), &from, true).unwrap();
assert_eq!(
std::fs::read(data.path().join("config.toml")).unwrap(),
b"# old config\n"
);
assert_eq!(
std::fs::read(data.path().join("wiki/default/project/notes/new.md")).unwrap(),
b"new page"
);
assert!(restore_scratch_dirs(data.path()).is_empty());
}
#[test]
fn restore_into_an_empty_data_dir_needs_no_force() {
let data = tempfile::TempDir::new().unwrap();
let scratch = tempfile::TempDir::new().unwrap();
let from = write_tarball(scratch.path(), "good.tar.gz", &good_archive());
restore_data_dir(data.path(), &from, false).unwrap();
assert_eq!(
std::fs::read(data.path().join("wiki/default/project/notes/new.md")).unwrap(),
b"new page"
);
assert!(data.path().join("db/memory.sqlite").is_file());
assert!(restore_scratch_dirs(data.path()).is_empty());
}
}
+23 -5
View File
@@ -19,7 +19,7 @@ on a homelab box where mistakes are harder to undo.
| `backup --to` | ✅ yes | no | n/a | Streams a gzipped tarball from the server's online `sqlite3 .backup` plus the wiki tree. Safe alongside the live writer. |
| `checkpoints` | ✅ yes | no | n/a | Lists recent wiki git checkpoints. Read-only. |
| `restore-page --path --from` | ✅ yes | overwrites one markdown page version | yes (restore another checkpoint) | Restores one page from wiki git history, reindexes it into SQLite, and writes a post-restore checkpoint. Does not restore DB-only state. |
| `restore --from <tarball>` | ❌ **stop the server first** | overwrites the data dir | no (without prior backup) | Refuses if any sibling `ai-memory` process is alive (sysinfo guard). |
| `restore --from <tarball>` | ❌ **stop the server first** | overwrites the data dir | no (without prior backup) | Refuses if any sibling `ai-memory` process is alive (sysinfo guard). Stages and verifies the archive before swapping it in, so a failed restore leaves `wiki/` and `db/` as they were. |
| `reset --confirm` | ❌ **stop the server first** | yes, all data | no | Refuses if any sibling `ai-memory` process is alive (sysinfo guard). |
| `reindex` | ❌ **stop the server first** | no wiki wipe; requires a clean DB | only with prior DB backup | Rebuilds pages/links/FTS from `wiki/` using `_meta.md` manifests. Refuses if SQLite already has rows so stale DB-only state cannot survive silently. |
@@ -715,17 +715,35 @@ alive (uses `sysinfo` to scan the process table).
Order of operations:
1. Check the data dir is empty (or the user passed `--force`).
2. Extract the tarball into the data dir.
3. Restore the SQLite snapshot in place.
4. Print a one-line summary.
2. Extract the tarball into a staging directory beside the live data
(`<data_dir>/.restore-staging-<stamp>/`), validating every entry.
3. Open the staged store so pending migrations run and the SQLite
snapshot is verified — still without touching the live data.
4. Swap: rename the live `wiki/` and `db/` (and `config.toml`, when the
archive carries one) aside, rename the staged copies into place, then
delete the previous data. Each move is a same-filesystem rename; if
one fails, the moves already made are reversed.
5. Print a one-line summary.
The live data is therefore untouched until the archive has proven usable.
A restore that fails in steps 2–3 leaves `wiki/` and `db/` exactly as they
were, which matters because a restore is usually attempted when no other
copy exists.
Failure modes:
- **Server still running** → exits with "another ai-memory process is
alive (pid X); stop it before restoring" - same wording as `reset`.
- **`--confirm` omitted** → exits with usage hint.
- **Data dir not empty + no `--force`** → exits with "data dir not
empty; pass `--force` to overwrite".
- **Truncated or corrupt tarball, an entry outside the allowed layout, or
a snapshot the current binary cannot open** (for example a backup taken
by a newer release) → exits with the error; the existing `wiki/` and
`db/` are left as they were.
- **A rename in the swap fails and cannot be reversed** → exits with an
`INCONSISTENT STATE` message naming the
`<data_dir>/.restore-previous-<stamp>/` directory that still holds the
pre-restore `wiki/` and `db/`, to be moved back by hand.
### `reset`