Skip to content

Commit 7b558a6

Browse files
pmaxhoganclaude
andauthored
feat(core): scheduled restore drills that prove the backup restores (#269)
Replaces #215 (clean re-cut from main per that PR's own merge-mechanics plan; the old branch is squash-stale against the #203 scrub merge). The engine is the reviewed #215 delta re-applied (migration renumbered to 0015); this PR also finishes the entire reporting surface #215 said was mandatory before landing. ## What this adds The read-side counterpart to the integrity scrub: a monthly drill that picks N files deterministically, restores them through the REAL restore path (download, decrypt, extract, BLAKE3-verify) into a temp dir, deletes the output, and records a counts-only report. A break anywhere in the restore chain currently surfaces exactly once: the day the user needs their data. - `driven_core::drill` engine + `0015_restore_drills.sql` + StateRepo methods + orchestrator dispatch (after the scrub), with 10 scheduling tests including a proven-non-vacuous scrub-before-drill ordering test. - `list_drill_runs` IPC + `DrillRunDto` + `DrillSettings` on `SettingsDto` (clamped ranges, kill-switch). - `driven-cli drill` with `--fail-on-failure` (keys on failed count, so an Inconclusive run never fails a monitor). - UI: `DrillHistoryPanel` in Activity, controls on the Advanced settings page (cadence in days), locale strings, stores/types, 100% coverage on all new files. - Headline integration tests against a real `AppState` + `InMemoryRemoteStore`: a genuine file restores Verified; `rot_object_bytes` corruption reports Failed (not Skipped); two Skipped paths; both assert no decrypted plaintext leaks in temp dirs. - Bug found en route: `ErrorCode::RestoreDrillFailed` had no `errors.*` locale key, so the one event this feature exists to surface would have rendered as a raw i18n path in the feed. Fixed with a regression test (no existing gate catches this class). - Visual baselines regenerated (24 PNGs: Activity, Advanced, shell backdrop) and eyeballed; linux visual suite 102/102. ## Gates (local, macOS) cargo fmt / clippy -D warnings / test workspace (1970 passed, 0 failed) all clean; ui lint (baseline warnings only), format:check, test:unit (777 passed), vue-tsc + build clean. 3-OS Rust compile is what this CI run verifies. README updated in this PR (footnote 44 three->four periodic checks, new feature bullet, removed the stale "#215 DRAFT" placeholder). Closes #215. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_015jEKvjBzVjaWLq6o5zg2PV --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 75a30a7 commit 7b558a6

61 files changed

Lines changed: 4090 additions & 13 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ Notes:
106106
- ⁴¹ Driven's checkmark is scoped to Windows, where a VSS snapshot lets a locked file (Outlook PST, running DB, VM disk) back up while it is held open. macOS has an equivalent behind an opt-in setting (Settings > macOS): a small privileged helper mounts a read-only APFS local snapshot so a *busy* file can be read: it is off by default, and it does nothing for a Full Disk Access denial. On both macOS and Linux, a file Driven cannot open is in any case classified precisely as a transient lock (`local.file_locked`) versus a macOS Full Disk Access denial (`local.permission_denied`) and skipped with a clear reason in the activity log, rather than misreported as a disk error. Linux has no snapshot equivalent and none is planned. See `design/DESIGN.md` §5.3 and §5.3.2.
107107
- ⁴² Driven's checkmark is scoped to the Google Drive destination. Keeping previous versions relies on a changed file's re-upload landing on a NEW remote object, which is true of a Drive create but not of a destination whose object key is derived from the file name: on S3-compatible stores, SFTP servers, and local folders the re-upload overwrites the previous copy. Driven therefore does not offer per-source versioning or a restore-by-date on those destinations at all, rather than reporting a point-in-time restore it cannot perform. For recoverability on S3, enable the provider's own bucket versioning. See [issue #220](https://github.com/pmaxhogan/driven/issues/220).
108108
- ⁴³ Google Drive (the original destination), any S3-compatible object store (AWS S3, Cloudflare R2, MinIO, Backblaze B2 in S3-compatible mode, Wasabi), any SSH server with SFTP (a NAS, home server, or VPS - added in v2.9.0, with TOFU host-key pinning and password or private-key auth), and a local or removable folder (USB drive, external disk, NAS share) - all behind one `RemoteStore` trait. OneDrive and other non-S3-compatible cloud APIs are not implemented.
109-
- ⁴⁴ Driven runs three independent periodic checks, not one: a weekly local re-hash (deep-verify) that catches local bit-rot, a startup / deep-verify remote-existence audit that catches an object deleted at the destination outside Driven, and, added in v2.5.0, a weekly rolling integrity scrub that re-checks each already-backed-up object's size against the destination and, where the destination can supply one, its content checksum too - catching remote-side corruption or tampering that neither of the other two checks can see. On by default; a source's population is swept in bounded slices (500 objects per run by default) rather than all at once. Checksum coverage depends on the destination: full on Google Drive and on a local-folder destination (which always re-hashes the bytes it just wrote); size-only for an S3 object uploaded via multipart, because S3 stores no plain content digest for one, only an ETag that digests the individual parts - the scrub reports those as unverifiable rather than guessing, never as falsely clean.
109+
- ⁴⁴ Driven runs four independent periodic checks, not one: a weekly local re-hash (deep-verify) that catches local bit-rot, a startup / deep-verify remote-existence audit that catches an object deleted at the destination outside Driven, and, added in v2.5.0, a weekly rolling integrity scrub that re-checks each already-backed-up object's size against the destination and, where the destination can supply one, its content checksum too - catching remote-side corruption or tampering that neither of the other two checks can see. On by default; a source's population is swept in bounded slices (500 objects per run by default) rather than all at once. Checksum coverage depends on the destination: full on Google Drive and on a local-folder destination (which always re-hashes the bytes it just wrote); size-only for an S3 object uploaded via multipart, because S3 stores no plain content digest for one, only an ETag that digests the individual parts - the scrub reports those as unverifiable rather than guessing, never as falsely clean. The fourth check is the restore drill: every one of the three above verifies the backup from the WRITE side ("is what we wrote still there?"), and none of them ever runs the reverse pipeline, so a broken restore path or an unusable key stays invisible until the day you actually need your data. Once a month Driven therefore restores a few of your backed-up files for real - download, decrypt, extract, verify the plaintext hash - into a temporary directory, then deletes them. On by default, three files per drill; both the cadence and the sample size are configurable, and the whole thing has a kill switch. A drill that verifies nothing (nothing restorable yet, or a locked keychain) is reported as inconclusive, never as a pass.
110110

111111
Competitor rows were verified in July 2026 against rclone 1.74.4, restic 0.19.1,
112112
Duplicati 2.3.0.4, Backblaze Personal Backup 10.0.2, and Drive for desktop 128.0.
@@ -174,20 +174,20 @@ These move: check each project's current docs before relying on a cell.
174174
catching remote-side corruption that neither of those two checks can see.
175175
On by default, weekly, in bounded slices so a huge source never triggers
176176
an unbounded sweep.
177+
- Restore drill: the only check that exercises the READ side. Every other
178+
check asks "is what we wrote still there?"; a drill periodically restores a
179+
small, deterministically-sampled set of your backed-up files through the
180+
real restore path - download, decrypt, extract, verify the plaintext hash -
181+
into a temp directory, then deletes them, proving your data actually comes
182+
back rather than merely that it is still stored. On by default, monthly,
183+
three files per drill. Reports are counts and error codes only, so a drill
184+
report can never leak an encrypted source's filenames.
177185
- rclone config importer (`driven-cli rclone`): point it at an existing
178186
`rclone.conf` and it tells you exactly what to enter for an `s3` or
179187
`drive` remote, rather than making you re-type every endpoint and key.
180188
Read-only - it never creates an account itself. See "Migrating from
181189
rclone" below.
182190

183-
<!--
184-
DRAFT - do not uncomment until the corresponding PR merges. Flip each bullet
185-
on individually as its PR lands, then delete this comment wrapper.
186-
- Restore drill: a one-click "prove the backup actually restores" check.
187-
(core engine landed, #215 DRAFT / unmerged - surface is not wired up to
188-
anything a user can click yet.)
189-
-->
190-
191191
## Install
192192

193193
Download the installer for your platform from the

crates/driven-cli/src/inspect.rs

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,21 @@ pub struct ScrubArgs {
4949
pub fail_on_drift: bool,
5050
}
5151

52+
/// Args for `driven-cli drill`.
53+
#[derive(Debug, Args)]
54+
pub struct DrillArgs {
55+
/// Path to the Driven state database.
56+
#[arg(long, default_value = "state.db")]
57+
pub db: PathBuf,
58+
/// Maximum number of drill runs to show (newest first).
59+
#[arg(long, default_value_t = 10)]
60+
pub limit: u32,
61+
/// Exit non-zero when the most recent run of any source could not restore a
62+
/// file it sampled, so the command is usable as a monitoring check.
63+
#[arg(long)]
64+
pub fail_on_failure: bool,
65+
}
66+
5267
/// Args for `driven-cli history`.
5368
#[derive(Debug, Args)]
5469
pub struct HistoryArgs {
@@ -319,6 +334,107 @@ pub async fn run_scrub(args: ScrubArgs) -> Result<()> {
319334
Ok(())
320335
}
321336

337+
/// Everything `drill` needs, gathered in one pass so the rendering is trivial
338+
/// and the query logic is unit-testable against a seeded temp DB.
339+
#[derive(Debug, Clone, PartialEq, Eq)]
340+
pub struct DrillOverview {
341+
/// The configured drill policy, as the engine would actually apply it
342+
/// (clamped + defaulted by `load_drill_config`).
343+
pub config: driven_core::drill::DrillConfig,
344+
/// Recent runs across every source, newest first.
345+
pub runs: Vec<driven_core::state::DrillRunRow>,
346+
/// Display name per source id, so the report can name sources without the
347+
/// caller re-querying. Sources deleted since a run was recorded are absent.
348+
pub source_names: std::collections::HashMap<String, String>,
349+
}
350+
351+
pub async fn gather_drill(repo: &dyn StateRepo, limit: u32) -> Result<DrillOverview> {
352+
let config = driven_core::drill::load_drill_config(repo).await;
353+
let runs = repo.list_drill_runs(None, limit.max(1)).await?;
354+
let source_names = repo
355+
.list_sources()
356+
.await?
357+
.into_iter()
358+
.map(|s| (s.id.to_string(), s.display_name))
359+
.collect();
360+
Ok(DrillOverview {
361+
config,
362+
runs,
363+
source_names,
364+
})
365+
}
366+
367+
pub async fn run_drill(args: DrillArgs) -> Result<()> {
368+
let repo = open_existing(&args.db).await?;
369+
let overview = gather_drill(&repo, args.limit).await?;
370+
371+
let c = &overview.config;
372+
println!(
373+
"restore drill: {} every {}s sample {}",
374+
if c.enabled { "enabled" } else { "DISABLED" },
375+
c.interval_secs,
376+
c.sample_size,
377+
);
378+
379+
if overview.runs.is_empty() {
380+
println!("No restore drill has run yet.");
381+
return Ok(());
382+
}
383+
384+
// Counts and stable SPEC s24 error codes only, never paths - the same rule
385+
// the persisted report follows, so piping this into a log or a bug report
386+
// cannot leak an encrypted source's filenames.
387+
for r in &overview.runs {
388+
let name = overview
389+
.source_names
390+
.get(&r.source_id.to_string())
391+
.map_or("(deleted source)", String::as_str);
392+
println!(
393+
"{} {:<12} {} sampled {} (verified {}, skipped {}, failed {})",
394+
fmt_epoch_ms(Some(r.started_at)),
395+
r.report.outcome.label(),
396+
name,
397+
r.report.sampled,
398+
r.report.verified,
399+
r.report.skipped,
400+
r.report.failed,
401+
);
402+
if !r.report.failure_codes.is_empty() {
403+
let codes = r
404+
.report
405+
.failure_codes
406+
.iter()
407+
.map(|(code, n)| format!("{code} x{n}"))
408+
.collect::<Vec<_>>()
409+
.join(", ");
410+
println!(" failures: {codes}");
411+
}
412+
}
413+
414+
if args.fail_on_failure {
415+
// "Most recent run PER SOURCE", same rule as `scrub --fail-on-drift`: a
416+
// source that failed a month ago and has drilled clean since is not a
417+
// live problem, and reporting it as one would make the check useless as
418+
// a monitor.
419+
//
420+
// Deliberately keyed on `failed`, NOT on the outcome: an INCONCLUSIVE
421+
// run (nothing restorable, or every candidate skipped because a key was
422+
// unavailable) is not evidence of a broken backup, and failing a monitor
423+
// on a locked keychain would train people to ignore it.
424+
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
425+
let mut failed = 0u64;
426+
for r in &overview.runs {
427+
if seen.insert(r.source_id.to_string()) {
428+
failed = failed.saturating_add(r.report.failed);
429+
}
430+
}
431+
if failed > 0 {
432+
anyhow::bail!("{failed} sampled file(s) could not be restored from the backup");
433+
}
434+
}
435+
Ok(())
436+
}
437+
322438
pub async fn run_verify(args: InspectArgs) -> Result<()> {
323439
let repo = open_existing(&args.db).await?;
324440
let rows = gather_status(&repo).await?;

crates/driven-cli/src/main.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,10 @@ enum Command {
8484
/// the local state database. With `--fail-on-drift`, exits non-zero when
8585
/// the latest run of any source found drift it could not repair.
8686
Scrub(inspect::ScrubArgs),
87+
/// Show the restore-drill configuration and recent drill reports from the
88+
/// local state database. With `--fail-on-failure`, exits non-zero when the
89+
/// latest run of any source could not restore a file it sampled.
90+
Drill(inspect::DrillArgs),
8791
/// Restore one backup source to a local directory (no GUI), optionally
8892
/// byte-verifying the result against the original folder. Exits non-zero
8993
/// when any file fails to restore or fails verification.
@@ -179,6 +183,7 @@ async fn main() -> anyhow::Result<()> {
179183
rclone::RcloneCommand::Import(a) => rclone::run_import(a).await,
180184
},
181185
Command::Scrub(args) => inspect::run_scrub(args).await,
186+
Command::Drill(args) => inspect::run_drill(args).await,
182187
Command::Restore(args) => restore::run_restore(args).await,
183188
}
184189
}

crates/driven-cli/tests/cli.rs

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ fn top_level_help_lists_every_subcommand() {
171171
.and(predicate::str::contains("verify"))
172172
.and(predicate::str::contains("rclone"))
173173
.and(predicate::str::contains("scrub"))
174+
.and(predicate::str::contains("drill"))
174175
.and(predicate::str::contains("restore")),
175176
);
176177
}
@@ -305,6 +306,175 @@ fn drifted_report() -> driven_core::scrub::ScrubReport {
305306
r
306307
}
307308

309+
/// Seed `path` with one source and `runs` recorded restore-drill reports.
310+
fn seed_drill_runs(path: &Path, runs: &[driven_core::drill::DrillReport]) {
311+
block_on(async {
312+
let repo = SqliteStateRepo::open(path).await.unwrap();
313+
let acc = AccountId::new_v4();
314+
let src = SourceId::new_v4();
315+
repo.upsert_account(&account(acc)).await.unwrap();
316+
repo.upsert_source(&source(acc, src, "Docs")).await.unwrap();
317+
for (i, report) in runs.iter().enumerate() {
318+
repo.insert_drill_run(&driven_core::state::NewDrillRun {
319+
source_id: src,
320+
started_at: i as i64 + 1,
321+
finished_at: i as i64 + 2,
322+
report: report.clone(),
323+
})
324+
.await
325+
.unwrap();
326+
}
327+
});
328+
}
329+
330+
/// Build a drill report out of `(verified, skipped, failures)`.
331+
fn drill_report(
332+
verified: u64,
333+
skipped: u64,
334+
failures: &[driven_core::types::ErrorCode],
335+
) -> driven_core::drill::DrillReport {
336+
let mut r = driven_core::drill::DrillReport::default();
337+
for _ in 0..verified {
338+
r.record(&driven_core::drill::DrillAttempt::Verified);
339+
}
340+
for _ in 0..skipped {
341+
r.record(&driven_core::drill::DrillAttempt::Skipped {
342+
code: driven_core::types::ErrorCode::CryptoKeyMissing,
343+
});
344+
}
345+
for code in failures {
346+
r.record(&driven_core::drill::DrillAttempt::Failed { code: *code });
347+
}
348+
r.finish();
349+
r
350+
}
351+
352+
#[test]
353+
fn drill_on_a_fresh_database_reports_the_shipped_policy_and_no_runs() {
354+
let dir = tempfile::tempdir().unwrap();
355+
let db = dir.path().join("state.db");
356+
seed_empty(&db);
357+
358+
cli()
359+
.args(["drill", "--db"])
360+
.arg(&db)
361+
.assert()
362+
.success()
363+
.stdout(
364+
// The shipped posture: on, monthly, three files.
365+
predicate::str::contains("restore drill: enabled")
366+
.and(predicate::str::contains("every 2592000s"))
367+
.and(predicate::str::contains("sample 3"))
368+
.and(predicate::str::contains("No restore drill has run yet.")),
369+
);
370+
}
371+
372+
#[test]
373+
fn drill_renders_recorded_runs_with_counts_and_codes_only() {
374+
let dir = tempfile::tempdir().unwrap();
375+
let db = dir.path().join("state.db");
376+
let passed = drill_report(3, 0, &[]);
377+
let failed = drill_report(
378+
1,
379+
1,
380+
&[
381+
driven_core::types::ErrorCode::CryptoDecryptFailed,
382+
driven_core::types::ErrorCode::CryptoDecryptFailed,
383+
],
384+
);
385+
seed_drill_runs(&db, &[passed, failed]);
386+
387+
cli()
388+
.args(["drill", "--db"])
389+
.arg(&db)
390+
.assert()
391+
.success()
392+
.stdout(
393+
predicate::str::contains("passed")
394+
.and(predicate::str::contains("failed"))
395+
.and(predicate::str::contains("verified 1"))
396+
.and(predicate::str::contains("skipped 1"))
397+
// The breakdown is aggregated with a count, and the code is a
398+
// closed-vocabulary dotted string - never a path.
399+
.and(predicate::str::contains("crypto.decrypt_failed x2"))
400+
.and(predicate::str::contains("Docs")),
401+
);
402+
}
403+
404+
/// An INCONCLUSIVE run must not render as a pass. "We restored nothing" reading
405+
/// as "we restored everything successfully" is the exact lie the feature exists
406+
/// to prevent.
407+
#[test]
408+
fn drill_renders_an_inconclusive_run_distinctly_from_a_pass() {
409+
let dir = tempfile::tempdir().unwrap();
410+
let db = dir.path().join("state.db");
411+
seed_drill_runs(&db, &[drill_report(0, 2, &[])]);
412+
413+
cli()
414+
.args(["drill", "--db"])
415+
.arg(&db)
416+
.assert()
417+
.success()
418+
.stdout(
419+
predicate::str::contains("inconclusive").and(predicate::str::contains("verified 0")),
420+
);
421+
}
422+
423+
/// `--fail-on-failure` makes the command usable as a monitoring check.
424+
#[test]
425+
fn drill_fail_on_failure_exits_non_zero_only_when_the_latest_run_still_failed() {
426+
let dir = tempfile::tempdir().unwrap();
427+
let db = dir.path().join("state.db");
428+
seed_drill_runs(
429+
&db,
430+
&[drill_report(
431+
2,
432+
0,
433+
&[driven_core::types::ErrorCode::DriveUnreachable],
434+
)],
435+
);
436+
437+
cli()
438+
.args(["drill", "--fail-on-failure", "--db"])
439+
.arg(&db)
440+
.assert()
441+
.failure()
442+
.stderr(predicate::str::contains("could not be restored"));
443+
444+
// A LATER passing run clears it: a source that failed once and has drilled
445+
// clean since is not a live problem.
446+
let dir2 = tempfile::tempdir().unwrap();
447+
let db2 = dir2.path().join("state.db");
448+
seed_drill_runs(
449+
&db2,
450+
&[
451+
drill_report(2, 0, &[driven_core::types::ErrorCode::DriveUnreachable]),
452+
drill_report(3, 0, &[]),
453+
],
454+
);
455+
cli()
456+
.args(["drill", "--fail-on-failure", "--db"])
457+
.arg(&db2)
458+
.assert()
459+
.success();
460+
}
461+
462+
/// An all-SKIPPED (inconclusive) run must NOT fail the monitor. A locked
463+
/// keychain is not evidence of a broken backup, and failing a monitoring check
464+
/// on it would train operators to ignore the check that matters.
465+
#[test]
466+
fn drill_fail_on_failure_ignores_an_inconclusive_run() {
467+
let dir = tempfile::tempdir().unwrap();
468+
let db = dir.path().join("state.db");
469+
seed_drill_runs(&db, &[drill_report(0, 3, &[])]);
470+
471+
cli()
472+
.args(["drill", "--fail-on-failure", "--db"])
473+
.arg(&db)
474+
.assert()
475+
.success();
476+
}
477+
308478
#[test]
309479
fn version_flag_prints_version() {
310480
cli()

0 commit comments

Comments
 (0)