A backup you have never restored is a rumour.
Backup software reports that it ran. That is a different claim from the data
can come back. restoreprobe answers the second one the only way it can be
answered: it pulls files out of the backup for real and compares the bytes with
the tree they were supposed to protect.
Everything below is something a green backup log will happily tell you is fine.
pip install -e . # from a clonePython 3.10+. No dependencies.
restoreprobe check --source /srv/data --backup /mnt/backups/nightly.zipDirectories, .zip and .tar[.gz|.bz2|.xz] are supported. By default it
restores a seeded sample of 50 files; --full restores everything.
The repository ships a demo whose backup is sabotaged in three quiet ways — each one the kind that a backup log calls a success. Build it and probe it:
$ python examples/make_demo.py
$ restoreprobe check --source examples/demo/live --backup examples/demo/nightly.zip --full --quiet
source examples\demo\live 9 files
backup examples\demo\nightly.zip zip, 6 entries
newest 2026-08-27 03:44 UTC (0 days old)
Restored every file and compared the bytes (2.0 KB in 0.1s, seed 0)
1 × restored, but different from the live file
db/dump.sql restored 1880 bytes, the live file has 1720
1 × restored as an empty file
notes/todo.md restored 0 bytes, the live file has 59
3 × never backed up
photos/beach.jpg not present in the backup
photos/city.jpg not present in the backup
photos/family.jpg not present in the backup
Where the gaps are:
photos/ 3/3 missing ENTIRE FOLDER
coverage 66.7 % · 4 of 6 sampled files restored identically
NOT RESTORABLE — 5 defect(s) above.Three different disasters, none of which shows up in an archive listing:
| What the listing says | What the restore says |
|---|---|
photos/ isn't mentioned |
an exclude rule swallowed a whole folder |
notes/todo.md — 0 bytes, fine |
the file came back empty |
db/dump.sql — present, 1880 bytes |
those are yesterday's bytes |
| Finding | Meaning |
|---|---|
missing |
in the live tree, absent from the backup |
unrestorable |
listed by the backup, and the data will not come out — bit rot, a broken stream |
mismatch |
restored, and the bytes are not the same |
truncated |
restored as zero bytes while the live file is not empty |
unsafe-path |
an entry that would be written outside the restore directory |
extra |
in the backup, not in the live tree — reported, never a defect |
Plus staleness (--max-age DAYS), because a perfect backup of a month ago
is still a month of lost work.
An archive is untrusted input, even one you made yourself. An entry named
../../etc/cron.d/payload writes outside wherever you extract it — the classic
Zip-Slip. restoreprobe refuses to extract such an entry, reports it, and
fails the probe. A tool that says "let me restore this for you" must not be the
thing that hands an attacker a write primitive.
| Flag | Meaning |
|---|---|
--source DIR |
the live tree the backup is supposed to protect |
--backup PATH |
directory, .zip, or .tar[.gz|.bz2|.xz] |
--sample N |
how many files to actually restore (default 50) |
--full |
restore and compare every file — the only real proof |
--seed N |
reproduce an earlier probe exactly |
--max-age DAYS |
fail if the newest file in the backup is older than this |
--exclude GLOB |
skip these paths in the source; repeatable |
--receipt PATH |
write a dated JSON record of the probe |
--show-extra |
also list entries that exist only in the backup |
restoreprobe inspect --backup PATH describes a backup on its own, with no
source to compare against.
| Code | Meaning |
|---|---|
0 |
restorable — every sampled file came back byte-identical |
1 |
defects found, or the backup is stale |
2 |
misuse |
3 |
the backup could not be opened at all |
restoreprobe check \
--source /srv/data \
--backup /mnt/backups/nightly.tar.gz \
--sample 100 --seed "$(date +%j)" \
--max-age 2 \
--receipt "/var/log/restoreprobe/$(date +%F).json" \
|| mail -s "backup probe FAILED" ops@example.comA different seed each day walks a different hundred files, so a month of cheap probes covers far more of the tree than one expensive one. The receipts pile up as dated, hash-bearing evidence that somebody actually looked — and because the seed is in each one, anybody can re-run exactly that probe.
Sampling limits what is restored, never what is counted. Coverage — is a file in the backup at all? — is answered from the listing, for every file, on every run. So a cheap nightly probe that restores 50 files still notices that an entire folder has vanished. Only the byte-for-byte comparison is sampled.
The sample is a shuffle, then a cut. Taking "the first 50" alphabetically would probe the same corner of the tree every single night and never touch the rest. It is seeded so a probe is reproducible.
Sampling is never dressed up as proof. A sampled run says so in the output:
evidence about the sample, not a proof about the whole backup. Only --full
drops that line.
Symlinks are skipped, not followed. Following them compares the same bytes twice, and with a loop it never finishes. More importantly, a backup that stored the link and one that stored the target are different backups, and silently equating them would hide the difference.
Zip archives store MS-DOS timestamps in the local time of the machine that
wrote them, with no zone attached. Reading them as UTC — as the first version
did — dates every archive written east of Greenwich into the future, and a
backup dated in the future can never be reported as stale. The age check would
have been silently dead for most of the world. It has a regression test now:
test_zip_timestamps_are_read_as_local_time_not_utc.
- It verifies data, not your restore procedure. Byte-identical files are necessary and not sufficient: permissions, ownership, extended attributes, hard links, sparseness and database consistency are all out of scope. A green probe means the bytes are there.
- It compares against the live tree as it is right now. A file legitimately
edited since the backup ran will show as a
mismatch. That is honest, not a false positive — but on a busy tree, expect it. - Encrypted and deduplicating backups need their own tooling. For
restic, borg or
kopia, use their
check --read-data, which knows how to verify their internal structure.restoreprobeis for the enormous number of backups that are, in the end, a folder or an archive. - A
--samplerun is evidence, not proof. It says so itself.
MIT — see LICENSE.