restverify 0.3.1
restverify[](https://pypi.org/project/restverify/#user-content-restverify)
A backup you haven't restored is a hope, not a backup. restverify restores your restic snapshots and proves the data came back — every night, on exit codes you can automate.
restverify restores a snapshot to a private temp directory, compares it against the original source tree, and delivers a verdict your shell can act on:
| Exit code | Meaning | | --------- | ------------------------------------------------------------------------ | | 0 | restore verified | | 2 | restored data differs from the source | | 1 | the run could not complete (restic failed, no snapshots, restic missing) | | 64 | usage problem — never a verification outcome |
Wire those to cron or CI and a silently degraded backup turns red long before you need it for real.
restverify never creates backups, never runs prune or forget, never writes to your source paths, and never stores or transmits your repository password.
Why[](https://pypi.org/project/restverify/#user-content-why)
`restic check` validates the repository's internal structure. It does not prove a restore works — the restic project tracks this as open issue #2332: “Backups are only real if they're able to be restored.”
The usual answer is a hand-rolled `restore && diff -r` script. It works the day you write it, then decays silently: excludes drift, the diff gets slow, someone comments out the cron line — and the first time it really runs is the day you need the backup.
restverify is that script, done properly: one command, a recorded history, a machine-readable JSON envelope, and a 442-test suite that is verified against real `restic` repositories — including a 1.02 GiB / 1253-file drill over an SFTP backend run as an unprivileged user (transcript).
Quick start[](https://pypi.org/project/restverify/#user-content-quick-start)
python3 -m pip install restverify # or: pipx install restverify
Requires Python 3.11+ and a `restic` binary on PATH. restverify itself has zero runtime dependencies.
one-time: save the repository (and optional source path) to the config
restverify init -r /srv/backup -s /srv/dataverify the newest snapshot
restverify run -r /srv/backup✓ restored snapshot b61d4bc1: 1253 files / 1.02 GiB in 12s; 0 diffs
That's the whole loop. `run` is read-only on the repository and never writes to your source paths. Restores land in a private (`0700`) temp directory that is removed on every exit — if the process is killed mid-restore, the next run sweeps the leftovers. Nothing of the verification is left behind.
Look before you leap:
restverify run -r /srv/backup --dry-run
What a run compares[](https://pypi.org/project/restverify/#user-content-what-a-run-compares)
With a source path configured (`-s` or saved via `init`), both trees are walked with the same excludes and compared on:
- the set of entries (files, directories, symlinks)
- per-file size and kind, and symlink targets
- file/byte/symlink totals
- content digests over a deterministic sample
- .github/actions/restore-drill/ — installs restic, downloads the released restverify wheel pinned to a version, verifies its GPG signature against the release key shipped inside the action, and runs two drills: a healthy fixture must exit 0, and a tampered fixture (one flipped byte) must exit 2.
- templates/ci-drill/ — a ready-made scheduled workflow (point it at your restic repo via secrets) and `drill.sh`, the same drill as a standalone script for plain cron, systemd timers, or any other CI.
- store: `~/.local/state/restverify/history.db` (override: `RESTVERIFY_STATE`)
- retention: newest 1000 rows per repository, pruned on write (the first prune announces itself on stderr)
- read it with `restverify report`, or machine-read it with `restverify report --json`
- browse it in a browser with `restverify dashboard` (localhost, read-only)
- `--dry-run` writes nothing
- restic is invoked read-only only — the verbs that mutate a repository (backup, forget, prune…) are refused at the call site, by construction, and a test pins it.
- One module is permitted to spawn processes; a test pins that too.
- Zero runtime dependencies: the supply chain is Python's standard library and your restic binary.
- Restores go to `0700` private temp dirs; nothing persists; source paths are never written.
- Details and their rationale: docs/SECURITY.md.
- Linux — fully drilled: real restic 0.16.4, real Docker for `--sandbox`, SFTP backend, unprivileged user.
- Windows — the full test suite and the run pipeline work natively (real `restic.exe`, Windows-cut release artefacts). `--sandbox` is the one Linux-only feature.
- restic: read-only verbs only, tested against 0.16.4; any recent restic should behave identically. If yours doesn't, that's a bug — please open an issue with the transcript.
- Not a backup tool. It never calls `backup`, `forget`, or `prune`; it can't lose your data because it never deletes anything.
- Not an orchestrator. It doesn't schedule your backups, manage retention, or replace resticprofile / your systemd units / your scripts. It sits underneath all of them and answers the question they don't: can this backup actually be restored?
- No cloud, no dashboards, no alerting service. Exit codes and JSON are the whole integration surface — anything that can run a command can consume a verdict.
The sample, precisely (it is deterministic, so any run can be reproduced): every file if there are ≤ 200, otherwise a stride of `ceil(total / 200)` in sorted-path order; the largest file is always included, plus up to 50 files matching your exclude patterns. Each sampled file is hashed with SHA-256, and the run digest is the SHA-256 over the serialised `path ␀ size ␀ file-sha256`lines in sorted-path order. The exact sampled list is in every `--json`response, so you can re-verify any claim independently.
Not compared, on purpose: mtimes and ordering — they never fail a run. Reported as warnings, promoted to exit 2 by `--strict`: an empty directory present on only one side, and file mode/ownership drift.
Symlinks are recorded with their target and never followed (following one could read outside the restore target). A path that was a file and came back as a symlink is an error, not a silent read.
Scope note: without a source comparison (`--no-source`), a run proves the restore completed — not that the data matches. The comparison is what makes it verification; bring a source path when it matters.
For machines: `--json`[](https://pypi.org/project/restverify/#user-content-for-machines---json)
restverify run -r /srv/backup --json > result.json
stdout carries exactly one JSON object and nothing else, in every mode — success, dry-run, and every failure — so the redirect is always valid JSON. Teaching text and errors go to stderr. The envelope is versioned (`"schema": 1`); a breaking change bumps the schema rather than editing it quietly. Errors carry a stable `kind` taxonomy (`restic_missing`, `no_snapshots`, `restic_failed`, `source`, …) with a human `what` and a `hint`.
The exit codes and the envelope are the entire integration surface: there is nothing to scrape and nothing that changes under you between patch releases.
Deliver the verdict: `--report-webhook`[](https://pypi.org/project/restverify/#user-content-deliver-the-verdict---report-webhook)
restverify run -r /srv/backup --json \ --report-webhook https://collector.example/hooks/restverify
After the run — pass, mismatch, or error — the exact JSON envelope is POSTed to the URL (`Content-Type: application/json`, user agent `restverify/<version>`), so a collector or pager sees the same object the operator would have seen on stdout.
Delivery is best-effort by contract: a failure is one line on stderr and never changes the exit code — the verdict is the verification's, not the webhook's. Validation happens before anything runs (`https://` only, timeout `0 < t <= 60` seconds, default 10; anything else is exit 64 and no network attempt). Redirects are refused, credentials in the URL are never transmitted or logged, and there are no retries — one attempt, one line of truth.
Prove a snapshot: `prove`[](https://pypi.org/project/restverify/#user-content-prove-a-snapshot-prove)
restverify prove -r /srv/backup # sample 10% of the newest snapshot restverify prove -r /srv/backup --sample 100 # every file, plus a full seal check restverify prove -r /srv/backup --seed 7 # a different deterministic sample
`prove` restores a deterministic sample of the snapshot's files and verifies each one against restic's own `ls --json` record — and it adds a second layer on top: `restic check --read-data-subset N%` over the same share of the repository's data packs (`--sample 100` becomes a full `--read-data`), which verifies the repository's cryptographic seals by reading the data back.
Per-file limits, stated plainly: `restic ls --json` exposes no content hashes (measured on restic 0.16.4), so the per-file claim is existence + size only — `hashes_available` is `false` in the envelope. The content half is covered by the seal check: with one flipped byte in a real pack, `restic restore` exits 0 writing a 0-byte file (the size check catches that one) while `check` reports the corruption — `prove` catches both.
Sampling is reproducible: same file list, same percent, same seed → same sample. The default seed derives from the snapshot id, so re-running prove on an unchanged snapshot re-checks the same files. The largest file is always included — the likeliest corruption canary is never left to chance.
Exit codes are the standing contract: 0 all verified, 2 at least one sampled file missing or size-differs or the seal check failed, 1 restic failures (including a locked repository — that is could-not-complete, not corruption), 64 usage.
Browse the history: `dashboard`[](https://pypi.org/project/restverify/#user-content-browse-the-history-dashboard)
restverify dashboard # prints a http://127.0.0.1:PORT/ URL restverify dashboard --port 8765 # a fixed port instead of a random one
One stdlib web page, read-only end to end: the newest 50 runs (status, exit code, repo, snapshot, duration) plus a pass/mismatch/error summary. The history database is opened with SQLite `mode=ro` — never created, never written. No framework, no JavaScript, no form, no external resources.
Built-in defenses, each pinned by a test: binds `127.0.0.1` (a random free port unless `--port`); `--bind-all` is refused (exit 64, before any socket exists) unless paired with `--yes-i-know`; the `Host` header must name the bound address, so a DNS-rebinding page cannot aim a browser at it; every database value is HTML-escaped; responses carry `CSP: default-src 'none'; style-src 'unsafe-inline'`, `nosniff`, and `Cache-Control: no-store`; GET and HEAD on `/` only — other paths 404, other methods 405; request logs never repeat query strings. Ctrl-C exits cleanly.
On a schedule[](https://pypi.org/project/restverify/#user-content-on-a-schedule)
restverify cron -r /srv/backup # one ready-to-paste crontab line restverify cron -r /srv/backup --systemd # a .service + .timer pair instead
`cron` prints and never installs: no crontab is modified, `systemctl` is never called, nothing is written into any unit directory. You stay in control of your own scheduler.
One note for scheduled runs: a scheduler runs with a minimal environment, so set `RESTIC_PASSWORD_COMMAND` (or `RESTIC_PASSWORD_FILE`) in the unit or crontab itself. restverify never stores your password.
Sandboxed restores[](https://pypi.org/project/restverify/#user-content-sandboxed-restores)
restverify run -r /srv/backup --sandbox
The restore executes inside a disposable container instead of directly on the host: your own `restic` binary is copied in, your temp dir is bind-mounted, and the process runs as your uid — restored files are owned by you, never root. Local repositories mount read-only, and the container has no network. Remote repos (`sftp://`, `rclone:`) resolve inside the container with your own credentials; a `password_command` that reads a host file works via `RESTVERIFY_SANDBOX_PASSFILE` (mounted read-only).
The container is purged on every exit — including `kill -9` mid-restore; containers carry an ownership label, and any orphan from a killed run is swept automatically by the next run. Requires `docker` (or a compatible runtime) on PATH with socket access for your user — never sudo. The gate was drilled against real Docker: a clean sandboxed run, a SIGKILL mid-restore, and proof the next run swept the orphan and finished with zero leftovers (transcript).
Verification, history and exit codes are identical with or without `--sandbox`.
The nightly CI drill[](https://pypi.org/project/restverify/#user-content-the-nightly-ci-drill)
The restore drill ships as a reusable GitHub Action in this repository, so your backup repo can drill itself on a schedule:
A drill that can't fail is decoration: the tampered-fixture leg exists so a nightly run that "succeeds" at everything proves nothing. Copy the template, add your secrets, and a degraded backup turns the job red before you ever need it for real. The drill runs the same outside GitHub as a plain script — no GitHub required.
Signed releases[](https://pypi.org/project/restverify/#user-content-signed-releases)
Every release is signed; verification needs no keyserver:
verify the tag
git tag -v v0.3.0verify a wheel/sdist from PyPI against the in-repo release key
curl -O https://files.pythonhosted.org/packages/<path>/restverify-0.3.0-py3-none-any.whl curl -O https://files.pythonhosted.org/packages/<path>/restverify-0.3.0-py3-none-any.whl.asc gpg --import docs/release-key.asc gpg --verify restverify-0.3.0-py3-none-any.whl.asc restverify-0.3.0-py3-none-any.whlRelease key: RSA3072, fingerprint `C5F735E977D4D45C1663AA40E4CE56B6CEF87373`, identity restverify release signing; the armored public key ships at docs/release-key.asc. Recorded sha256 digests for the current release and its validation transcript are in docs/RELEASE-VALIDATION-0.3.0.md; the 0.2.1 upgrade drill (exit-code matrix, artefact digests) remains at docs/RELEASE-VALIDATION-0.2.1.md.
History and trends[](https://pypi.org/project/restverify/#user-content-history-and-trends)
Every verification writes one row to a local SQLite store — failures included, because a trend that hides failures is worse than no trend.
Moving the whole layout (config + state) for CI or drills: `RESTVERIFY_HOME=/some/home`. Explicit `RESTVERIFY_CONFIG` / `RESTVERIFY_STATE`still win.
Security posture[](https://pypi.org/project/restverify/#user-content-security-posture)
Platform notes[](https://pypi.org/project/restverify/#user-content-platform-notes)
What restverify is not[](https://pypi.org/project/restverify/#user-content-what-restverify-is-not)
Documentation[](https://pypi.org/project/restverify/#user-content-documentation)
| Doc | Contents | | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | | CHANGELOG.md | every release, honestly annotated | | docs/SECURITY.md | security posture and its rationale | | docs/RELEASE.md | how releases are cut and signed | | docs/RELEASE-VALIDATION-0.2.1.md | upgrade drill, exit-code matrix, release digests | | docs/I9-DRILL.md | the 1 GiB / 1253-file scale drill | | docs/I10-CI.md | the CI drill, rehearsed | | docs/I11-SANDBOX.md | the sandbox gate |
Status[](https://pypi.org/project/restverify/#user-content-status)
v0.3.0 is the current release — live on PyPI. It adds `prove` (source-less snapshot verification with a cryptographic seal check), `dashboard` (the local read-only history page), and `run --report-webhook` (deliver the verdict to a collector). Every command speaks `--json` with the same schema-1 envelope.
Release files for restverify 0.3.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| Source distribution for restverify 0.3.1 | File | Size | Uploaded | | | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------------ | ----------------------------------------------------------------------- | | | restverify-0.3.1.tar.gz | 179.6 kB | Sep 30, 2026 | Details | |
---
Built distribution (wheel)
| Table of built distributions (wheels) for restverify 0.3.1 | File | Interpreter | ABI | Platform | Reset | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ----------- | --- | --------------------------------------------------------------------------------- | --------------------------------------------------- | | restverify-0.3.1-py3-none-any.whl 81.7 kB Sep 30, 2026 | Python 3 | none | any | Details | |
---
Total release size: 261.3 kB
Release files/ restverify-0.3.1.tar.gz
| Download URL | restverify-0.3.1.tar.gz | | ------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Size | 179.6 kB | | Tags | Source | | SHA-256 checksum How to use checksums | 459e83a67a5f4025f665c825dc3567121c3eeadb454e39c09d4a982d745a2624 | | BLAKE2b-256 checksum How to use checksums | e705dea22f7f02e76ea80b1018e4922897f63c71708e862aaca3249f8d050225 | | Upload date | Sep 30, 2026 | | Uploaded using Trusted Publishing? What is trusted publishing? | No | | Uploaded via | twine/7.0.0 CPython/3.11.15 |
Release files/ restverify-0.3.1-py3-none-any.whl
| Download URL | restverify-0.3.1-py3-none-any.whl | | ------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Size | 81.7 kB | | Tags | Python 3 | | SHA-256 checksum How to use checksums | 40f75078ab8198bd70ab58cc171eb7eb60ab3b3060b27b23b44afac96a424dfa | | BLAKE2b-256 checksum How to use checksums | 6b9a64cb18aea2f4ad01f7c4398798d694e11df17934b49df4d9413a6a7de98f | | Upload date | Sep 30, 2026 | | Uploaded using Trusted Publishing? What is trusted publishing? | No | | Uploaded via | twine/7.0.0 CPython/3.11.15 |
Release history Release notifications | RSS feed
This release
0.3.1 This release
Sep 30, 2026 2 release files
Sep 30, 2026 2 release files
Sep 29, 2026 2 release files
Sep 29, 2026 2 release files