Author SHA1 Message Date
clawbot 6e4e8062a1 Fix the ten findings of the review on the destination, the marker file and chunk sizes
check / check (push) Successful in 9m14s
README.md: the "Getting started" destination advice, the Full Disk Access
paragraph moved above the block with the reopen step, the mount-point
case, the remote nuke failure sentence, the hostname setting, the upload
order under "Sharing a destination", the confirmation list and the dev
stamp sentence. configs/config.example.yml: the storage_url and
blob_size_limit comments. docs/ARCHITECTURE.md and docs/DATAMODEL.md:
the Snapshot ID and Marker file terms, and "marker file" in place of
"destination marker".

Model: fable-5-1
2026-11-11 09:11:51 +00:00
clawbot a8b9c57e93 Fix the six findings of the review on config set, version and blob names
check / check (push) Successful in 11m16s
README.md: config set keeps comments and can remove blank lines; a
config that does not load yet also has a value that is not a size
refused; vaultik version reports a tag and adds a development-build
note to every other version. docs/REPOSTRUCTURE.md and README.md:
someone who has a blob's whole contents can compute its name and see
whether it is stored. docs/DATAMODEL.md: snapshot list shows an
unfinished snapshot as remote only once its manifest is at the
destination; closing a full blob commits the first chunk of the next
one. internal/cli/config.go: config set --help says the same about
comments and blank lines.

Model: fable-5-1
2026-11-11 08:28:40 +00:00
clawbot f65fd2870b Fix three review findings in help text, example config and terms
check / check (push) Successful in 12m37s
The snapshot restore help splits its 40-word permissions sentence in
two. The snapshot-ID case leaves the parenthesis, and the temporary
space requirement gets its own sentence.

configs/config.example.yml gains a commented-out env entry, after
hostname, so its first line, which says the file shows every option,
is true again.

docs/REPOSTRUCTURE.md defines remote key in Terms, before the
marker-file section uses it.

Model: fable-5-1
2026-11-11 07:55:31 +00:00
clawbot 2bf51d65e3 Fix nine review findings in the documents and help text
check / check (push) Failing after 19m7s
- snapshot verify and snapshot restore also list the destination for a
  snapshot ID that is not in the local index: README, REPOSTRUCTURE,
  ARCHITECTURE, DATAMODEL and the two commands' help.
- prune on a separate machine deletes only unused data and does not
  remove old snapshots.
- The --quiet usage string names everything the flag leaves out.
- A warning written as a log record stays on stderr.
- config set on a config that does not load refuses three things.
- No test is held back by a build tag.
- A plain docker build . adds no -dirty.
- The backup's order of work records directories.
- TODO.md says what parallel downloads would speed up and names
  restorePlan and restoreSweeper.

Model: fable-5-1
2026-11-11 07:11:05 +00:00
sneak 1fcdfcc1c6 Fix the three review findings on README.md
Lists `env` under Settings, so the sentences that say every key names a
setting are true. Says how `--skip-errors` reports a file whose blob
cannot be read. Counts the commands that refuse a changed destination
as five, instead of pointing at the wrong list with "these".

Model: fable-5-1
2026-11-11 07:01:54 +00:00
sneak a0efb2a5bc Update the rewritten documents for the fixes merged into next
The documents, the example config and the help text are rebased onto the
fourteen fixes on next. The sentences those fixes made false now match the
code: an unknown config key, a relative index_path and a value that is not a
size are refused when the config loads; a backup saves the destination in the
local index only after it writes the marker file, and no other command saves
it; a backup skips the local index, its side files and the lock file; a binary
from go install reports its tag and counts as a release; a declined deletion
prompt exits 1 with "deletion not confirmed"; the S3 keys are set before an
s3:// storage_url. No document quoted the old messages for an unknown snapshot
ID, a wrong private key or an unreadable blob.

Model: fable-5-1
2026-11-11 07:01:54 +00:00
sneak da300fc91c Reword the bootstrap entry and note the macOS paths
README.md, "Entrypoints": the `script/bootstrap` item now says in two
sentences that Go is installed when the machine has none, and that
prettier is installed for the Markdown, with node and yarn when they
are missing.

README.md, the paragraph after the getting-started block: it now says
the block uses macOS paths and that another system puts the destination
in a directory of its own.

Model: fable-5-1
2026-11-11 07:01:54 +00:00
sneak 6107f60e55 Update README.md for the four fixes merged into next
`config init` writes the apps snapshot, which backs up /Applications,
only on macOS, so the advice to edit the snapshots section first on
another system goes. `version` now labels the commit date "commit date".
`script/fmt` formats the Markdown with prettier as well as the Go code,
`script/fmt-check` checks both, and `script/bootstrap` installs node and
yarn for prettier. No document said that `snapshot verify` or
`remote info` printed outside `internal/ui`, so nothing changes for that
fix.

Model: fable-5-1
2026-11-11 07:01:54 +00:00
sneak 718c10da10 Point links at the moved documents and config example
ARCHITECTURE.md, TODO.md and config.example.yml moved to docs/ and
configs/ on next. README.md now links to the new paths. docs/ARCHITECTURE.md
and the roadmap in docs/TODO.md link to their sibling documents by file
name, as docs/DATAMODEL.md and docs/REPOSTRUCTURE.md already do. The help
text and configs/config.example.yml name none of the old paths.

Model: fable-5-1
2026-11-11 07:01:54 +00:00
sneak 179af61c68 Format the rewritten documents with prettier
Re-wraps README.md, docs/ARCHITECTURE.md, docs/DATAMODEL.md,
docs/REPOSTRUCTURE.md and the roadmap in docs/TODO.md with `make fmt`,
which now runs prettier on Markdown. Only line wrapping changes; the
words are those that passed review at 4da102c.

Model: fable-5-1
2026-11-11 07:01:54 +00:00
sneak 1128b85653 Put the main rule before the exceptions in "What every command needs"
The README section opened with the commands that need nothing and the
config commands, and reached the requirement that covers most commands
third. It now states that rule first, then lists the commands that
need less. The facts are unchanged; only the order is.

Model: fable-5-1
2026-11-11 07:01:54 +00:00
sneak 7c0a6d90c1 Add snapshot verify and restore to the commands a missing marker stops
Given a remote key, `snapshot verify` and `snapshot restore` resolve it
through `listAllRemoteSnapshotKeys`, which checks the destination marker,
so they fail when the marker is missing. Given a snapshot ID they do not
check it. Add them to the "These commands fail" list in `README.md` and
`docs/REPOSTRUCTURE.md`.

Model: fable-5-1
2026-11-11 07:01:54 +00:00
clawbot d335e19521 Say that a symlink is recorded, not read
A new or changed symlink goes through recordNonRegularFile in the
scanner and is never opened. README.md, ARCHITECTURE.md and
docs/DATAMODEL.md now say so where they describe what a backup reads,
and README.md no longer lists a changed link target as a reason to
read a file.

Model: fable-5-1
2026-11-11 07:01:54 +00:00
sneak 7743ec621e Define rclone's remote where it is first used
The documents use "remote key" for something else, so "remote" as
rclone's noun is now defined on first use: a remote is a storage service
set up in rclone. Changed in `README.md` (the destination bullet),
`docs/REPOSTRUCTURE.md` (the opening) and `config.example.yml` (the
`storage_url` comment).

Model: fable-5-1
2026-11-11 07:01:54 +00:00
clawbot ef34926327 Say what a backup needs on an rclone remote and fix four more findings
- `README.md`, `docs/REPOSTRUCTURE.md`, `internal/cli/snapshot.go`: a
  backup needs only write permission on S3; an rclone remote that can
  move objects also needs read and delete, and an upload there stops
  when its look-up is refused.
- `docs/DATAMODEL.md`, `docs/REPOSTRUCTURE.md`: a restore reads no
  file, chunk or blob rows from the local index, but it does open it.
- `README.md`: confirmation prompts go to stdout.
- `internal/cli/remote.go`: `remote nuke` leaves the backup destination
  store empty, not a bucket prefix.
- `README.md`: a TODO section, as `REPO_POLICIES.md` requires.

Model: fable-5-1
2026-11-11 07:01:54 +00:00
clawbot 92928c86d4 Read all five documents against the code and fix what the sixth review found
Fixes the four findings of the sixth review: `snapshot remove` with
`--local-only`, the two comma-chained permission lists, six undefined terms,
and the sentence about an unfinished snapshot in the local index.

Then reads every sentence of `README.md`, `ARCHITECTURE.md`,
`docs/DATAMODEL.md`, `docs/REPOSTRUCTURE.md` and the comments in
`config.example.yml` against the code for four faults: a command's needs or
behaviour stated without regard to a flag, a false absolute, three or more
things chained with commas, and a technical word not defined on first use.
Each sentence changed is listed on the pull request.

The help string of `snapshot remove --dry-run` now says it needs no
permission to delete.

Model: fable-5-1
2026-11-11 07:01:54 +00:00
clawbot 864e9a76f4 Fix five README.md statements from the fifth review
Under `--json`, `snapshot list` prints no warning for a snapshot with
no manifest at the destination; the README now says so and points to
`remote_present`. The stdout list names `snapshot create` as well as
`snapshot restore` for the per-file error lines. `snapshot create`
stops "before it writes anything at the destination", since the
destination binding is saved first. `prune_grace_period` is a Go
duration, so the README now says only which units it rejects. The
commands that run while a backup is in progress are a bulleted list.

Model: fable-5-1
2026-11-11 07:01:54 +00:00
clawbot 2662cf44be Correct README.md on where warnings go and on which make targets use Docker and script/
Warnings and the per-file errors of `snapshot restore` are written to stdout
through `internal/ui`, so the stdout list names them and the redirect example
says a file captures them. Only `make test` and `make lint` run inside Docker;
`make test-coverage` runs on the host. Some `Makefile` targets (`build`,
`clean`, `deps`, `test-coverage`, `local`, `install`) call no `script/` entry,
so "Most" replaces "only".

Fixes the two findings of the fourth review of
#399.

Model: fable-5-1
2026-11-11 07:01:54 +00:00
sneak 72ea483312 Say which commands need the private key, in README.md and config.example.yml
The last sentence of "The private key" and the matching comment in
config.example.yml named only the restore machine. snapshot verify --deep
needs the key too. Both now name both commands. The "Everyday use" block
gains a comment saying its next three commands run on a machine that holds
the private key.

Model: fable-5-1
2026-11-11 07:01:54 +00:00
sneak 49af986fd1 Fix the four findings of the second review
README.md: the opening no longer says the private key is needed only to
restore; a backup never needs it, a deep verify does.

ARCHITECTURE.md: the concurrency note says each open database uses one
connection, since the metadata export opens a second one.

docs/REPOSTRUCTURE.md: the size leak is about a secret near the
outsider's data in the same blob, not the same chunk, because all new
chunks of a blob share one zstd stream.

Every path used as link text in the four documents is in backticks.

Model: fable-5-1
2026-11-11 07:01:54 +00:00
sneak 281033239e Fix other sentences that carry faults the review named
A search of the four documents, the help text and config.example.yml
for the review's faults found these.

- prune help, and snapshot purge and snapshot list in README.md: a
  snapshot is dropped or reported when its manifest is gone, not its
  metadata or directory.
- README.md, version: the date printed is the commit date.
- README.md, Configuration: names the commands that start without a
  config that loads.
- README.md, snapshot purge: "each such snapshot" named nothing.
- README.md and docs/DATAMODEL.md: a backup can upload unchanged
  content again, so "uploads only what changed" was false.
- docs/REPOSTRUCTURE.md: db.zst.age holds more than one snapshot's
  rows.
- ARCHITECTURE.md: "database records" read as a noun.

Model: fable-5-1
2026-11-11 07:01:54 +00:00
sneak fb58b9cf21 Fix three sentences that kept faults the review named
The review of the rewrite named these faults in other files. The same
sentences were still in two documents.

- docs/REPOSTRUCTURE.md, "The marker file": "index records" read as a
  noun. It now says the next backup would not upload data that the
  local index says is stored.
- docs/REPOSTRUCTURE.md, "Chunk boundaries use no secret": a chunk is
  not always between 2.5 MB and 40 MB. Only a file's last chunk can be
  smaller than a quarter of chunk_size.
- ARCHITECTURE.md, "Snapshot metadata": the export does not delete
  everything outside the snapshot. It now says what is deleted, and
  that local_meta and schema_migrations are kept.

Model: fable-5-1
2026-11-11 07:01:54 +00:00
sneak 536ee8bb75 Fix the review findings in --help and config.example.yml
snapshot list says why a remote-only snapshot is listed by its remote
key. snapshot verify says what access to the destination it needs. The
note on the age-keygen file is two sentences in snapshot verify and
snapshot restore. remote nuke says "backup destination store".

config.example.yml names the commands that run without a snapshot.

Only help strings and comments change.

Model: fable-5-1
2026-11-11 07:01:54 +00:00
sneak a592a62672 Move the roadmap into TODO.md and fix a test comment
The rewrite removed the README's roadmap, which TODO.md linked to. The
roadmap items are now under Future Steps in TODO.md, in place of the
link.

A comment in internal/storage/s3_test.go cited the README for the
destination layout. That layout is in docs/REPOSTRUCTURE.md.

Model: fable-5-1
2026-11-11 07:01:54 +00:00
sneak 86c8d30def Fix the review findings in the three design documents
ARCHITECTURE.md: the chunk size limits, the commit date, the cases in
which a backup skips its prune, and two sentences that read badly.

docs/DATAMODEL.md: which repository type serves which table, what the
metadata export deletes and keeps, the blobs table in a restore, and
when a chunk row is deleted.

docs/REPOSTRUCTURE.md: how an object's size relates to its contents,
what blob sizes can leak and when, and a sentence moved out of the
prune rules.

All three now say that prune drops a local snapshot whose manifest is
gone, not whose directory is gone.

Model: fable-5-1
2026-11-11 07:01:54 +00:00
sneak c1266f3988 Fix the review findings in README.md
The first sentence now names the licence, the kind of program, the
language and the author. The README gains a Design section, and its
sections carry the names REPO_POLICIES.md asks for: Rationale and
Getting started, which now holds the install command. Entrypoints opens
with the Scripts to Rule Them All statement.

Corrected: when a file is read again, the chunk size limits, and the
first prune step, which looks for a snapshot's manifest. Two sentences
that misparsed are reworded.

A test comment that named the README quickstart follows the rename.

Model: fable-5-1
2026-11-11 07:01:54 +00:00
sneak 6a2e2aaecc Reword README.md sentences that read badly on a second pass
Split sentences that carried two or three ideas, turned the cases in
which a backup does not find a mount point into a list, and named the
figures that remote info reports as unknown.

Model: fable-5-1
2026-11-11 07:01:54 +00:00
sneak 871cef1284 Say what each command needs in --help and config.example.yml
snapshot verify --help now says --deep needs the age private key, from
VAULTIK_AGE_SECRET_KEY or age_secret_key, as snapshot restore does.
Both now name the config key as well as the environment variable.

The help of snapshot create, list, purge, remove, prune, remote info
and remote nuke says which access to the destination it needs.
config.example.yml gains age_secret_key, and no longer tells the reader
to pipe age-keygen into grep, which discards the private key.

Only help strings and comments change.

Model: fable-5-1
2026-11-11 07:01:54 +00:00
sneak f016cf54ac Turn a comma chain in docs/REPOSTRUCTURE.md into a list
Model: fable-5-1
2026-11-11 07:01:54 +00:00
sneak c8ad289e46 Rewrite docs/REPOSTRUCTURE.md sentence by sentence
Rewritten from scratch against the code on next: the layout of the
destination, what it protects, the accepted risks, the order of writes
and the rules that keep prune from deleting data in use.

Corrected: a restore needs only db.zst.age and the blobs it reads, not
the manifest. The snapshot ID form without a name is gone, since no
command creates one.

Model: fable-5-1
2026-11-11 07:01:54 +00:00
sneak 1d743e2255 Turn two comma chains in ARCHITECTURE.md into lists
Model: fable-5-1
2026-11-11 07:01:54 +00:00
sneak 137ef59cae Rewrite docs/DATAMODEL.md sentence by sentence
Rewritten from scratch against internal/database on next. Every table
and column in schema/001.sql is listed, with the indexes.

Added what the old text lacked: files.source_path, the local_meta and
schema_migrations tables, and five indexes. Corrected what it had
wrong: blobs.blob_hash holds a placeholder, not NULL, until the blob is
closed; blob_chunks rows are written when the blob is closed; queries
are parameterised, not prepared.

Model: fable-5-1
2026-11-11 07:01:54 +00:00
sneak 5afcd4f6b5 Rewrite ARCHITECTURE.md sentence by sentence
Rewritten from scratch against the code on next: the packages, the fx
wiring, and the steps of a backup, a restore, a verify and a prune.

Names that no longer exist in the code are gone: the S3 client as the
upload step, blob.BlobWithReader, PurgeSnapshots and the repository
interfaces. The storage layer is described through storage.Storer.

Model: fable-5-1
2026-11-11 07:01:54 +00:00
sneak 8058b775d6 Rewrite README.md sentence by sentence
Rewritten from scratch against the code on next: what the program is,
how to use it, a command reference that says what each command needs,
the configuration reference as nested bullets, then development and
releasing.

snapshot verify --deep now says it needs the private key, from
VAULTIK_AGE_SECRET_KEY or age_secret_key, as snapshot restore does.

The roadmap section is removed: it described features that are not
built.

Model: fable-5-1
2026-11-11 07:01:54 +00:00
clawbot 6a8bb2f389 Print a failed command's error once on stderr (closes #433)
check / check (push) Failing after 9m31s
A failed command printed its error twice on stderr: as an ERROR log record and as the final ERROR: line the README documents. The command handlers, a restore failure that ends the command and a failed blob upload now log that error at info level or not at all, so without --verbose or --debug only the final line appears.

Model: opus-5-5
2026-11-11 07:38:11 +01:00
clawbot 5f1f8c4f66 Tell the user to set the S3 keys before an s3:// storage_url (closes #428)
check / check (push) Successful in 12m48s
config set storage_url to an s3:// URL was refused, without saying why, when s3.access_key_id and s3.secret_access_key were not yet set. The refusal now names both keys and says to set them first. config set still refuses any change that leaves the config unable to load, and clearing a key gets the plain refusal.

Model: opus-5-5
2026-11-11 06:48:46 +01:00
clawbot e8ab13b526 Name the setting and value when a size setting does not parse (closes #429)
check / check (push) Failing after 19m36s
A non-numeric chunk_size or blob_size_limit failed with an error that named neither the setting nor the value. Every size setting now fails at load with its name and the quoted value, for example chunk_size: "banana" is not a size; use a number of bytes or a unit such as 10MB. A value too large for a size says so, with the setting name.

Model: opus-5-5
2026-11-11 06:42:45 +01:00
clawbot f6df500ba2 Refuse a relative index_path (closes #430)
check / check (push) Successful in 10m28s
A relative index_path, or VAULTIK_INDEX_PATH, was accepted and resolved against whatever directory vaultik ran in. Loading the config now refuses a relative or empty value, quoting it, with the same wording as the file:// check; ~ is still expanded first.

Model: opus-5-5
2026-11-11 06:06:54 +01:00
clawbot 7aae6e4ff3 Skip vaultik's own index and PID file in a backup (closes #421)
check / check (push) Successful in 10m54s
The default config backs up the home directory, which holds vaultik's local index and PID file, so a backup stored a torn copy of the index it was writing. The scan now skips the index, its SQLite side files and the PID file whatever the config says, including when index_path or its directory is a symlink. database purge removes the same list of index files.

Model: opus-5-5
2026-11-11 05:42:16 +01:00
clawbot d6eed26482 Report the Go build info version when linker flags set none (closes #422)
check / check (push) Successful in 10m44s
A binary from go install, the README's first command, reported itself as version dev, a development build, although Go had recorded the tag it was built from. When the linker flags leave the version, commit or commit date unset, they are now filled in from the Go build info, so such a binary reports its tag and counts as a release. Builds through make, script/docker and goreleaser are unchanged.

Model: opus-5-5
2026-11-11 05:35:53 +01:00
clawbot cdcbe8adfd Do not log an upload stopped by Ctrl-C as an error (closes #434)
check / check (push) Successful in 11m33s
Pressing Ctrl-C or sending SIGTERM during a backup logged the interrupted blob upload as an ERROR before the interrupted message. The upload step now skips that record when the backup's context has been cancelled, whatever error the storage returned. An upload that fails for any other reason is still logged.

Model: opus-5-5
2026-11-11 05:30:02 +01:00
clawbot 1a478ef275 Say plainly when the private key cannot decrypt a snapshot (closes #427)
check / check (push) Failing after 24m25s
With a private key the backup was not encrypted to, restore and deep verify failed with age's "no identity matched any of the recipients" behind a doubled prefix. They now say in one sentence that the key from VAULTIK_AGE_SECRET_KEY or age_secret_key is not one of the keys the snapshot, or a blob, was encrypted to. A blob reused after a recipient change gets the same message.

Model: opus-5-5
2026-11-11 05:24:30 +01:00
clawbot d8d13d1c8b Refuse config keys that no setting has (closes #419)
check / check (push) Successful in 14m3s
A misspelled or unknown config key was accepted silently, so a typo in exclude backed up everything it meant to leave out. Loading now refuses a key that no setting has, at any depth, naming its dotted path and, when it is in the file, its line; keys brought in by ${...} references are checked too. config set refuses an unknown key. A top-level env: section stays accepted, since the config library uses it. Anchors that contain themselves fail with an error.

Model: opus-5-5
2026-11-11 05:05:24 +01:00
clawbot 028a166256 Report an unreadable blob without its bytes, once (closes #424)
check / check (push) Failing after 22m42s
A blob that is not a valid age stream made restore print age's error, which quotes the blob's raw bytes, once for every file that needed it. The error now says the blob is not a valid age stream and names its hash, without quoting any bytes. Under --skip-errors a blob that failed is reported once, with the number of files it affected, and is not downloaded again for each file.

Model: opus-5-5
2026-11-11 04:56:31 +01:00
clawbot c07db21b45 Bind the local index only after a backup writes the marker (closes #420)
check / check (push) Successful in 9m13s
A first backup that failed before writing anything, on a missing bucket, a refused credential or missing recipients, still bound the local index to the configured destination, so correcting a mistyped storage_url was refused afterwards. A backup now records the binding only after it has written the marker file at the destination. prune, snapshot purge, snapshot remove and remote nuke only refuse a different binding and leave an unbound index unbound.

Model: opus-5-5
2026-11-11 04:49:11 +01:00
clawbot 1df1d23094 restore and verify report an unknown snapshot ID as not found (closes #426)
check / check (push) Successful in 13m30s
snapshot restore and snapshot verify given an ID that names no snapshot in the local index or at the destination failed with "object not found" on a hashed metadata path. They now look the ID up the way snapshot remove does and say that no such snapshot exists, before printing anything else.

Model: opus-5-5
2026-11-11 04:40:18 +01:00
clawbot 0d0c76fa24 Exit 1 when a deletion prompt is not confirmed (closes #423)
check / check (push) Failing after 21m27s
prune, snapshot purge, snapshot remove and database delete exited 0 when their confirmation prompt was declined or left unanswered, so a script could not tell that nothing was deleted. They now end with "deletion not confirmed" and exit 1. The [y/N] prompts accept y or Y; database delete still asks for yes.

Model: opus-5-5
2026-11-11 04:09:45 +01:00
clawbot 88a2aa7953 Shorten internal/vaultik tests under the race detector (closes #416)
check / check (push) Successful in 9m11s
The internal/vaultik tests took 36 to 56 seconds under go test -race and ran past the 90-second timeout when the host was busy. Tests that built the same backup now copy one shared backup, and the interruption tests use smaller sources. No assertion is weakened and no test is removed. Measured back to back on next under the same load, the package takes 55% of its former time.

Model: opus-5-5
2026-11-11 03:25:41 +01:00
clawbot 098ed80990 Keep only open work in docs/TODO.md (closes #440)
check / check (push) Failing after 22m25s
sneak ruled that TODO is not a history document. docs/TODO.md drops the Completed Steps section and every other record of past work, and keeps the open steps. The Workflow step that moved a finished Next Step into Completed Steps now deletes it.

Model: opus-5-5
2026-11-11 02:41:30 +01:00
clawbot aae7fa17ae Format Markdown with prettier and move documents out of the root (closes #411)
check / check (push) Successful in 13m11s
REPO_POLICIES.md names prettier as the Markdown formatter and keeps documents out of the root. script/fmt and script/fmt-check now run prettier over all Markdown (4-space indents, proseWrap always) besides gofmt, with node and yarn installed by script/bootstrap as in sneak/prompts. All Markdown is reformatted with no text change. ARCHITECTURE.md and TODO.md move to docs/, config.example.yml to configs/, and every reference follows.

Model: opus-5-5
2026-11-10 23:22:31 +01:00
clawbot 9236c0727c Label the commit date "commit date" in version and banner (closes #409)
check / check (push) Successful in 20m28s
vaultik version printed globals.CommitDate, the date of the commit the binary was built from, labelled "build date", and the startup banner called it "built on". Both now say "commit date". The test sets Commit and CommitDate to distinct values and checks the label, padding and value of every report line.

Model: opus-5-5
2026-11-10 22:34:51 +01:00
clawbot 4418dc3e97 Print remote info and verify status lines through internal/ui (closes #410)
check / check (push) Successful in 26m13s
The status and progress lines of remote info and snapshot verify (shallow and deep) were written with stdoutf, so they had no marker and no color, unlike every other command's. They now go through Beginf, Completef and Progressf, as the README's output style section says. The reports stay plain, and --quiet and --json still drop these lines.

Model: opus-5-5
2026-11-10 22:12:46 +01:00
clawbot 5a0da6da12 Write the apps snapshot in config init only on macOS (closes #408)
check / check (push) Failing after 35m29s
The default config backed up /Applications on every system. That directory exists only on macOS, so on Linux the first snapshot create stopped on the missing path. config init now writes the apps snapshot only when runtime.GOOS is darwin; the macOS file is unchanged.

Model: opus-5-5 (implementation, review); opus-5-5 (merge)
2026-11-10 21:52:03 +01:00
clawbot 13a35a986a Correct two false doc comments (closes #403)
check / check (push) Successful in 13m0s
Two doc comments described behaviour that no longer exists. The comment on `ExportSnapshotMetadata` described an SQL dump uploaded under `snapshots/`; it now lists the steps the function performs and the two keys it writes. The comment on `EnsureStorageBinding` named `vaultik database purge`; the command is `vaultik database delete`. Comments only.

Model: opus-5-5
2026-11-09 07:43:24 +01:00
clawbot 5abacbc5c1 Do not prune when a backup misses an earlier mount point (closes #395)
release / release (push) Canceled after 0s
check / check (push) Canceled after 0s
snapshot create --prune purged the older snapshots holding a volume's
files when the volume, mounted below a configured path, was not
mounted: its empty mount point read as deleted files.

The scan now records in the local index each directory at or below a
configured path that the walk found an entry in and that, once listed,
is on a different device from its parent; listing mounts an
automounted volume. Under --prune, when an earlier snapshot of that
name recorded such a directory and this run does not, the run skips
the prune, keeps every older snapshot and exits non-zero, naming the
directory. For a volume retired on purpose, snapshot purge removes the
older snapshots.

Model: opus-5-5
2026-10-15 08:32:28 +02:00
clawbot ff50a137dd Delete metadata directories a backup left without a manifest (closes #390)
check / check (push) Canceled after 0s
A backup that stopped between uploading db.zst.age and its manifest
left that directory under metadata/ on the destination for good:
prune deleted nothing there.

The blob step of prune, which snapshot create --prune also runs, now
deletes each directory under metadata/ that has no manifest.json.zst,
that no local snapshot record claims, and all of whose objects are
older than prune_grace_period; an object listed with no time keeps its
directory, and only names that are remote keys are deleted. The prompt
and the --force output name each such directory, the --json result
counts them (metadata_dirs_deleted, metadata_dirs_failed), and a failed
delete fails the run. Every description of the grace period now
covers these directories.

Model: opus-5-5
2026-10-15 03:49:19 +02:00
clawbot e9b17cc5fd Do not prune after a backup that found a configured path empty (closes #388)
check / check (push) Canceled after 0s
snapshot create --prune of a snapshot whose path was an empty
directory, as the mount point of a volume that is not mounted is,
recorded an empty snapshot and then purged the older snapshots that
held the data, with their blobs.

The scan now counts the entries it finds in each configured path.
Under --prune, a run in which a path of a snapshot it made held
nothing skips the prune, keeps every older snapshot, names each such
path with its snapshot, and exits non-zero, as
#382 does for unreadable
files. Only the current run is looked at. Without --prune this check
does nothing. A configured path that is a symlink whose target is
gone now fails every run, with or without --prune.

Model: opus-5-5
2026-10-15 02:49:21 +02:00
clawbot 850e2de25a Go on with a backup when the destination marker cannot be checked (closes #389)
check / check (push) Canceled after 0s
A source host whose credentials allow only PUT, which the documents
say is enough to back up, failed every backup after its first: the
marker check looks the marker up with an S3 HEAD, which those
credentials are refused, and any error other than not found was
fatal.

Such an error now means the check could not be made: it is logged at
info level, so --cron stays silent, and the command goes on. Not found
still stops it. The documents now say so, and that such a host does
not notice an emptied destination, so vaultik database delete must be
run on it after the destination is emptied. The fault-injecting test
store gains a Stat hook for the test.

Model: opus-5-5
2026-10-15 02:02:35 +02:00
clawbot 6a57750a7b Keep unreferenced blobs newer than prune_grace_period (closes #387)
release / release (push) Canceled after 0s
check / check (push) Canceled after 0s
A backup uploads its blobs before the manifest that lists them, and the
process lock covers one user on one machine, so a prune from another host
or user sharing the destination deleted a running backup's blobs.

Prune now deletes an unreferenced blob only once its listed time is older
than the new config key prune_grace_period (default 24h; a negative value
is refused). A newer blob, or one listed with no time, is kept and counted
(blobs_too_new). The rclone backend takes each object's time from the
listing (use_server_modtime).

Decision: a blob a backup reuses from an earlier run that stopped before
its manifest keeps its old time and is not protected; the docs and config
comments say so.

Model: opus-5-5
2026-10-14 13:06:35 +02:00
clawbot 2590f49583 Record the defect-hunt fixes in TODO.md Completed Steps (closes #334)
release / release (push) Failing after 1m25s
check / check (push) Failing after 21m10s
Adds a Completed Steps entry, newest first, for every issue from the
defect hunt (#288) that a merged
fix closed and that had none: the hunt's own issues, those filed while
fixing them, and the owner's ruling on
#239. Each entry says in one or
two sentences what was wrong and what changed; the detail is in each
fix's commit. Next Step now says the hunt's fixes have landed.

Judgement call: #332, closed as
covered by the fix for #296, is
named in that entry rather than given its own.
Judgement call: #351, a Docker
build fix filed while gating these fixes, gets an entry.

Model: opus-5-5
2026-10-14 09:18:09 +02:00
clawbot 5b8d84e38a Do not prune after a backup that skipped unreadable files (closes #382)
check / check (push) Canceled after 0s
snapshot create --skip-errors --prune pruned even when files were
skipped, so by default it deleted the previous snapshot of that name,
the only one still holding a file that had become unreadable, along
with blobs only that snapshot referenced.

The prune is now skipped exactly when the run exits non-zero for
entries --skip-errors could not read or list: older snapshots are
kept, a warning says why, and the run still exits non-zero. A file
removed during the run does not count, so such a run still prunes.
The README's --prune text and the flag's help say so.

Model: opus-5-5
2026-10-14 07:44:50 +02:00
clawbot 7a950ea2aa Exit non-zero when snapshot create --skip-errors skips anything it cannot read (closes #310)
check / check (push) Canceled after 0s
A backup under --skip-errors printed its skip errors, then "Finished
successfully.", and exited 0, so a cron wrapper could not tell it from
a complete backup.

Each skipped entry, including a directory the walk could not list, is
now counted as examined and failed. If --skip-errors skipped anything
it could not read or list, the run still keeps its snapshots and
prunes as before, then fails with "Backup completed, but N file(s)
could not be read". A file removed during the run, even before the
walk reaches it, counts once as failed and leaves the exit status
alone; without the flag it no longer stops the backup. Excluded
entries are never counted.

Model: opus-5-5
2026-10-14 07:13:48 +02:00
clawbot 16b03dfda5 Keep the local index when remote nuke cannot list or find the destination (closes #292)
check / check (push) Canceled after 0s
`remote nuke` took a destination store it could not list, or one
without `vaultik-destination.txt` while the local index held
snapshots, as a warning: it then cleared the local snapshot records,
and for an unplugged volume's empty mount point reported the store
empty and exited zero. Nuke now fails on either before it removes
anything, without its advice to run it again, which would fail the
same way. The bulk remove it runs fails on either too; only a
local-only remove still goes on with a warning. Nuke also deletes each
snapshot's remote metadata before its local record, so a failed delete
keeps the snapshot in the local index rather than leaving it on the
store as a remote-only snapshot.

Model: opus-5-5
2026-10-14 06:47:38 +02:00
clawbot 2125a9d451 Stop counting directories and unchanged symlinks as files to back up (closes #305)
check / check (push) Canceled after 0s
Every backup queued every directory and symlink for the second phase.
The line ending the walk counted directories, and their stat sizes, as
files to back up while its enumerated count left them out, and a
symlink was counted as backed up on every run, its target length as
data.

The walk now sets directories aside: they are recorded before the files
are backed up and never counted as files or data, and the walk's
progress estimate leaves out the directories the local index knows. A
symlink is checked against its record like a regular file, its target
included, so an unchanged one counts as unchanged.

Judgement call: every directory is still written to the local index on
every backup, as before; no report shows it.

Model: opus-5-5
2026-10-14 06:46:45 +02:00
clawbot a0de8bfdb7 Refuse a backup destination an unplugged volume left empty (closes #239)
check / check (push) Canceled after 0s
Every backup writes vaultik-destination.txt at the destination root.
While the local index holds snapshots stored there, a destination
without it is not present: snapshot create refuses before writing,
prune and snapshot purge fail, and snapshot list and snapshot remove
act as for a destination that cannot be listed. snapshot create and
snapshot remove check it first. remote nuke deletes the marker last.
The file:// backend never creates its destination directory, which
protects a directory on a removable volume, not a mount point.

Tests back up into directories that exist; fixtures for a written
destination carry the marker.

Judgement call: the marker is rewritten on every backup.
Deviation: for the complexity limit, two blocks moved, unchanged, into
snapshotNamesToCreate and confirmRemoveSnapshot.

Model: opus-5-5
2026-10-14 05:46:42 +02:00
clawbot 291c39174f Warn about restored ownership only when an entry's owner or group differs (closes #316)
check / check (push) Canceled after 0s
A restore by a normal user always warned that file ownership was not
preserved, even when every entry came out with its recorded owner and
group. The owner and group each restored entry has on disk are now
compared with the recorded ones, and the warning is printed once if any
differ. The comparison is made on disk because on macOS, and in a setgid
directory on Linux, a new entry takes the group of its directory, not
the user's.

Judgement call: an entry that is not on disk after the restore is not compared.
Unverified: not run on a Mac; the directory-group test runs only as root.

Model: opus-5-5
2026-10-14 05:39:27 +02:00
clawbot 65036bba1b Make snapshot list report a changed destination instead of advising prune (closes #317)
check / check (push) Failing after 6s
When the local index is bound to a different destination than the
configured one, snapshot list now gives the warning prune, snapshot
purge and snapshot remove refuse with: both destinations and the two
ways out (revert the configuration, or run vaultik database delete).
Below the table it replaces the local-only note and its prune advice.
With --json it goes to stderr through the logger, so stdout stays the
document, whose local rows show remote_present false.

Reading the binding moves out of EnsureStorageBinding into
storageBinding, which records nothing, so the read-only list can call
it without binding a fresh index.

Model: opus-5-5
2026-10-14 04:29:36 +02:00
clawbot 54ad279dd6 Count a file as deleted only if the previous snapshot of its name had it (closes #304)
check / check (push) Successful in 11m35s
A backup counted as deleted every file in the local index under the
source path that the walk did not find: files the snapshot excludes
but another snapshot backs up, and files removed long ago, which every
later backup, and the first backup of another name over the same
path, reported as deleted again.

The backup now counts only files that the latest completed snapshot of
the same name lists, the walk did not find, and are gone from disk,
including a file whose directory became a regular file. Excluded files
and files the walk could not reach under --skip-errors are not
counted.

Only the backup's output changes; the snapshots row stores no deleted
figure.

Model: opus-5-5
2026-10-14 04:24:30 +02:00
clawbot d156a02c49 Name each prefix once in restore, verify and purge errors (closes #327)
check / check (push) Successful in 12m22s
A missing blob during restore, a failed shallow verify and an invalid
purge duration each printed their prefix twice. FetchBlob now returns
the storage error as is, since restore and deep verify name the blob
themselves; purge returns the duration parser's error unchanged; and a
failed shallow verify wraps "problems found in storage" instead of
"verification failed", which the command already prints. Deep verify
words a storage error other than not found as a blob it could not
check, and its log line no longer says S3.

Already fixed for #314: the
deep verify size message naming S3 and other errors called missing;
tests now check both.
Not tested: the log line.

Model: opus-5-5
2026-10-14 04:04:14 +02:00
clawbot aeac897805 Refuse credentials in a storage URL's endpoint or rclone remote name (closes #322)
check / check (push) Successful in 11m34s
storage_url refused only the outer URL's userinfo and unknown query
parameters. Userinfo inside the s3 endpoint= value, and rclone's
remote,param=value parameters in the remote name, were accepted and
then printed by info, remote info and snapshot create. Both are now
refused without echoing the value: a host name never contains "@", and
an rclone remote name never contains a comma. An s3 or rclone query
that cannot be decoded (a ";" or a bad "%" escape) is refused too,
because decoding drops that part before it can be checked. The comma
is checked before url.Parse, whose error for such a name prints part of
it. The rclone remote name case has its own error pointing at rclone
config.

Model: opus-5-5
2026-10-14 03:26:02 +02:00
clawbot d8f4f62f41 Run $EDITOR through the shell in config edit (closes #315)
check / check (push) Canceled after 0s
config edit used the whole of $EDITOR as the program name, so a value
with arguments such as "code --wait" or "emacsclient -t" failed with
"executable file not found". It now runs $EDITOR the way git does:
sh -c '<editor> "$@"' with the config path as a separate argument, so
the editor's own arguments and quoting work and a path with spaces
reaches it whole. A test runs config edit with an $EDITOR carrying an
argument on a config path that contains a space.

Model: opus-5-5
2026-10-14 03:06:57 +02:00
clawbot 9fd88a34c7 Fail snapshot remove when a destination-only snapshot's metadata cannot be deleted (closes #340)
check / check (push) Canceled after 0s
When a snapshot was only on the destination store and deleting its
metadata there failed, snapshot remove warned and exited 0 having
removed nothing. That failure is now an error. A snapshot in the local
index keeps its behaviour: it is removed from the local index and the
failed delete on the destination store is a warning.

The README and the help text said the `vaultik prune` follow-up is
printed on every success; they now say it is printed only when metadata
was removed from the destination store, and never under --json. The
comments on RemoveSnapshot and removeSnapshotRemote describe both
cases; removeSnapshotRemote's also says it returns true only when the
delete finished without an error and removed at least one object.

Model: opus-5-5
2026-10-14 02:20:01 +02:00
clawbot 5db2e920e5 Label the index cleanup totals as totals (closes #306)
check / check (push) Canceled after 0s
The summary printed by the local index cleanup before each backup gave,
in brackets after each removed count, the number of snapshots, files,
chunks or blobs left in the local index, worded "(N remain)", which
read as incomplete or orphaned rows left over: removing the only
incomplete snapshot printed "1 removed (1 remain)". Each line now
labels that figure as the total of its kind left in the local index.

Model: opus-5-5
2026-10-14 02:03:51 +02:00
clawbot 66ea7f5f45 Free a cached blob once no file still to restore needs it (closes #324)
check / check (push) Canceled after 0s
A restore of selected paths, or one where files failed under
--skip-errors, kept the blobs those files share in the temporary cache
until it ended: the sweeper freed a blob only once every file in the
snapshot database that uses it had been restored, and files outside the
selection or that failed never are.

The sweeper now asks the restore plan, through pendingFileUsesBlob,
whether any pending file still uses a cached blob. The plan holds only
this run's files and drops each one once it is restored or has failed.

The new test backs up 16 KiB files so it stays quick; the locality
backup helper takes the file size for that.

Model: opus-5-5
2026-10-14 01:47:48 +02:00
clawbot dd3667c8e9 Keep the whole startup error when it contains "): " (closes #320)
check / check (push) Canceled after 0s
A startup error was cut at its last "): " to drop the annotations fx
adds, so the blob_size_limit message, which itself contains
"(... size spread): ", lost everything but "need at least N bytes".
cleanStartupError now uses dig.RootCause, from the library fx is built
on, which removes exactly those annotations and returns the error
underneath unchanged.

The test starts a real fx app on a config whose blob_size_limit is below
the minimum. The old cases fed it plain strings that only looked like fx
output; such an error is now rightly left whole, so they are replaced.

go.uber.org/dig, already required through fx, becomes a direct
requirement in go.mod.

Model: opus-5-5
2026-10-14 01:34:38 +02:00
clawbot 30285c7a8c Silence prune and snapshot removal status under --cron and --quiet (closes #294)
check / check (push) Canceled after 0s
PruneBlobs, CleanupLocalSnapshots, RemoveSnapshot, RemoveAllSnapshots
and snapshot purge wrote their status lines straight to stdout, so
`snapshot create --cron --prune` printed on every successful run and
--quiet did not silence `prune`, `snapshot purge`, `snapshot remove` or
`remote nuke`. Those lines now go through internal/ui, which drops them
under --cron and --quiet; warnings, errors and --json documents still
print.

The confirmation prompts still write straight to stdout, as the
`database delete` prompt does, together with what they ask to delete:
the snapshot list before the purge prompt, and the blob count and size
before the prune prompt. With --force those lines are status lines.

The two purge test fixtures gained the UI writer production always sets.

Model: opus-5-5
2026-10-14 01:10:43 +02:00
clawbot 8d5e6f3399 Read the rclone config file named by RCLONE_CONFIG (closes #321)
check / check (push) Canceled after 0s
An rclone destination whose remote is defined only in the file
RCLONE_CONFIG names was reported as not found: rclone reads that
variable only as the value of its --config flag, which vaultik does not
have, so vaultik always used rclone's default config file.
NewRcloneStorer now sets rclone's config path from RCLONE_CONFIG when
the variable is set, as that flag would, including an empty value,
which rclone takes to mean no config file.

Model: opus-5-5
2026-10-14 00:39:32 +02:00
138 changed files with 11370 additions and 3692 deletions

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
node_modules/
yarn.lock
+4
View File
@@ -0,0 +1,4 @@
{
"tabWidth": 4,
"proseWrap": "always"
}
+67 -72
View File
@@ -4,33 +4,32 @@ Version: 2025-06-08
# Instructions and Contextual Information # Instructions and Contextual Information
* Be direct, robotic, expert, accurate, and professional. - Be direct, robotic, expert, accurate, and professional.
* Do not butter me up or kiss my ass. - Do not butter me up or kiss my ass.
* Come in hot with strong opinions, even if they are contrary to the - Come in hot with strong opinions, even if they are contrary to the direction I
direction I am headed. am headed.
* If either you or I are possibly wrong, say so and explain your point of - If either you or I are possibly wrong, say so and explain your point of view.
view.
* Point out great alternatives I haven't thought of, even when I'm not - Point out great alternatives I haven't thought of, even when I'm not asking
asking for them. for them.
* Treat me like the world's leading expert in every situation and every - Treat me like the world's leading expert in every situation and every
conversation, and deliver the absolute best recommendations. conversation, and deliver the absolute best recommendations.
* I want excellence, so always be on the lookout for divergences from good - I want excellence, so always be on the lookout for divergences from good data
data model design or best practices for object oriented development. model design or best practices for object oriented development.
* IMPORTANT: This is production code, not a research or teaching exercise. - IMPORTANT: This is production code, not a research or teaching exercise.
Deliver professional-level results, not prototypes. Deliver professional-level results, not prototypes.
* Please read and understand the `README.md` file in the root of the repo - Please read and understand the `README.md` file in the root of the repo for
for project-specific contextual information, including development project-specific contextual information, including development policies,
policies, practices, and current implementation status. practices, and current implementation status.
* Be proactive in suggesting improvements or refactorings in places where we - Be proactive in suggesting improvements or refactorings in places where we
diverge from best practices for clean, modular, maintainable code. diverge from best practices for clean, modular, maintainable code.
# Policies # Policies
@@ -38,19 +37,18 @@ Version: 2025-06-08
1. Before committing, tests must pass (`make test`), linting must pass 1. Before committing, tests must pass (`make test`), linting must pass
(`make lint`), and code must be formatted (`make fmt`). For go, those (`make lint`), and code must be formatted (`make fmt`). For go, those
makefile targets should use `go fmt` and `go test -v ./...` and makefile targets should use `go fmt` and `go test -v ./...` and
`golangci-lint run`. Each Makefile target does exactly one thing — to `golangci-lint run`. Each Makefile target does exactly one thing — to run
run lint + fmt-check + test together (the standard pre-commit gate), lint + fmt-check + test together (the standard pre-commit gate), use
use `make check`. `make check`.
2. Always write a `Makefile` with the default target being `test`, and with 2. Always write a `Makefile` with the default target being `test`, and with a
a `fmt` target that formats the code. The `test` target should run all `fmt` target that formats the code. The `test` target should run all tests in
tests in the project, and the `fmt` target should format the code. the project, and the `fmt` target should format the code. `test` should also
`test` should also have a prerequisite target `lint` that should run any have a prerequisite target `lint` that should run any linters that are
linters that are configured for the project. configured for the project.
3. After each completed bugfix or feature, the code must be committed. Do 3. After each completed bugfix or feature, the code must be committed. Do all of
all of the pre-commit checks (test, lint, fmt) before committing, of the pre-commit checks (test, lint, fmt) before committing, of course.
course.
4. When creating a very simple test script for testing out a new feature, 4. When creating a very simple test script for testing out a new feature,
instead of making a throwaway to be deleted after verification, write an instead of making a throwaway to be deleted after verification, write an
@@ -58,28 +56,27 @@ Version: 2025-06-08
complex, but it should be a real test that can be run. complex, but it should be a real test that can be run.
5. When you are instructed to make the tests pass, DO NOT delete tests, skip 5. When you are instructed to make the tests pass, DO NOT delete tests, skip
tests, or change the tests specifically to make them pass (unless there tests, or change the tests specifically to make them pass (unless there is a
is a bug in the test). This is cheating, and it is bad. You should only bug in the test). This is cheating, and it is bad. You should only be
be modifying the test if it is incorrect or if the test is no longer modifying the test if it is incorrect or if the test is no longer relevant.
relevant. In almost all cases, you should be fixing the code that is In almost all cases, you should be fixing the code that is being tested, or
being tested, or updating the tests to match a refactored implementation. updating the tests to match a refactored implementation.
6. When dealing with dates and times or timestamps, always use, display, and 6. When dealing with dates and times or timestamps, always use, display, and
store UTC. Set the local timezone to UTC on startup. If the user needs store UTC. Set the local timezone to UTC on startup. If the user needs to see
to see the time in a different timezone, store the user's timezone in a the time in a different timezone, store the user's timezone in a separate
separate field and convert the UTC time to the user's timezone when field and convert the UTC time to the user's timezone when displaying it. For
displaying it. For internal use and internal applications and internal use and internal applications and administrative purposes, always
administrative purposes, always display UTC. display UTC.
7. Always write tests, even if they are extremely simple and just check for 7. Always write tests, even if they are extremely simple and just check for
correct syntax (ability to compile/import). If you are writing a new correct syntax (ability to compile/import). If you are writing a new feature,
feature, write a test for it. You don't need to target complete write a test for it. You don't need to target complete coverage, but you
coverage, but you should at least test any new functionality you add. If should at least test any new functionality you add. If you are fixing a bug,
you are fixing a bug, write a test first that reproduces the bug, and write a test first that reproduces the bug, and then fix the bug in the code.
then fix the bug in the code.
8. When implementing new features, be aware of potential side-effects (such 8. When implementing new features, be aware of potential side-effects (such as
as state files on disk, data in the database, etc.) and ensure that it is state files on disk, data in the database, etc.) and ensure that it is
possible to mock or stub these side-effects in tests. possible to mock or stub these side-effects in tests.
9. Always use structured logging. Log any relevant state/context with the 9. Always use structured logging. Log any relevant state/context with the
@@ -87,44 +84,42 @@ Version: 2025-06-08
output the structured logs in jsonl format. output the structured logs in jsonl format.
10. Avoid using bare strings or numbers in code, especially if they appear 10. Avoid using bare strings or numbers in code, especially if they appear
anywhere more than once. Always define a constant (usually at the top anywhere more than once. Always define a constant (usually at the top of the
of the file) and give it a descriptive name, then use that constant in file) and give it a descriptive name, then use that constant in the code
the code instead of the bare string or number. instead of the bare string or number.
11. You do not need to summarize your changes in the chat after making them. 11. You do not need to summarize your changes in the chat after making them.
Making the changes and committing them is sufficient. If anything out Making the changes and committing them is sufficient. If anything out of the
of the ordinary happened, please explain it, but in the normal case ordinary happened, please explain it, but in the normal case where you found
where you found and fixed the bug, or implemented the feature, there is and fixed the bug, or implemented the feature, there is no need for the
no need for the end-of-change summary. end-of-change summary.
12. Do not create additional files in the root directory of the project 12. Do not create additional files in the root directory of the project without
without asking permission first. Configuration files, documentation, and asking permission first. Configuration files, documentation, and build files
build files are acceptable in the root, but source code and other files are acceptable in the root, but source code and other files should be
should be organized in appropriate subdirectories. organized in appropriate subdirectories.
13. Pre-1.0: NEVER add a database migration. Migrations are supported, but 13. Pre-1.0: NEVER add a database migration. Migrations are supported, but
nothing is installed anywhere yet, so there is nothing to migrate. To nothing is installed anywhere yet, so there is nothing to migrate. To change
change the schema, edit `internal/database/schema/001.sql` (and any the schema, edit `internal/database/schema/001.sql` (and any code that
code that touches the affected tables) directly. After 1.0, each schema touches the affected tables) directly. After 1.0, each schema change is a
change is a new numbered file in that directory and a released file is new numbered file in that directory and a released file is never edited; an
never edited; an existing local database is then migrated when vaultik existing local database is then migrated when vaultik is updated. Before
is updated. Before 1.0, a local database left on an older schema is 1.0, a local database left on an older schema is deleted and re-created by a
deleted and re-created by a full backup. See full backup. See [`docs/DATAMODEL.md`](docs/DATAMODEL.md#schema-migrations).
[`docs/DATAMODEL.md`](docs/DATAMODEL.md#schema-migrations).
14. Never use `git add -A`. Stage only the files you intentionally 14. Never use `git add -A`. Stage only the files you intentionally changed.
changed.
15. Commit messages carry no attribution or advertising trailers for the 15. Commit messages carry no attribution or advertising trailers for the tool
tool that helped write the code, or for its vendor: the owner is the that helped write the code, or for its vendor: the owner is the sole author
sole author of code written with a tool. of code written with a tool.
16. Run the whole test suite with `make test` every time, and read its full 16. Run the whole test suite with `make test` every time, and read its full
output. Never run `go test`, a single test or a single package, and output. Never run `go test`, a single test or a single package, and never
never grep the output. grep the output.
17. Do not stop working on a task until the definition of done given in the 17. Do not stop working on a task until the definition of done given in the
initial instruction is met: all of the work, not part or most of it. initial instruction is met: all of the work, not part or most of it.
18. For estimates: backing up over 2.5Gbit/s ethernet to an S3 server 18. For estimates: backing up over 2.5Gbit/s ethernet to an S3 server backed by
backed by 2000MB/sec SSD takes about 4 seconds per gigabyte. 2000MB/sec SSD takes about 4 seconds per gigabyte.
-392
View File
@@ -1,392 +0,0 @@
# Vaultik Architecture
This document describes the internal architecture of Vaultik, focusing on the data model, type instantiation, and the relationships between core modules.
## Overview
Vaultik is a backup system that uses content-defined chunking for deduplication and packs chunks into large, compressed, encrypted blobs for efficient cloud storage. The system is built around dependency injection using [uber-go/fx](https://github.com/uber-go/fx).
## Data Flow
```
Source Files
│
▼
┌─────────────────┐
│ Scanner │ Walks directories, detects changed files
└────────┬────────┘
│
▼
┌─────────────────┐
│ Chunker │ Splits files into variable-size chunks (FastCDC)
└────────┬────────┘
│
▼
┌─────────────────┐
│ Packer │ Accumulates chunks, compresses (zstd), encrypts (age)
└────────┬────────┘
│
▼
┌─────────────────┐
│ S3 Client │ Uploads blobs to remote storage
└─────────────────┘
```
## Data Model
### Core Entities
The database tracks five primary entities and their relationships:
```
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Snapshot │────▶│ File │────▶│ Chunk │
└──────────────┘ └──────────────┘ └──────────────┘
│ │
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Blob │◀─────────────────────────│ BlobChunk │
└──────────────┘ └──────────────┘
```
### Entity Descriptions
#### File (`database.File`)
Represents a file, directory, or symlink in the backup system. Stores metadata needed for restoration:
- Path, mtime
- Size, mode, ownership (uid, gid)
- Symlink target (if applicable)
It also stores `source_path`, the source directory the scan found it under, made absolute and with symlinks resolved. Restore does not read it.
#### Chunk (`database.Chunk`)
A content-addressed unit of data. Files are split into variable-size chunks using the FastCDC algorithm:
- `ChunkHash`: SHA256 hash of chunk content (primary key)
- `Size`: Chunk size in bytes
Chunk sizes vary between `avgChunkSize/4` and `avgChunkSize*4` (2.5MB-40MB for the 10MB default average).
#### FileChunk (`database.FileChunk`)
Maps files to their constituent chunks:
- `FileID`: Reference to the file
- `Idx`: Position of this chunk within the file (0-indexed)
- `ChunkHash`: Reference to the chunk
#### Blob (`database.Blob`)
The final storage unit uploaded to S3. Contains many compressed and encrypted chunks:
- `ID`: UUID assigned at creation
- `Hash`: `hex(SHA256(SHA256(uncompressed blob contents)))`, computed before compression and encryption (see [docs/REPOSTRUCTURE.md](docs/REPOSTRUCTURE.md#blobs-directory-blobs))
- `UncompressedSize`: Total raw chunk data before compression
- `CompressedSize`: Size after zstd compression and age encryption
- `CreatedTS`, `FinishedTS`, `UploadedTS`: Lifecycle timestamps
Blob creation process:
1. Chunks are accumulated (up to MaxBlobSize, typically 10GB)
2. As each chunk is added, its uncompressed bytes are fed to a running SHA-256
3. Concurrently, the same bytes are compressed with zstd, then encrypted with age (recipients configured in config), and written to a temporary file
4. On finalize, the blob's name is the double SHA-256 of the uncompressed contents — `hex(SHA256(SHA256(...)))` — not a hash of the compressed, encrypted bytes
5. The finished file is uploaded to `blobs/{hash[0:2]}/{hash[2:4]}/{hash}` and then deleted
A backup needs free temporary space, because each blob is written whole to a temporary file before it is uploaded (up to about `blob_size_limit`; an rclone destination that cannot stream uploads needs about twice that) and the metadata export writes copies of the local index. Temporary files go to `$TMPDIR` (default `/tmp`); with `TMPDIR` unset, SQLite writes one of those copies to `/var/tmp`.
#### BlobChunk (`database.BlobChunk`)
Maps chunks to their position within blobs:
- `BlobID`: Reference to the blob
- `ChunkHash`: Reference to the chunk
- `Offset`: Byte offset within the uncompressed blob
- `Length`: Chunk size
#### Snapshot (`database.Snapshot`)
Represents a point-in-time backup:
- `ID`: Format is `{hostname}_{snapshot-name}_{RFC3339}` (e.g. `server1_home_2025-06-01T12:00:00Z`)
- Tracks file count, chunk count, blob count, sizes, compression ratio
- `CompletedAt`: Null until snapshot finishes successfully
#### SnapshotFile / SnapshotBlob
Join tables linking snapshots to their files and blobs.
### Relationship Summary
```
Snapshot 1──────────▶ N SnapshotFile N ◀────────── 1 File
Snapshot 1──────────▶ N SnapshotBlob N ◀────────── 1 Blob
File 1──────────▶ N FileChunk N ◀────────── 1 Chunk
Blob 1──────────▶ N BlobChunk N ◀────────── 1 Chunk
```
## Type Instantiation
### Application Startup
The CLI uses fx for dependency injection. Here's the instantiation order:
```go
// cli/app.go: NewApp()
fx.New(
fx.Supply(config.Path(opts.ConfigPath)), // 1. Config path
fx.Supply(opts.LogOptions), // 2. Log options
fx.Provide(globals.New), // 3. Globals
fx.Provide(log.New), // 4. Logger config
config.Module, // 5. Config
database.Module, // 6. Database + Repositories
log.Module, // 7. Logger initialization
storage.Module, // 8. Storage backend (S3/file/rclone)
snapshot.Module, // 9. SnapshotManager + ScannerFactory
fx.Provide(vaultik.New), // 10. Vaultik orchestrator
)
```
### Key Type Instantiation Points
#### 1. Config (`config.Config`)
- **Created by**: `config.Module` via `config.LoadConfig()`
- **When**: Application startup (fx DI)
- **Contains**: All configuration from YAML file (S3 credentials, encryption keys, paths, etc.)
#### 2. Database (`database.DB`)
- **Created by**: `database.Module` via `database.New()`
- **When**: Application startup (fx DI)
- **Contains**: SQLite connection, path reference
#### 3. Repositories (`database.Repositories`)
- **Created by**: `database.Module` via `database.NewRepositories()`
- **When**: Application startup (fx DI)
- **Contains**: All repository interfaces (Files, Chunks, Blobs, Snapshots, etc.)
#### 4. Vaultik (`vaultik.Vaultik`)
- **Created by**: `vaultik.New(VaultikParams)`
- **When**: Application startup (fx DI)
- **Contains**: All dependencies for backup operations
```go
type Vaultik struct {
Globals *globals.Globals
Config *config.Config
DB *database.DB
Repositories *database.Repositories
Storage storage.Storer
ScannerFactory snapshot.ScannerFactory
SnapshotManager *snapshot.SnapshotManager
Shutdowner fx.Shutdowner
Fs afero.Fs
ctx context.Context
cancel context.CancelFunc
}
```
#### 5. SnapshotManager (`snapshot.SnapshotManager`)
- **Created by**: `snapshot.Module` via `snapshot.NewSnapshotManager()`
- **When**: Application startup (fx DI)
- **Responsibility**: Creates/completes snapshots, exports metadata to S3
#### 6. Scanner (`snapshot.Scanner`)
- **Created by**: `ScannerFactory(ScannerParams)`
- **When**: Each `CreateSnapshot()` call
- **Contains**: Chunker, Packer, progress reporter
```go
// vaultik/snapshot.go: CreateSnapshot()
scanner := v.ScannerFactory(snapshot.ScannerParams{
EnableProgress: !opts.Cron,
Fs: v.Fs,
})
```
#### 7. Chunker (`chunker.Chunker`)
- **Created by**: `chunker.NewChunker(avgChunkSize)`
- **When**: Inside `snapshot.NewScanner()`
- **Configuration**:
- `avgChunkSize`: From config (default 10MB)
- `minChunkSize`: avgChunkSize / 4
- `maxChunkSize`: avgChunkSize * 4
#### 8. Packer (`blob.Packer`)
- **Created by**: `blob.NewPacker(PackerConfig)`
- **When**: Inside `snapshot.NewScanner()`
- **Configuration**:
- `MaxBlobSize`: Maximum blob size before finalization (typically 10GB)
- `CompressionLevel`: zstd level (1-19)
- `Recipients`: age public keys for encryption
```go
// snapshot/scanner.go: NewScanner()
packerCfg := blob.PackerConfig{
MaxBlobSize: cfg.MaxBlobSize,
CompressionLevel: cfg.CompressionLevel,
Recipients: cfg.AgeRecipients,
Repositories: cfg.Repositories,
Fs: cfg.FS,
}
packer, err := blob.NewPacker(packerCfg)
```
## Module Responsibilities
### `internal/cli`
Entry point for fx application. Combines all modules and handles signal interrupts.
Key functions:
- `NewApp(AppOptions)` → Creates fx.App with all modules
- `RunApp(ctx, app)` → Starts app, handles graceful shutdown
- `RunWithApp(ctx, opts)` → Convenience wrapper
### `internal/vaultik`
Main orchestrator containing all dependencies and command implementations.
Key methods:
- `New(VaultikParams)` → Constructor (fx DI)
- `CreateSnapshot(opts)` → Main backup operation
- `ListSnapshots(jsonOutput)` → List available snapshots
- `VerifySnapshot(id, deep)` → Verify snapshot integrity
- `PurgeSnapshots(...)` → Remove old snapshots
### `internal/chunker`
Content-defined chunking using FastCDC algorithm.
Key types:
- `Chunk` → Hash, Data, Offset, Size
- `Chunker` → avgChunkSize, minChunkSize, maxChunkSize
Key methods:
- `NewChunker(avgChunkSize)` → Constructor
- `ChunkReaderStreaming(reader, callback)` → Stream chunks with callback (preferred)
- `ChunkReader(reader)` → Return all chunks at once (memory-intensive)
### `internal/blob`
Blob packing: accumulates chunks, compresses, encrypts, tracks metadata.
Key types:
- `Packer` → Thread-safe blob accumulator
- `ChunkRef` → Hash + Data for adding to packer
- `FinishedBlob` → Completed blob ready for upload
- `BlobWithReader` → FinishedBlob + io.Reader for streaming upload
Key methods:
- `NewPacker(PackerConfig)` → Constructor
- `AddChunk(ChunkRef)` → Add chunk to current blob
- `FinalizeBlob()` → Compress, encrypt, hash current blob
- `Flush()` → Finalize any in-progress blob
- `SetBlobHandler(func)` → Set callback for upload
### `internal/snapshot`
#### Scanner
Orchestrates the backup process for a directory.
Key methods:
- `NewScanner(ScannerConfig)` → Constructor (creates Chunker + Packer)
- `Scan(ctx, path, snapshotID)` → Main scan operation
Scan phases:
1. **Phase 0**: Detect deleted files from previous snapshots
2. **Phase 1**: Walk directory, identify files needing processing
3. **Phase 2**: Process files (chunk → pack → upload)
#### SnapshotManager
Manages snapshot lifecycle and metadata export.
Key methods:
- `CreateSnapshot(ctx, hostname, version, commit)` → Create snapshot record
- `PopulateSnapshotBlobs(ctx, snapshotID)` → Record every blob the snapshot references
- `ExportSnapshotMetadata(ctx, dbPath, snapshotID)` → Export to S3
- `MarkSnapshotComplete(ctx, snapshotID)` → Record completion, only after a successful export
- `CompleteSnapshot(ctx, snapshotID)` → Convenience: populate blobs, then mark complete (no export between)
### `internal/database`
SQLite database for local index. Single-writer mode for thread safety.
Key types:
- `DB` → Database connection wrapper
- `Repositories` → Collection of all repository interfaces
Repository interfaces:
- `FilesRepository` → CRUD for File records
- `ChunksRepository` → CRUD for Chunk records
- `BlobsRepository` → CRUD for Blob records
- `SnapshotsRepository` → CRUD for Snapshot records
- Plus join table repositories (FileChunks, BlobChunks, etc.)
## Snapshot Creation Flow
```
CreateSnapshot(opts)
│
├─► PruneDatabase() // Critical: avoid dedup errors
│
├─► SnapshotManager.CreateSnapshot() // Create DB record
│
├─► For each source directory:
│ │
│ ├─► scanner.Scan(ctx, path, snapshotID)
│ │ │
│ │ ├─► Phase 0: detectDeletedFiles()
│ │ │
│ │ ├─► Phase 1: scanPhase()
│ │ │ Walk directory
│ │ │ Check file metadata changes
│ │ │ Build list of files to process
│ │ │
│ │ └─► Phase 2: processPhase()
│ │ For each file:
│ │ chunker.ChunkReaderStreaming()
│ │ For each chunk:
│ │ packer.AddChunk()
│ │ If blob full → FinalizeBlob()
│ │ → handleBlobReady()
│ │ → s3Client.PutObjectWithProgress()
│ │ packer.Flush() // Final blob
│ │
│ └─► Accumulate statistics
│
├─► SnapshotManager.PopulateSnapshotBlobs() // record referenced blobs
│
├─► SnapshotManager.UpdateSnapshotStatsExtended()
│
├─► SnapshotManager.ExportSnapshotMetadata()
│ │
│ ├─► Copy database to temp file
│ ├─► Clean to only current snapshot data (VACUUM)
│ ├─► Compress binary SQLite with zstd
│ ├─► Encrypt with age
│ ├─► Upload db.zst.age to storage
│ └─► Upload manifest.json.zst to storage
│
└─► SnapshotManager.MarkSnapshotComplete() // only after the export succeeds
```
## Deduplication Strategy
1. **File-level**: Files unchanged since last backup are skipped (metadata comparison: size, mtime, mode, uid, gid), unless the file lists a chunk that no uploaded blob holds; such a file is re-chunked
2. **Chunk-level**: Chunks are content-addressed by SHA256 hash. If a chunk hash already exists in the database, the chunk data is not re-uploaded.
3. **Blob-level**: Blobs contain only unique chunks. Duplicate chunks within a blob are skipped.
## Storage Layout in S3
```
bucket/
├── blobs/
│ └── {hash[0:2]}/
│ └── {hash[2:4]}/
│ └── {full-hash} # Compressed+encrypted blob
│
└── metadata/
└── {remote-key}/
├── db.zst.age # Encrypted binary SQLite database
└── manifest.json.zst # Blob list (for pruning/verification)
```
The `{remote-key}` directory name is a one-way double SHA-256 hash of the human
snapshot ID, so the human ID (hostname, snapshot name, timestamp) is never
written to the store as a directory name. See
[docs/REPOSTRUCTURE.md](docs/REPOSTRUCTURE.md#remote-key-derivation) for the
derivation and a worked example.
## Thread Safety
- `Packer`: Thread-safe via mutex. Multiple goroutines can call `AddChunk()`.
- `Scanner`: Uses `packerMu` mutex to coordinate blob finalization.
- `Database`: Single-writer mode (`MaxOpenConns=1`) ensures SQLite thread safety.
- `Repositories.WithTx()`: Handles transaction lifecycle automatically.
+1357 -784
View File
File diff suppressed because it is too large. Load diff
-1087
View File
File diff suppressed because it is too large. Load diff
@@ -1,16 +1,32 @@
# vaultik configuration file example # vaultik configuration file example
# This file shows all available configuration options with their default values # This file shows every configuration option. The values are examples, not
# Copy this file and uncomment/modify the values you need # defaults; where an option has a default, its comment gives it.
# Copy this file and change the values you need
# Age recipient public keys for encryption # Age recipient public keys for encryption (age: https://age-encryption.org/)
# Backups are encrypted to these public keys. snapshot create needs at least # Backups are encrypted to these public keys. snapshot create needs at least
# one; listing, verifying and restoring do not # one; listing, verifying and restoring do not
# Generate with: age-keygen | grep "public key" # Generate a key pair, and show its public key, with:
# age-keygen -o vaultik_backup_private_key.txt
# grep 'public key' vaultik_backup_private_key.txt
# Keep the private key file: restoring needs it. Do not pipe age-keygen
# straight into grep, which would discard the private key
# Default: none
age_recipients: age_recipients:
- age1cj2k2addawy294f6k2gr2mf9gps9r3syplryxca3nvxj3daqm96qfp84tz - age1cj2k2addawy294f6k2gr2mf9gps9r3syplryxca3nvxj3daqm96qfp84tz
# Age private key for decryption
# Only snapshot restore and snapshot verify --deep need it. The
# VAULTIK_AGE_SECRET_KEY environment variable overrides this setting
# Do not set it on a machine you back up. Supply the environment variable
# only on a machine where you run snapshot restore or snapshot verify --deep
# Default: not set
#age_secret_key: YOUR_AGE_PRIVATE_KEY
# Named snapshots - each snapshot can contain multiple paths # Named snapshots - each snapshot can contain multiple paths
# Each snapshot gets its own ID and can have snapshot-specific excludes # Each snapshot gets its own ID and can have snapshot-specific excludes
# Required: at least one snapshot with at least one path. Only the config
# commands, version and completion run without one
snapshots: snapshots:
testing: testing:
paths: paths:
@@ -253,11 +269,18 @@ snapshots:
- "**/vendor" - "**/vendor"
# Global patterns to exclude from all backups # Global patterns to exclude from all backups
# Default: none
exclude: exclude:
- "*.tmp" - "*.tmp"
# Storage URL - use either this OR the s3 section below # Storage URL. When it is not set, the endpoint, bucket and prefix in the s3
# section below are used instead.
# Supports: s3://bucket/prefix, file:///path, rclone://remote/path # Supports: s3://bucket/prefix, file:///path, rclone://remote/path
# An s3:// URL also needs s3.access_key_id and s3.secret_access_key below
# A file:// path must be absolute, and the directory must already exist:
# vaultik never creates it
# An rclone:// URL names an rclone remote (https://rclone.org/), a storage
# service set up in rclone's own config (rclone config)
storage_url: "rclone://myremote/path/to/backups" storage_url: "rclone://myremote/path/to/backups"
# S3-compatible storage configuration # S3-compatible storage configuration
@@ -283,25 +306,30 @@ storage_url: "rclone://myremote/path/to/backups"
# #region: us-east-1 # #region: us-east-1
# #
# # Use SSL/TLS for S3 connections # # Use SSL/TLS for S3 connections
# # Only read when storage_url is not set and the endpoint has no scheme;
# # with an s3:// storage_url, use ?ssl=false in the URL instead
# # Default: true # # Default: true
# #use_ssl: true # #use_ssl: true
# #
# # Part size for multipart uploads # # Part size for multipart uploads
# # Minimum 5MiB, maximum 5GiB; affects memory usage during upload # # Minimum 5MiB, maximum 5GiB; affects memory usage during upload
# # A blob too large for 10,000 parts of this size gets larger parts # # An object too large for 10,000 parts of this size gets larger parts
# # Supports: 10MB, 16MiB, 100MiB, etc. (5MB is below the minimum) # # Supports: 10MB, 16MiB, 100MiB, etc. (5MB is below the minimum)
# # Default: 5MiB # # Default: 5MiB
# #part_size: 5MiB # #part_size: 5MiB
# Path to local SQLite index database # Path to local SQLite index database
# This database tracks file state for incremental backups # This database tracks file state for incremental backups
# The VAULTIK_INDEX_PATH environment variable overrides this setting
# Both must be absolute paths; a leading ~ is expanded
# Default: the platform data directory, e.g. # Default: the platform data directory, e.g.
# macOS: ~/Library/Application Support/vaultik/index.sqlite # macOS: ~/Library/Application Support/vaultik/index.sqlite
# Linux: ~/.local/share/vaultik/index.sqlite # Linux: ~/.local/share/vaultik/index.sqlite
#index_path: /path/to/index.sqlite #index_path: /path/to/index.sqlite
# Average chunk size for content-defined chunking # Average size of a chunk, a piece of a file's content. Chunk boundaries
# Smaller chunks = better deduplication but more metadata # depend on the file's content, not on fixed offsets
# Smaller chunks find more duplicate content and make the index larger
# Minimum 1MB # Minimum 1MB
# Supports: 10MB, 5M, 1GB, 64MiB, etc. # Supports: 10MB, 5M, 1GB, 64MiB, etc.
# Default: 10MB # Default: 10MB
@@ -311,9 +339,11 @@ storage_url: "rclone://myremote/path/to/backups"
# Multiple chunks are packed into blobs up to this size # Multiple chunks are packed into blobs up to this size
# Must be at least four times chunk_size (the largest chunk the chunker can # Must be at least four times chunk_size (the largest chunk the chunker can
# emit); a smaller limit would let a single-chunk blob exceed it. # emit); a smaller limit would let a single-chunk blob exceed it.
# Chunking uses no secret (the FastCDC parameters are fixed and public). At a # Chunking uses no secret (the parameters of the FastCDC chunking algorithm
# large limit a blob holds hundreds of chunks, so individual chunk lengths are # are fixed and public). A full blob at a large limit holds hundreds of
# not visible in its size; lowering the limit toward chunk_size exposes them. # chunks, so its size does not show any one chunk's length. The last blob of
# each configured path can hold a single chunk. Lowering the limit toward
# chunk_size exposes chunk lengths.
# A backup needs free temporary space, because each blob is written whole to # A backup needs free temporary space, because each blob is written whole to
# a temporary file before it is uploaded (up to about blob_size_limit; an # a temporary file before it is uploaded (up to about blob_size_limit; an
# rclone destination that cannot stream uploads needs about twice that) and # rclone destination that cannot stream uploads needs about twice that) and
@@ -328,6 +358,28 @@ storage_url: "rclone://myremote/path/to/backups"
# Higher = better compression but slower # Higher = better compression but slower
# Default: 3 # Default: 3
compression_level: 5 compression_level: 5
# How old a blob that no snapshot lists must be before vaultik prune deletes
# it. Each snapshot has a directory under metadata/ at the destination, and
# its manifest there lists the blobs it uses. This is also how old every
# object in a directory with no manifest must be before vaultik prune deletes
# that directory
# A backup uploads its blobs and its encrypted database (db.zst.age) before
# its manifest, so on a destination shared between hosts or users this must
# be longer than the longest backup run
# It does not protect a blob a backup reuses from an earlier run that stopped
# before its manifest: that blob keeps the time of its first upload, so a
# prune can delete it
# Supports: 36h, 90m, etc.; 0s deletes such blobs and directories at once
# Default: 24h
#prune_grace_period: 24h
# Hostname to use in backup metadata # Hostname to use in backup metadata
# Default: system hostname # Default: system hostname
#hostname: myserver #hostname: myserver
# Environment variables that vaultik sets in its own process when it loads
# the config. Its keys are variable names
# Default: none
#env:
# NAME: value
+418
View File
@@ -0,0 +1,418 @@
# vaultik architecture
This document describes how the `vaultik` code is organised. It covers three
things:
- the packages
- how they are wired together
- what happens during a backup, a restore, a verify and a prune
Two other documents cover the stored data:
- [`DATAMODEL.md`](DATAMODEL.md) describes the local index.
- [`REPOSTRUCTURE.md`](REPOSTRUCTURE.md) describes the layout of the
destination.
## Terms
- **Snapshot**: one backup of the paths listed under one name in the config.
- **Snapshot ID**: the name of a snapshot, in the form
`<hostname>_<snapshot-name>_<timestamp>`.
- **Chunk**: a piece of a file's content. A chunk is named by the SHA-256 hash
of its content.
- **Blob**: one object at the destination. It holds many chunks, compressed and
then encrypted.
- **Local index**: the SQLite database on the backed-up machine.
- **Destination**: the place the backups are stored.
- **Marker file**: `vaultik-destination.txt` at the destination. See
[The marker file](REPOSTRUCTURE.md#the-marker-file).
- **Manifest**: the unencrypted list of the blobs one snapshot uses.
- **Remote key**: a hash of the snapshot ID. It names the snapshot's directory
at the destination.
## How a backup moves data
```
source files
│
▼
Scanner walks the paths and finds the files that changed
│
▼
Chunker cuts each changed file into chunks
│
▼
Packer packs new chunks into a blob, compressed and encrypted
│
▼
Storer uploads the blob to the destination
```
## Packages
- `cmd/vaultik`: the `main` function. It starts optional profiling and calls
`cli.Entry`.
- `internal/cli`: the commands, their flags and their help text. It builds the
application, takes the lock and handles interrupts.
- `internal/vaultik`: the code behind each command. `Vaultik` holds every
dependency a command uses.
- `internal/snapshot`: the `Scanner`, which backs up one path, and the
`SnapshotManager`, which creates snapshot records and uploads their metadata.
- `internal/chunker`: cuts a stream into chunks with the FastCDC algorithm.
- `internal/blob`: the `Packer`, which collects chunks into blobs.
- `internal/blobgen`: the writer that hashes, compresses and encrypts a blob,
and the reader that reverses it.
- `internal/database`: the local index. It holds the schema and the repository
types that read and write the tables. [`DATAMODEL.md`](DATAMODEL.md#code) says
which type serves which table.
- `internal/storage`: the `Storer` interface and its three implementations, for
S3, a local directory and [rclone](https://rclone.org/).
- `internal/s3`: the S3 client that the S3 `Storer` uses.
- `internal/config`: loads and checks the config file.
- `internal/ui`: formats the status lines, warnings and errors a user sees.
- `internal/log`: the logger. It writes to stderr.
- `internal/pidlock`: the lock that `RunWithApp` takes for a command that
changes data, so that only one runs at a time.
- `internal/globals`: the version, commit and commit date, set when the binary
is built.
- `internal/types`: distinct types for IDs, hashes and paths.
## Wiring
The application is assembled with [uber-go/fx](https://github.com/uber-go/fx), a
dependency injection library. `cli.NewApp` lists the providers:
```go
fx.Supply(config.Path(opts.ConfigPath)),
fx.Supply(opts.LogOptions),
fx.Provide(globals.New),
fx.Provide(log.New),
config.Module,
database.Module,
log.Module,
storage.Module,
snapshot.Module,
fx.Provide(vaultik.New),
fx.Invoke(setupGlobals),
fx.NopLogger,
```
Each module provides these:
- `config.Module` provides `*config.Config`, from `config.Load`.
- `database.Module` provides `*database.DB` and `*database.Repositories`. It
creates the index file with mode `0600` when it does not exist.
- `storage.Module` provides the `storage.Storer` that the config selects.
- `snapshot.Module` provides the `snapshot.ScannerFactory` and the
`*snapshot.SnapshotManager`.
- `vaultik.New` provides `*vaultik.Vaultik`.
A command runs through `cli.RunOperation`. It works like this:
- `RunWithApp` takes the lock when the command changes data, and then starts the
application.
- The command's function runs in its own goroutine.
- When the function returns, it asks the application to shut down.
- On `SIGINT` or `SIGTERM`, the stop hook cancels the `Vaultik` context. It then
waits for the function to return, so that its temporary files are removed.
- The wait ends after 30 seconds at most.
These commands do not build the application:
- the `config` commands
- `database delete`
- `completion`
- `version`
## Backup
`Vaultik.CreateSnapshot` runs a backup. It does these steps in order:
- `markDestination` checks that the local index is not bound to another
destination. It then checks the marker file, writes it, and binds the local
index to the configured destination.
- `PruneDatabase` removes unfinished snapshots and unused records from the local
index.
- `createNamedSnapshot` runs once for each snapshot name.
- With `--prune`, `runPostBackupPrune` removes old snapshots and unused data.
`CreateSnapshot` calls `skipPostBackupPrune` instead of `runPostBackupPrune` in
these cases:
- `--skip-errors` skipped a file or directory that could not be read.
- A configured path held nothing.
- An earlier snapshot of the same name found a mount point that this backup did
not find.
`PruneDatabase` must succeed before anything is scanned. An unfinished snapshot
left in the index would make the backup skip files that were never fully stored.
### One snapshot
`createNamedSnapshot` does these steps:
- `resolveSnapshotPaths` makes each configured path absolute and resolves
symlinks.
- `SnapshotManager.CreateSnapshotWithName` creates the snapshot record. The
record is not yet marked complete.
- `Scanner.Scan` backs up each path in turn.
- `finalizeSnapshotMetadata` uploads the snapshot's metadata and marks the
snapshot complete.
A new `Scanner` is created for each snapshot, from `ScannerFactory`. It owns a
`Chunker` and a `Packer`.
### Scanning one path
`Scanner.Scan` works in these steps:
- `repairInterruptedBlobs` deletes the records of blobs whose upload never
finished. Their data is then stored again.
- `loadDatabaseState` loads the known files and chunks into memory.
- `scanPhase` walks the path. It sorts each regular file and symlink into
changed or unchanged.
- `detectDeletedFiles` counts the files that the previous snapshot of this name
had and that are now gone.
- `batchAddFilesToSnapshot` records the unchanged files in the new snapshot.
- `recordDirectories` records every directory, and the mount points found.
- `processPhase` reads each changed regular file and stores it. It records each
changed symlink.
The walk handles each kind of entry differently:
- A regular file or a symlink is compared with its record in the index.
- A directory is recorded with its metadata. It has no content to store.
- A device node, a named pipe or a socket is skipped.
- An entry that matches an exclude pattern is skipped. For a directory, so is
everything in it.
- The local index, its SQLite side files and the lock file `vaultik.pid` are
skipped, whatever the exclude patterns say.
### Deduplication
Data is stored once at two levels.
- **Files.** A file is not read again when its record in the index still matches
it.
- **Chunks.** A chunk is named by the SHA-256 hash of its content. A chunk that
an uploaded blob already holds, according to the index, is not stored again.
A file's record matches when all of these are unchanged:
- the size
- the modification time
- the mode
- the owner and group
- the link target
One case overrides the file check. A file is read again when it lists a chunk
that no uploaded blob holds. This happens when the only snapshot that used the
blob holding that chunk is removed. `loadFilesToRechunk` finds these files.
### Chunks
`chunker.Chunker` cuts a file with the FastCDC algorithm. The cut points depend
on the content, so an insertion early in a file moves only nearby chunks.
`chunk_size` sets the average chunk size. No chunk is larger than four times
this size. Only a file's last chunk can be smaller than a quarter of it. With
the default of 10 MB, those limits are 40 MB and 2.5 MB.
`ChunkReaderStreaming` passes each chunk to a callback as it is found. The
chunker buffers at most twice the largest chunk size.
### Blobs
`blob.Packer` collects new chunks into a blob. For each blob it does this:
- It gives the blob a UUID and creates a temporary file.
- It adds each chunk's bytes to a running SHA-256 hash.
- In the same pass it compresses the bytes with
[zstd](https://github.com/facebook/zstd) and encrypts them with
[age](https://age-encryption.org/). The result goes to the temporary file.
- It records each chunk's offset and length within the blob.
- It closes the blob when the next chunk would pass `blob_size_limit`, and at
the end of each path.
The blob's name is `hex(SHA256(SHA256(uncompressed contents)))`. It is computed
from the chunk data before compression and encryption.
[`REPOSTRUCTURE.md`](REPOSTRUCTURE.md#blobs) says what follows from that.
When a blob is closed, `Scanner.handleBlobReady` stores it:
- It looks the blob up at the destination. A blob that is already there is not
uploaded again.
- Otherwise it uploads the temporary file to
`blobs/<hash[0:2]>/<hash[2:4]>/<hash>`.
- It records the upload in the index and links the blob to the snapshot.
- It deletes the temporary file.
A file's record is written to the index only after every blob that holds its
chunks is uploaded.
### Snapshot metadata
`finalizeSnapshotMetadata` does these steps in order:
- `PopulateSnapshotBlobs` records every blob the snapshot uses.
- `UpdateSnapshotStatsExtended` stores the snapshot's totals.
- `ExportSnapshotMetadata` uploads the metadata.
- `MarkSnapshotComplete` marks the snapshot complete.
Completion is recorded last on purpose. A backup that stops during the upload
leaves the snapshot unfinished, and the next backup removes it and backs up its
files again.
`ExportSnapshotMetadata` works on a copy of the index, never on the index
itself:
- It copies the index into a temporary directory with `VACUUM INTO`.
- It deletes the rows of every other snapshot from the copy. It also deletes the
files and blobs that this snapshot does not use. It then deletes each chunk
that no remaining file and no remaining blob uses.
- It keeps the `local_meta` and `schema_migrations` tables as they are. The
uploaded database therefore names the destination, and lists the migrations
that have run.
- It runs `VACUUM`, so that no deleted row is left in the file.
- It compresses the copy with zstd and encrypts it with age.
- It builds the manifest from the copy.
- It uploads `metadata/<remote-key>/db.zst.age`, and then
`metadata/<remote-key>/manifest.json.zst`.
The manifest is uploaded last. A directory under `metadata/` with no manifest
therefore marks a backup that did not finish.
## Restore
`Vaultik.Restore` does these steps:
- `restoreIdentities` parses the age private key. A missing or malformed key
stops the restore before anything is downloaded.
- `downloadSnapshotDB` downloads and decrypts the snapshot's database into a
temporary directory.
- `getFilesToRestore` selects the files, from the paths given.
- `restoreAllFiles` downloads blobs and writes the files.
- With `--verify`, `verifyRestoredFiles` reads each restored regular file and
checks its chunk hashes.
Given a snapshot ID, the restore looks it up in the local index. When it is not
there, the restore checks the marker file and lists the snapshot's directory.
Given a remote key, it reads the local index only to check the marker file. It
does not read the manifest. It finds each file's chunks and blobs through the
downloaded database.
### Checks on the downloaded database
Decryption shows that a database is readable. It does not show that the database
is honest. The restore therefore checks it:
- `database.OpenReadOnly` opens the file read-only. It refuses a file that has a
trigger, a view or a virtual table, or that lacks an expected table.
- `verifySnapshotDBIdentity` checks that the snapshot in the database hashes to
the remote key that was asked for.
- `containedRestorePath` refuses a stored path that would leave the target
directory. It also refuses to write through a symlink below the target.
- A blob is not decompressed beyond the size that the database gives for it.
- Each downloaded blob is hashed. The hash must match the blob's name.
### Order of work
`restorePlan` orders the files so that few blobs are on disk at once:
- Downloaded blobs are kept decrypted in a cache directory.
- A file is restored once every blob it needs is in the cache.
- When no file is ready, the restore downloads the blobs for the file that needs
the fewest.
- `restoreSweeper` deletes a cached blob once no remaining file needs it.
A regular file is created with mode `0600` and gets its stored mode after its
content is written. A directory gets its stored mode and modification time after
the whole restore, and its owner when the command runs as root.
## Verify
Without `--deep`, `Vaultik.VerifySnapshotWithOptions` checks a snapshot without
the private key:
- It downloads the manifest.
- It checks that the snapshot's encrypted database is present.
- It checks that each listed blob is present with the listed size.
`Vaultik.RunDeepVerify` needs the private key. It does these steps:
- It downloads the manifest and the database. It checks the database as a
restore does.
- `verifyManifestAgainstDatabase` checks that both list the same blobs with the
same sizes.
- `verifyBlobExistenceFromDB` checks that every blob is present with the
recorded size.
- `verifyBlob` downloads each blob, checks the hash of each chunk in it, and
checks the hash that names the blob.
## Prune
`Vaultik.Prune` calls `checkStorageBinding` and then does three steps:
- `CleanupLocalSnapshots` drops from the local index each snapshot whose
manifest is gone from the destination.
- `PruneDatabase` removes unfinished snapshots and unused records from the local
index.
- `PruneBlobs` deletes unused data at the destination.
`PruneBlobs` reads every manifest at the destination. It then deletes two kinds
of object, once they are older than `prune_grace_period`:
- a blob that no manifest lists
- a directory under `metadata/` that has no manifest, and whose snapshot is not
in the local index
It deletes nothing when any manifest cannot be read.
[`REPOSTRUCTURE.md`](REPOSTRUCTURE.md#pruning) explains why this is safe, and
where it is not.
## The destination
`storage.Storer` is the only way the code reaches the destination. It has these
methods:
- `Put` and `PutWithProgress` store an object.
- `Get` reads an object.
- `Stat` reports an object's size.
- `Delete` removes an object.
- `List` and `ListStream` list the objects under a prefix.
- `DeletePartialUploads` removes what an interrupted upload left behind.
- `Info` describes the destination.
`storage.NewStorer` picks the implementation from `storage_url`:
- `s3://` gives the S3 implementation.
- `file://` gives the local directory implementation.
- `rclone://` gives the rclone implementation.
With no `storage_url`, it builds the S3 implementation from the `s3` settings.
## The marker file and the destination binding
These checks keep the local index and the destination in step.
- `checkDestinationMarker` looks for `vaultik-destination.txt`. It fails when
the file is reported missing while the local index holds snapshots. It skips
the check when the local index belongs to another destination.
- `checkStorageBinding` fails when the local index is bound to a different
destination from the configured one. It records nothing.
- `EnsureStorageBinding` binds an unbound local index to the configured
destination. Only `markDestination` calls it, after the marker file is
written, so a backup that writes nothing leaves the local index unbound.
Every command that takes the lock calls `checkStorageBinding` first.
## Concurrency
- Each open database uses one connection, so its queries run one at a time.
- The index runs in SQLite's write-ahead log (WAL) mode. A second process can
therefore read it while a backup writes to it.
- `Packer` guards its state with a mutex.
- `Scanner` holds `packerMu` while a chunk is added to the packer and while a
blob is closed.
- `Repositories.WithTx` runs a function in one transaction. It commits when the
function succeeds and rolls back when it fails.
+412 -254
View File
@@ -1,292 +1,450 @@
# Vaultik Data Model # vaultik data model
## Overview `vaultik` keeps a SQLite database on the backed-up machine, called the local
index. This document describes its tables, and how a backup, a restore and
`prune` use them.
Vaultik uses a local SQLite database to track file metadata, chunk mappings, and blob associations during the backup process. This database serves as an index for incremental backups and enables efficient deduplication. The local index has two jobs:
**Important Notes:** - It records which files, chunks and blobs are already stored. The next backup
then does not store them again.
- It is the source of each snapshot's own database. A backup uploads a trimmed,
encrypted copy of the index with every snapshot.
- **Version Compatibility**: In rare cases, you may need to use the same version A restore reads no file, chunk or blob rows from the local index. It reads the
of Vaultik to restore a backup as was used to create it. This ensures copy that was uploaded with the snapshot.
compatibility with the metadata format stored in S3.
## Schema Migrations ## Terms
Vaultik supports schema migrations. They are the numbered SQL files in - **Snapshot**: one backup of the paths listed under one name in the config.
`internal/database/schema/`, embedded in the binary: `000.sql` creates the - **Snapshot ID**: the name of a snapshot, in the form
`schema_migrations` table, which records each migration that has run, and `<hostname>_<snapshot-name>_<timestamp>`.
`001.sql` creates the application tables. `database.New` opens a database and - **Chunk**: a piece of a file's content, named by the SHA-256 hash of that
applies, in order, every migration that database has not yet recorded. content.
- **Blob**: one object at the destination. It holds many chunks, compressed and
then encrypted.
- **Destination**: the place the backups are stored.
- **Marker file**: `vaultik-destination.txt` at the destination. See
[The marker file](REPOSTRUCTURE.md#the-marker-file).
- **Manifest**: the unencrypted list of the blobs one snapshot uses.
- **Remote key**: a hash of the snapshot ID. It names the snapshot's directory
at the destination.
**Before 1.0** no migrations are added, because nothing is installed anywhere ## Where the index is
yet. A schema change edits `001.sql` (and the code that uses the affected
tables) directly. A local database created before the change has already
recorded `001.sql` as run, so it keeps the old schema and can become unusable;
`vaultik database delete` followed by a full backup rebuilds it.
**After 1.0** each schema change is a new numbered file, so an existing local
database is migrated the first time an updated vaultik opens it. A file that
has shipped in a release is never edited.
## Database Tables
### 1. `files`
Stores metadata about files in the filesystem being backed up.
**Columns:**
- `id` (TEXT PRIMARY KEY) - UUID for the file record
- `path` (TEXT NOT NULL UNIQUE) - Absolute file path
- `mtime` (INTEGER NOT NULL) - Modification time, whole seconds since the Unix epoch
- `mtime_nsec` (INTEGER NOT NULL) - Nanoseconds within that second, 0 to 999999999
- `size` (INTEGER NOT NULL) - File size in bytes
- `mode` (INTEGER NOT NULL) - Unix file permissions and type
- `uid` (INTEGER NOT NULL) - User ID of file owner
- `gid` (INTEGER NOT NULL) - Group ID of file owner
- `link_target` (TEXT) - Symlink target path (NULL for regular files)
**Indexes:**
- `idx_files_path` on `path` for efficient lookups
**Purpose:** Tracks file metadata to detect changes between backup runs. Used for incremental backup decisions. The UUID primary key provides stable references that don't change if files are moved.
### 2. `chunks`
Stores information about content-defined chunks created from files.
**Columns:**
- `chunk_hash` (TEXT PRIMARY KEY) - SHA256 hash of chunk content
- `size` (INTEGER NOT NULL) - Chunk size in bytes
**Purpose:** Enables deduplication by tracking unique chunks across all files. The index is the file `index.sqlite` in the platform data directory. By default
that is `~/.local/share/vaultik/` on Linux and
### 3. `file_chunks` `~/Library/Application Support/vaultik/` on macOS. The `index_path` setting and
Maps files to their constituent chunks in order. the `VAULTIK_INDEX_PATH` environment variable change the path. So does
`XDG_DATA_HOME`.
**Columns:**
- `file_id` (TEXT) - File ID (FK to files.id)
- `idx` (INTEGER) - Chunk index within file (0-based)
- `chunk_hash` (TEXT) - Chunk hash (FK to chunks.chunk_hash)
- PRIMARY KEY (`file_id`, `idx`)
**Purpose:** Allows reconstruction of files from chunks during restore. The file is readable only by its owner, because it lists every backed-up path.
`vaultik` sets that mode each time it opens the index.
### 4. `chunk_files` ## Schema migrations
Reverse mapping showing which files contain each chunk.
**Columns:** `vaultik` supports schema migrations. They are the numbered SQL files in
- `chunk_hash` (TEXT) - Chunk hash (FK to chunks.chunk_hash) `internal/database/schema/`, which are built into the binary.
- `file_id` (TEXT) - File ID (FK to files.id)
- `file_offset` (INTEGER) - Byte offset of chunk within file
- `length` (INTEGER) - Length of chunk in bytes
- PRIMARY KEY (`chunk_hash`, `file_id`)
**Purpose:** Supports efficient queries for chunk usage and deduplication statistics. - `000.sql` creates the `schema_migrations` table. That table records each
migration that has run.
- `001.sql` creates every other table.
### 5. `blobs` `database.New` opens the index. It then applies, in order, every migration that
Stores information about packed, compressed, and encrypted blob files. the index has not recorded.
**Columns:** Before version 1.0, no migration is added, because nothing is installed anywhere
- `id` (TEXT PRIMARY KEY) - UUID assigned when blob creation starts yet. A schema change edits `001.sql` directly, with the code that uses the
- `blob_hash` (TEXT UNIQUE) - `hex(SHA256(SHA256(uncompressed blob contents)))`, computed before compression and encryption (NULL until finalized); see [REPOSTRUCTURE.md](REPOSTRUCTURE.md#blobs-directory-blobs) changed tables.
- `created_ts` (INTEGER NOT NULL) - Creation timestamp
- `finished_ts` (INTEGER) - Finalization timestamp (NULL if in progress)
- `uncompressed_size` (INTEGER NOT NULL DEFAULT 0) - Total size of chunks before compression
- `compressed_size` (INTEGER NOT NULL DEFAULT 0) - Size after compression and encryption. Once uploaded, the size of the object in storage: when the blob was already stored, under another compression level or other recipients, this can differ from the size of the local copy
- `uploaded_ts` (INTEGER) - Upload completion timestamp (NULL if not uploaded)
**Purpose:** Tracks blob lifecycle from creation through upload. The UUID primary key allows immediate association of chunks with blobs. This has one consequence. An index created before such a change has already
recorded `001.sql` as run, so it keeps the old schema and can become unusable.
To rebuild it, run `vaultik database delete` and then a full backup.
### 6. `blob_chunks` After version 1.0, each schema change is a new numbered file. An existing index
Maps chunks to the blobs that contain them. is then migrated the first time an updated `vaultik` opens it. A file that was
part of a release is never edited.
**Columns:**
- `blob_id` (TEXT) - Blob ID (FK to blobs.id)
- `chunk_hash` (TEXT) - Chunk hash (FK to chunks.chunk_hash)
- `offset` (INTEGER) - Byte offset of chunk within blob (before compression)
- `length` (INTEGER) - Length of chunk in bytes
- PRIMARY KEY (`blob_id`, `chunk_hash`)
**Purpose:** Enables chunk retrieval from blobs during restore operations.
### 7. `snapshots`
Tracks backup snapshots.
**Columns:**
- `id` (TEXT PRIMARY KEY) - Snapshot ID (format: `hostname_name_timestamp`, e.g. `server1_home_2025-06-01T12:00:00Z`: the hostname up to its first `.`, the snapshot name, and an RFC 3339 UTC timestamp)
- `hostname` (TEXT) - Hostname where backup was created
- `vaultik_version` (TEXT) - Version of Vaultik used
- `vaultik_git_revision` (TEXT) - Git revision of Vaultik used
- `started_at` (INTEGER) - Start timestamp
- `completed_at` (INTEGER) - Completion timestamp (NULL if in progress)
- `file_count` (INTEGER) - Number of files in snapshot
- `chunk_count` (INTEGER) - Number of chunks this snapshot stored that were not stored before
- `new_chunk_size` (INTEGER) - Total size of those chunks before compression; `snapshot list` shows it as NEW CHUNK SIZE
- `blob_count` (INTEGER) - Number of blobs this snapshot created
- `total_size` (INTEGER) - Total size of all files
- `uncompressed_chunk_size` (INTEGER) - Total size before compression of the distinct chunks the snapshot's files reference, recorded when its backup ran; `snapshot list` shows it as UNCOMPRESSED SIZE
- `blob_size` (INTEGER) - Total compressed size of all referenced blobs
- `blob_uncompressed_size` (INTEGER) - Total uncompressed size of all referenced blobs
- `compression_ratio` (REAL) - Compression ratio achieved
- `compression_level` (INTEGER) - Compression level used for this snapshot
- `upload_bytes` (INTEGER) - Total bytes uploaded during this snapshot
- `upload_duration_ms` (INTEGER) - Total milliseconds spent uploading to S3
**Purpose:** Provides snapshot metadata and statistics including version tracking for compatibility.
### 8. `snapshot_files`
Maps snapshots to the files they contain.
**Columns:**
- `snapshot_id` (TEXT) - Snapshot ID (FK to snapshots.id)
- `file_id` (TEXT) - File ID (FK to files.id)
- PRIMARY KEY (`snapshot_id`, `file_id`)
**Purpose:** Records which files are included in each snapshot. Migrations apply to the local index only. A snapshot's database at the
destination is read as it was written. Each snapshot records the `vaultik`
version that wrote it.
### 9. `snapshot_blobs` ## Tables
Maps snapshots to the blobs they reference.
**Columns:** Times are whole seconds since the Unix epoch, in UTC. The two exceptions are
- `snapshot_id` (TEXT) - Snapshot ID (FK to snapshots.id) noted at their columns.
- `blob_id` (TEXT) - Blob ID (FK to blobs.id)
- `blob_hash` (TEXT) - Denormalized blob hash for manifest generation
- PRIMARY KEY (`snapshot_id`, `blob_id`)
**Purpose:** Tracks blob dependencies for snapshots and enables manifest generation.
### 10. `uploads`
Tracks blob upload metrics.
**Columns:**
- `blob_hash` (TEXT PRIMARY KEY) - Hash of uploaded blob
- `snapshot_id` (TEXT NOT NULL) - The snapshot that triggered this upload (FK to snapshots.id)
- `uploaded_at` (INTEGER) - Upload timestamp
- `size` (INTEGER) - Size of uploaded blob
- `duration_ms` (INTEGER) - Upload duration in milliseconds
**Purpose:** Performance monitoring and tracking which blobs were newly created (uploaded) during each snapshot.
## Data Flow and Operations
### 1. Backup Process
1. **File Scanning**
- `INSERT OR REPLACE INTO files` - Update file metadata
- `SELECT * FROM files WHERE path = ?` - Check if file has changed
- `INSERT INTO snapshot_files` - Add file to current snapshot
2. **Chunking** (for changed files)
- `INSERT OR IGNORE INTO chunks` - Store new chunks
- `INSERT INTO file_chunks` - Map chunks to file
- `INSERT INTO chunk_files` - Create reverse mapping
3. **Blob Packing**
- `INSERT INTO blobs` - Create blob record with UUID (blob_hash NULL)
- `INSERT INTO blob_chunks` - Associate chunks with blob immediately
- `UPDATE blobs SET blob_hash = ?, finished_ts = ?` - Finalize blob after packing
4. **Upload**
- `UPDATE blobs SET uploaded_ts = ?, compressed_size = ?` - Mark blob as uploaded, with the size of the object in storage (the upload is skipped when the blob is already stored)
- `INSERT INTO uploads` - Record upload metrics with snapshot_id
- `INSERT INTO snapshot_blobs` - Associate blob with snapshot
5. **Snapshot Completion**
- `UPDATE snapshots SET completed_at = ?, stats...` - Finalize snapshot
- Generate and upload blob manifest from `snapshot_blobs`
### 2. Incremental Backup
1. **Change Detection**
- `SELECT * FROM files WHERE path = ?` - Get previous file metadata
- Compare mtime, size, mode to detect changes
- Re-chunk a file that lists a chunk no uploaded blob holds, even when its metadata is unchanged
- Skip unchanged files but still add to `snapshot_files`
2. **Chunk Reuse**
- `SELECT * FROM blob_chunks WHERE chunk_hash = ?` - Find existing chunks
- `INSERT INTO snapshot_blobs` - Reference existing blobs for unchanged files
### 3. Snapshot Metadata Export
After a snapshot is completed:
1. Copy database to temporary file
2. Clean temporary database to contain only current snapshot data
3. VACUUM the trimmed database so deleted rows leave no pages behind
4. Compress with zstd and encrypt with age
5. Upload to S3 as `metadata/{remote-key}/db.zst.age`
6. Generate blob manifest and upload as `metadata/{remote-key}/manifest.json.zst`
The `{remote-key}` directory name is a one-way hash of the human snapshot ID, so the ID is never written to the store in plaintext. The hash uses no secret, so a guessed hostname and snapshot name can still be confirmed against a listing; see [REPOSTRUCTURE.md](REPOSTRUCTURE.md#remote-key-derivation) and its [Accepted Risks](REPOSTRUCTURE.md#accepted-risks).
### 4. Restore Process
The restore process doesn't use the local database. Instead: ### `files`
1. Downloads and decrypts the snapshot's metadata database (`db.zst.age`) from S3
2. Downloads the blobs holding the chunks of the files being restored, found through that database's `blob_chunks` table; the manifest is not read
3. Reconstructs files from decrypted and decompressed chunks
### 5. Pruning One row for each backed-up path: a regular file, a symlink or a directory.
1. **Identify Unreferenced Blobs** - `id`: a UUID. Primary key.
- Query blobs not referenced by any remaining snapshot - `path`: the absolute path. Unique.
- Delete from S3 and local database - `source_path`: the configured path under which the scan found this entry, made
absolute with symlinks resolved. A restore does not read it.
- `mtime`: the modification time.
- `mtime_nsec`: the nanoseconds within that second.
- `size`: the size in bytes.
- `mode`: the permission bits and the file type.
- `uid`: the owner's user ID.
- `gid`: the owner's group ID.
- `link_target`: the target of a symlink. It is empty for other entries.
### 6. Incomplete Snapshot Cleanup `idx_files_path` indexes `path`.
Before each backup: There is one row for each path, and it is updated when the file changes. The row
1. Query incomplete snapshots (where `completed_at IS NULL`) is shared by every snapshot in the local index that lists the path. An older
2. Delete each one and all its associations, without checking S3 for its metadata snapshot's own view of the file is in that snapshot's database at the
3. Clean up orphaned files, chunks, and blobs destination.
## Repository Pattern ### `chunks`
Vaultik uses a repository pattern for database access: One row for each distinct chunk.
- `FileRepository` - CRUD operations for files and file metadata - `chunk_hash`: the SHA-256 hash of the chunk's content, in hex. Primary key.
- `ChunkRepository` - CRUD operations for content chunks - `size`: the size in bytes.
- `FileChunkRepository` - Manage file-to-chunk mappings
- `ChunkFileRepository` - Manage chunk-to-file reverse mappings
- `BlobRepository` - Manage blob lifecycle (creation, finalization, upload)
- `BlobChunkRepository` - Manage blob-to-chunk associations
- `SnapshotRepository` - Manage snapshots and their relationships
- `UploadRepository` - Track blob upload metrics
Each repository provides methods like: ### `file_chunks`
- `Create()` - Insert new record
- `GetByID()` / `GetByPath()` / `GetByHash()` - Retrieve records
- `Update()` - Update existing records
- `Delete()` - Remove records
- Specialized queries for each entity type (e.g., `DeleteOrphaned()`, `GetIncompleteByHostname()`)
## Transaction Management The chunks that make up each file, in order.
All database operations that modify multiple tables are wrapped in transactions: - `file_id`: the file. References `files.id`.
- `idx`: the position of the chunk within the file, counted from 0.
- `chunk_hash`: the chunk. References `chunks.chunk_hash`.
The primary key is (`file_id`, `idx`). `idx_file_chunks_chunk_hash` indexes
`chunk_hash`.
### `chunk_files`
The reverse of `file_chunks`: the files that contain each chunk.
- `chunk_hash`: the chunk. References `chunks.chunk_hash`.
- `file_id`: the file. References `files.id`.
- `file_offset`: the byte offset of the chunk within the file.
- `length`: the length of the chunk in bytes.
The primary key is (`chunk_hash`, `file_id`). `idx_chunk_files_file_id` indexes
`file_id`.
### `blobs`
One row for each blob.
- `id`: a UUID, given when packing of the blob starts. Primary key.
- `blob_hash`: the name of the blob at the destination. Unique. Until the blob
is closed, it holds a placeholder that starts with `temp-placeholder-`.
- `created_ts`: when packing started.
- `finished_ts`: when the blob was closed. It is `NULL` before that.
- `uncompressed_size`: the total size of the blob's chunks.
- `compressed_size`: the size after compression and encryption.
- `uploaded_ts`: when the upload finished. It is `NULL` before that.
`blob_hash` is `hex(SHA256(SHA256(uncompressed contents)))`. See
[`REPOSTRUCTURE.md`](REPOSTRUCTURE.md#blobs).
Once a blob is uploaded, `compressed_size` is the size of the object at the
destination. A blob that was already there can have a different size from the
local copy. That happens when it was written with another compression level or
other recipients.
### `blob_chunks`
The chunks that each blob holds.
- `blob_id`: the blob. References `blobs.id`.
- `chunk_hash`: the chunk. References `chunks.chunk_hash`.
- `offset`: the byte offset of the chunk within the uncompressed blob.
- `length`: the length of the chunk in bytes.
The primary key is (`blob_id`, `chunk_hash`). `idx_blob_chunks_chunk_hash`
indexes `chunk_hash`.
A restore finds the blob for each chunk through this table.
### `snapshots`
One row for each snapshot.
- `id`: the snapshot ID. Primary key.
- `hostname`: the `hostname` setting of the config. When that setting is empty,
it is the machine's hostname.
- `vaultik_version`: the `vaultik` version that made it.
- `vaultik_git_revision`: the git commit of that version.
- `started_at`: when the backup started.
- `completed_at`: when the backup finished. It is `NULL` until then.
- `file_count`: the number of files and symlinks in the snapshot. Directories
are not counted.
- `chunk_count`: the number of chunks this snapshot stored that were not stored
before.
- `new_chunk_size`: the total size of those chunks before compression.
- `blob_count`: the number of blobs this snapshot created.
- `total_size`: the total size of all files in the snapshot.
- `uncompressed_chunk_size`: the total size, before compression, of the distinct
chunks the snapshot's files use.
- `blob_size`: the total compressed size of the blobs the snapshot uses.
- `blob_uncompressed_size`: the total uncompressed size of those blobs.
- `compression_ratio`: the compression ratio of those blobs.
- `compression_level`: the compression level the backup used.
- `upload_bytes`: the bytes uploaded during the backup.
- `upload_duration_ms`: the time spent uploading, in milliseconds.
The snapshot ID has the form `<hostname>_<snapshot-name>_<timestamp>`. An
example is `server1_home_2025-06-01T12:00:00Z`. It has three parts:
- the hostname, up to its first `.`
- the snapshot name
- the start time, in UTC
The `snapshot list` table shows `uncompressed_chunk_size` as `UNCOMPRESSED SIZE`
and `new_chunk_size` as `NEW CHUNK SIZE`.
A snapshot with `completed_at` set to `NULL` is unfinished. `snapshot list`
shows it only when its manifest is at the destination, and then as remote only.
The next backup deletes it, and so does `prune`.
### `snapshot_files`
The files in each snapshot.
- `snapshot_id`: the snapshot. References `snapshots.id`.
- `file_id`: the file. References `files.id`.
The primary key is (`snapshot_id`, `file_id`). `idx_snapshot_files_file_id`
indexes `file_id`.
### `snapshot_blobs`
The blobs that each snapshot uses.
- `snapshot_id`: the snapshot. References `snapshots.id`.
- `blob_id`: the blob. References `blobs.id`.
- `blob_hash`: a copy of the blob's hash.
The primary key is (`snapshot_id`, `blob_id`). `idx_snapshot_blobs_blob_id`
indexes `blob_id`.
The manifest is built from this table.
### `snapshot_mount_points`
The mount points that a snapshot's backup found.
- `snapshot_id`: the snapshot. References `snapshots.id`.
- `path`: the absolute path of the directory.
The primary key is (`snapshot_id`, `path`).
A directory is recorded here when all of these hold:
- It is at or below a configured path.
- The backup found an entry in it that is not excluded.
- It is on a different filesystem from its parent directory.
The device number is read after the directory has been listed. An automount
point is a directory whose volume is mounted when it is first listed, so the
number is that of the volume. An automount point whose volume cannot be mounted
is not recorded, because the backup finds no entry in it.
`snapshot create --prune` reads this table. It skips the prune when an earlier
snapshot of the same name recorded a mount point that the new backup did not
find. That happens when the volume is not mounted.
### `uploads`
One row for each blob that a backup uploaded.
- `blob_hash`: the blob. References `blobs.blob_hash`. Primary key.
- `snapshot_id`: the snapshot whose backup uploaded it. References
`snapshots.id`.
- `uploaded_at`: when the upload started. It is written as a date and time, not
as seconds.
- `size`: the size of the uploaded blob.
- `duration_ms`: the duration of the upload, in milliseconds.
`idx_uploads_snapshot_id` indexes `snapshot_id`.
A blob that was already at the destination gets no row here.
### `local_meta`
Settings that belong to this copy of the index.
- `key`: the name of the setting. Primary key.
- `value`: its value.
The only key is `storage_url`. A backup sets it once it has written the marker
file at the destination. No other command sets it. These commands refuse to run
when the config names a different destination:
- `snapshot create`
- `snapshot purge`
- `snapshot remove`
- `prune`
- `remote nuke`
### `schema_migrations`
The migrations that have run.
- `version`: the number of the migration. Primary key.
- `applied_at`: when it ran. It is written as a date and time, not as seconds.
## How commands use the tables
### Backup
A backup works on the tables in this order:
- It saves the destination in `local_meta` when the index has none yet. It does
so once it has written the marker file at the destination.
- It deletes unfinished snapshots, and the rows that no snapshot uses.
- It creates a row in `snapshots`, with `completed_at` set to `NULL`.
- It deletes the `blobs` rows that were never uploaded.
- It walks each path. It compares each regular file and symlink with its row in
`files`.
- It links each unchanged file to the snapshot in `snapshot_files`.
- It writes a `files` row and a `snapshot_files` row for each directory.
- It records each mount point in `snapshot_mount_points`.
- It reads each changed regular file and stores it. See below. A changed symlink
is recorded without being read.
- It fills `snapshot_blobs` with every blob the snapshot uses.
- It stores the snapshot's totals in `snapshots`.
- It uploads the snapshot's metadata.
- It sets `completed_at`.
A file counts as changed when its row in `files` differs from the file on disk
in any of these:
- size
- modification time
- mode
- owner or group
- link target
A file is also read again when it lists a chunk that no uploaded blob holds. A
regular file with no row in `files` is read too.
Storing a changed file writes these rows:
- A row in `blobs`, when packing of a blob starts.
- When the blob is closed, in one transaction:
- a row in `chunks` for each chunk queued since the last blob was closed.
When a blob is full, that includes the first chunk of the next blob.
- the blob's rows in `blob_chunks`
- the blob's `blob_hash`, sizes and `finished_ts`
- When the blob is stored at the destination, in one transaction:
- `uploaded_ts` and `compressed_size` in `blobs`
- a row in `snapshot_blobs`
- a row in `uploads`, unless the blob was already at the destination
- The row in `files`, and its rows in these tables:
- `file_chunks`
- `chunk_files`
- `snapshot_files`
The row in `files` is written last. It is written only after every blob that
holds the file's new chunks is stored.
### Snapshot metadata
The metadata of a snapshot is made from a copy of the index:
- The index is copied into a temporary directory with `VACUUM INTO`.
- The rows of every other snapshot are deleted from the copy. So are the files
and blobs that this snapshot does not use. A chunk is then deleted when no
remaining file and no remaining blob uses it.
- The `local_meta` and `schema_migrations` tables are kept as they are. The
uploaded database therefore names the destination, and lists the migrations
that have run.
- `VACUUM` rewrites the copy, so that no deleted row is left in the file.
- The copy is compressed with [zstd](https://github.com/facebook/zstd) and
encrypted with [age](https://age-encryption.org/).
- The manifest is built from the copy's `snapshot_blobs` table.
- The copy is uploaded as `metadata/<remote-key>/db.zst.age`.
- The manifest is uploaded as `metadata/<remote-key>/manifest.json.zst`.
The copy is made through SQLite, not by copying the file. The index runs in
SQLite's write-ahead log (WAL) mode, so rows that are already committed can
still be in the `-wal` file beside it.
### Restore
A restore reads no file, chunk or blob rows from the local index. It reads
`snapshots` and `local_meta` there, to look up a snapshot ID and to check the
marker file.
- It downloads and decrypts `db.zst.age` into a temporary directory.
- It opens that file read-only. It applies no migration to it.
- It reads the snapshot's row in `snapshots`, to check that this is the right
database.
- It reads the files to restore from `files`.
- It finds each file's chunks in `file_chunks`.
- It finds the blob and offset of each chunk in `blob_chunks`.
- It finds the name of each blob in `blobs`.
- With `--verify`, it reads each chunk's size from `chunks`.
It does not read the manifest.
### Prune
`prune` saves nothing in `local_meta`. It refuses to run when `local_meta` holds
a different destination from the configured one. It changes the local index in
two ways:
- It deletes each snapshot whose manifest is gone from the destination.
- It deletes unfinished snapshots, and the rows that no snapshot uses.
The second step removes unused rows in this order:
- files that are in no snapshot
- blobs that are in no snapshot
- `blob_chunks` rows whose chunk is gone
- chunks that no file and no blob uses
`prune` then deletes unused data at the destination. That step reads the
manifests, not the local index. It reads the local index only to keep a
directory under `metadata/` whose snapshot is still recorded there.
## Code
Each repository type in `internal/database` serves one or more tables:
- `FileRepository`: `files`
- `ChunkRepository`: `chunks`
- `FileChunkRepository`: `file_chunks`
- `ChunkFileRepository`: `chunk_files`
- `BlobRepository`: `blobs`
- `BlobChunkRepository`: `blob_chunks`
- `SnapshotRepository`:
- `snapshots`
- `snapshot_files`
- `snapshot_blobs`
- `snapshot_mount_points`
- `uploads`, where it deletes a snapshot's rows
- `UploadRepository`: `uploads`
- `LocalMetaRepository`: `local_meta`
No repository type serves `schema_migrations`. The code that applies the
migrations reads and writes it.
`Repositories` holds one of each type.
`Repositories.WithTx` runs a function in one transaction:
```go ```go
err := repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error { err := repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
// Multiple repository operations using tx // several repository calls that use tx
}) })
``` ```
This ensures consistency, especially important for operations like: The transaction commits when the function returns no error. Otherwise it is
- Creating file-chunk mappings rolled back.
- Associating chunks with blobs
- Updating snapshot statistics
## Performance Considerations ## SQLite settings
1. **Indexes**: - The index runs in WAL mode. A command that only reads, such as
- Primary keys are automatically indexed `snapshot list`, can read the index while a backup writes to it.
- `idx_files_path` on `files(path)` for efficient file lookups - A connection waits up to 10 seconds for a lock before it fails.
- Foreign keys are enforced.
2. **Prepared Statements**: All queries use prepared statements for performance and security - Each open database uses one connection, so its queries run one at a time.
- Queries pass their values as parameters.
3. **Batch Operations**: Where possible, operations are batched within transactions
4. **Write-Ahead Logging**: The local index runs in SQLite WAL mode with a 10-second busy timeout, so a read-only command such as `snapshot list` can read it while a backup writes to it. Committed rows can sit in the `-wal` file beside the index until a checkpoint, so the metadata export copies the index through SQLite (`VACUUM INTO`), not as a file
## Data Integrity
1. **Foreign Keys**: Enforced through CASCADE DELETE and application-level repository methods
2. **Unique Constraints**: Chunk hashes, file paths, and blob hashes are unique
3. **Null Handling**: Nullable fields clearly indicate in-progress operations
4. **Timestamp Tracking**: All major operations record timestamps for auditing
+389 -119
View File
@@ -1,177 +1,447 @@
# Vaultik S3 Repository Structure # vaultik destination layout
This document describes the structure and organization of data stored in the S3 bucket by Vaultik. This document describes what `vaultik` stores at the destination. It covers the
layout, what the layout protects, and the risks it accepts.
## Overview The destination is the place the backups are stored. It is an S3 bucket and
prefix, a local directory or an [rclone](https://rclone.org/) remote. A remote
is a storage service set up in rclone. The layout is the same on all three.
Vaultik stores all backup data in an S3-compatible object store. The repository consists of two main components: ## Terms
1. **Blobs** - The actual backup data (content-addressed, encrypted)
2. **Metadata** - Snapshot information and manifests (partially encrypted)
## Directory Structure - **Snapshot**: one backup of the paths listed under one name in the config.
- **Snapshot ID**: the name of a snapshot, in the form
`<hostname>_<snapshot-name>_<timestamp>`.
- **Remote key**: a hash of the snapshot ID. It names the snapshot's directory
under `metadata/`. See [Remote keys](#remote-keys).
- **Chunk**: a piece of a file's content, named by the SHA-256 hash of that
content.
- **Blob**: one object under `blobs/`. It holds many chunks.
- **Local index**: the SQLite database on the backed-up machine.
- **Recipient**: an [age](https://age-encryption.org/) public key that backups
are encrypted to.
## Layout
``` ```
<bucket>/<prefix>/ <destination>/
├── vaultik-destination.txt
├── blobs/ ├── blobs/
│ └── <hash[0:2]>/ │ └── <hash[0:2]>/
│ └── <hash[2:4]>/ │ └── <hash[2:4]>/
│ └── <full-hash> │ └── <hash>
└── metadata/ └── metadata/
└── <remote-key>/ └── <remote-key>/
├── db.zst.age ├── db.zst.age
└── manifest.json.zst └── manifest.json.zst
``` ```
The metadata subdirectory is named with the **remote key**, a one-way hash of the snapshot ID, not with the human-readable snapshot ID itself. See [Remote Key Derivation](#remote-key-derivation). The destination holds three things:
## Blobs Directory (`blobs/`) - `vaultik-destination.txt`, the marker file
- `blobs/`, which holds the file contents
- `metadata/`, which holds one directory for each snapshot
### Structure ## The marker file
- **Path format**: `blobs/<first-2-chars>/<next-2-chars>/<full-hash>`
- **Example**: `blobs/ca/fe/cafebabe1234567890abcdef1234567890abcdef1234567890abcdef12345678`
- **Sharding**: The two-level directory structure (using the first 4 characters of the hash) prevents any single directory from containing too many objects
### Content Every backup writes `vaultik-destination.txt` before it uploads anything else.
- **What it contains**: Packed collections of content-defined chunks from files The file holds one fixed line of text. It names no host, path or time.
- **Format**: Zstandard compressed, then Age encrypted
- **Encryption**: Always encrypted with Age using the configured recipients
- **Naming**: Content-addressed. The blob's name is `hex(SHA256(SHA256(uncompressed blob contents)))` — the double SHA-256 of the concatenated chunk data, computed before compression and encryption, not a hash of the stored (compressed, encrypted) bytes. One consequence: only a holder of the age private key can check a stored blob's integrity, because matching a blob to its name means decrypting and decompressing it first — which is what `restore` and `verify --deep` do. Implemented in `internal/blobgen` (`DoubleSHA256`). This is the canonical description of blob naming; other documents and comments point here.
### Why Encrypted The marker tells an emptied or missing destination apart from a new one. An
Blobs contain the actual file data from backups and must be encrypted for security. The content-addressing ensures deduplication while the encryption ensures privacy. unplugged volume leaves an empty directory at its mount point. Without the
marker, that directory would look like a destination whose snapshots are all
gone.
## Metadata Directory (`metadata/`) The check applies while the local index holds snapshots and belongs to the
configured destination. When the destination then answers that the marker does
not exist, commands treat the destination as not present:
Each snapshot has its own subdirectory. The directory is **not** named with the human-readable snapshot ID; it is named with the remote key — a one-way hash of that ID. The human ID is never written to the destination store as a directory name (see [Remote Key Derivation](#remote-key-derivation)). - `snapshot create` refuses to write to it.
- These commands fail:
- `prune`
- `snapshot purge`
- `remote nuke`
- `snapshot verify` and `snapshot restore`, when given a remote key, or a
snapshot ID that is not in the local index
- `snapshot list` shows the local index alone.
- `snapshot remove` deletes nothing at the destination.
### Snapshot ID Format The check is skipped when the look-up fails for any other reason. S3 refuses the
look-up to credentials that can only write. A machine with such credentials
therefore does not notice an emptied destination. Its next backup would not
upload data that its local index says is stored. After a destination is emptied,
run `vaultik database delete` on such a machine before it backs up again.
The human-readable snapshot ID is used in CLI arguments, log lines, and the local database. It is not written to the destination store. `remote nuke` deletes the marker last.
- **Format**: `<hostname>_<snapshot-name>_<RFC3339>` (or `<hostname>_<RFC3339>` if no
name was specified)
- **Example**: `laptop_home_2024-01-15T14:30:52Z`
- **Components**:
- Short hostname (everything before the first dot is stripped from the FQDN)
- Snapshot name from the configured `snapshots:` map (optional)
- RFC3339 UTC timestamp
This ID reveals the hostname, the configured snapshot name, and the backup time, so it is never used as the on-disk directory name — the remote key is used instead. ## Blobs
### Remote Key Derivation A blob holds the content of many chunks, one after another.
The remote key is `hex(SHA256(SHA256("vaultik|" + snapshot-id)))`: a double SHA-256 over the snapshot ID, with a `vaultik|` domain-separation prefix. The result is a 64-character hex string. The hash is not reversible, but it uses no secret: an observer who guesses a candidate hostname and snapshot name can hash it the same way and confirm whether that snapshot is present. The remote key keeps names out of a plain listing; it does not hide them from a guess. Implemented in `internal/snapshot/remotekey.go`. - **Path**: `blobs/<first 2 characters>/<next 2 characters>/<full hash>`.
- **Example**:
`blobs/ca/fe/cafebabe1234567890abcdef1234567890abcdef1234567890abcdef12345678`.
- **Format**: the chunk data, compressed with
[zstd](https://github.com/facebook/zstd) and then encrypted with age.
- **Recipients**: the public keys in the `age_recipients` setting when the blob
was written.
The two directory levels keep any one directory from holding too many objects.
### Blob names
A blob's name is `hex(SHA256(SHA256(uncompressed contents)))`. The contents are
the chunk data, joined in order, before compression and encryption. The name is
not a hash of the stored bytes.
This has three consequences:
- Checking a blob against its name needs the private key. The blob must be
decrypted and decompressed first. `snapshot restore` and
`snapshot verify --deep` do this.
- Without the key, only a blob's presence and size can be checked.
`snapshot verify` does this.
- Someone who has the whole contents of a blob can compute its name, and so see
whether it is stored. This works for a file they have that fills blobs on its
own.
`blobgen.DoubleSHA256` in `internal/blobgen` computes the name.
### Uploads
A backup looks a blob up by name before it uploads it. It does not upload a blob
that is already there.
On S3, when the destination refuses the look-up, the backup uploads the blob. An
object with that name is then replaced by one that holds the same chunk data.
## Metadata
Each snapshot has one directory under `metadata/`. The directory is named with
the snapshot's remote key, not with its snapshot ID.
### Snapshot IDs
A snapshot ID has the form `<hostname>_<snapshot-name>_<timestamp>`. An example
is `laptop_home_2024-01-15T14:30:52Z`. It has three parts:
- the hostname, up to its first `.`
- the snapshot name from the config
- the time the backup started, in UTC
The snapshot ID is used on the command line, in log records and in the local
index. It reveals the hostname, the snapshot name and the backup time. It is
therefore never written to the destination without encryption.
### Remote keys
The remote key is `hex(SHA256(SHA256("vaultik|" + snapshot ID)))`. It is 64 hex
characters long. `internal/snapshot/remotekey.go` computes it.
For example:
Worked example:
- Snapshot ID: `server1_home_2025-06-01T12:00:00Z` - Snapshot ID: `server1_home_2025-06-01T12:00:00Z`
- Remote key: `17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa` - Remote key: `17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa`
- Directory: `metadata/17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa/` - Directory:
`metadata/17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa/`
A plain listing of the destination store therefore shows only these hashes, not the hostname or snapshot name of any backup — but because the hash uses no secret, a guessed hostname and snapshot name can be hashed and confirmed against the listing. The same remote key is stored in the manifest's `snapshot_id` field. A listing of the destination therefore shows no hostname and no snapshot name.
### Files in Each Snapshot Directory The hash cannot be reversed, but it uses no secret. Someone who guesses a
snapshot ID can hash it the same way and see whether that snapshot exists. The
remote key keeps names out of a listing. It does not hide them from a guess.
#### `db.zst.age` - Encrypted Database ### `db.zst.age`
- **What it contains**: Pruned binary SQLite database for this snapshot
- **Format**: Binary SQLite → Zstandard compressed → Age encrypted The snapshot's database. A restore needs it.
- **Encryption**: Encrypted with Age
- **Purpose**: Contains full file metadata, chunk mappings, and all relationships - **Contents**: a SQLite database. It is a copy of the local index without the
- **Why encrypted**: Contains sensitive metadata like file paths, permissions, and ownership rows of other snapshots. It lists every file in the snapshot with its
metadata, its chunks, and the blob that holds each chunk.
- **Format**: the database file, compressed with zstd and then encrypted with
age.
It is encrypted because it holds file paths, permissions and owners.
[`DATAMODEL.md`](DATAMODEL.md) describes its tables.
### `manifest.json.zst`
The list of the blobs the snapshot uses.
- **Format**: JSON, compressed with zstd. It is not encrypted.
#### `manifest.json.zst` - Unencrypted Blob Manifest
- **What it contains**: JSON list of all blob hashes referenced by this snapshot
- **Format**: JSON → Zstandard compressed (NOT encrypted)
- **Encryption**: NOT encrypted
- **Purpose**: Enables pruning operations without requiring decryption keys
- **Structure**:
```json ```json
{ {
"snapshot_id": "17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa", "snapshot_id": "17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa",
"timestamp": "2025-06-01T12:00:00Z", "timestamp": "2025-06-01T12:04:31Z",
"blob_count": 42, "blob_count": 2,
"total_compressed_size": 1048576, "total_compressed_size": 57344,
"blobs": [ "blobs": [
{ "hash": "cafebabe1234567890abcdef1234567890abcdef1234567890abcdef12345678", "compressed_size": 24576 }, {
{ "hash": "deadbeef1234567890abcdef1234567890abcdef1234567890abcdef12345678", "compressed_size": 32768 } "hash": "cafebabe1234567890abcdef1234567890abcdef1234567890abcdef12345678",
"compressed_size": 24576
},
{
"hash": "deadbeef1234567890abcdef1234567890abcdef1234567890abcdef12345678",
"compressed_size": 32768
}
] ]
} }
``` ```
`snapshot_id` is the remote key (a hash), not the human ID; `timestamp` is written in the clear.
### Why Manifest is Unencrypted - `snapshot_id`: the remote key, not the snapshot ID.
The manifest must be readable without the private key to enable: - `timestamp`: when the manifest was written, at the end of the backup.
1. **Pruning operations** - Identifying unreferenced blobs for deletion - `blob_count`: the number of blobs.
2. **Storage analysis** - Understanding space usage without decryption - `total_compressed_size`: the total size of those blobs, in bytes.
3. **Verification** - Checking blob existence without decryption - `blobs`: the hash and the size of each blob.
4. **Cross-snapshot deduplication analysis** - Finding shared blobs between snapshots
The manifest contains the remote key, the backup timestamp, the blob count and total compressed size, and each blob's hash and compressed size. It contains no file names, paths, or other decrypted metadata. The manifest holds no file name and no path.
## Security Considerations The manifest is not encrypted so that these commands work without the private
key:
### What's Encrypted - `prune`, which must know which blobs are still in use
- **All file content** (in blobs) - `snapshot verify`, which checks that each blob is present
- **All file metadata** (paths, permissions, timestamps, ownership in db.zst.age) - `snapshot list` and `remote info`, which report what the destination holds
- **File-to-chunk mappings** (in db.zst.age)
### What's Not Encrypted ## What is protected
- **The remote key** — directory names and the manifest `snapshot_id`, a one-way hash of the snapshot ID (see [Remote Key Derivation](#remote-key-derivation))
- **The backup timestamp** (in manifest.json.zst)
- **Blob hashes and their compressed sizes** (in manifest.json.zst)
- **Blob count and total compressed size per snapshot** (in manifest.json.zst)
### Privacy Implications These are encrypted:
From the unencrypted data, an observer of the destination store can determine:
- **When each backup was taken** — not from the directory name, which is a one-way hash, but from the plaintext `timestamp` field in manifest.json.zst, which is published in the clear
- How many blobs each snapshot references, and the total compressed size
- The compressed size of each blob, and which blobs are shared between snapshots (deduplication patterns)
- **Whether a guessed hostname and snapshot name are present** — the remote key is an unkeyed hash, so an observer holding candidate names can hash each one and match it against the directory listing. The human ID is never published, so it cannot be read off directly, but it can be confirmed by guessing.
Together these give an observer a timing-and-size profile of every snapshot. This is an accepted, documented property of the format, not a defect: the manifest is unencrypted so that pruning can run without the private key, and the timing channel could not be closed by encrypting it anyway — object creation times and per-object sizes stay visible at the storage layer on both `s3://` and `file://` destinations regardless. - file contents, in the blobs
- in `db.zst.age`:
- file names and paths
- permissions and owners
- modification times
- which chunks make up which file
An observer cannot determine: These are not encrypted:
- The hostname or snapshot name of any backup by reading it off the store — the directory name and the manifest `snapshot_id` are unkeyed hashes of the human ID, so the text is never published (though a guessed name can be confirmed, as above)
- File names or paths
- File contents
- File permissions or ownership
- Directory structure
- Which chunks belong to which files
### Accepted Risks - the remote key of each snapshot
- the time each backup finished, in the manifest
- the hash and the compressed size of each blob, in the manifest
- the number of blobs and their total size for each snapshot, in the manifest
These are known, deliberate properties of the format and the tooling, recorded so an operator can weigh them rather than discover them. Someone who can read the destination therefore learns these:
1. **No proof of authorship.** Restore and `verify --deep` prove that data decrypts with the age private key and matches its unkeyed content hashes. They do not prove who wrote it: anyone who knows a recipient public key and can replace objects on the destination can substitute a snapshot they built. The recipient string is not stored at the destination, but a compromised backed-up host has it. Defences live on the destination side — bucket versioning or object lock, credentials for the source host that cannot delete or overwrite existing versions, and pruning from a trusted host. Note that S3 `PutObject` overwrites an existing key, so PUT permission alone is not append-only. - when each backup finished
2. **Compression reveals sizes.** Blobs and `db.zst.age` are zstd-compressed then age-encrypted; the manifest is compressed only. age does not pad, so an object's size is the exact compressed length of its contents. All new chunks packed into one blob share a single zstd stream (8 MiB window, 4 MiB at compression levels 1-2), and a blob is closed at `blob_size_limit` and at the end of each configured path. Because a stored chunk is never packed again, someone who can write into a backed-up file and watch blob sizes learns something only when their controlled data and a secret land in the same chunk of a file that keeps changing. Advice: back up any outsider-writable directory as its own snapshot. - how many blobs each snapshot uses, and how large they are
3. **Chunking uses no secret.** The FastCDC parameters are fixed and public. The default 10 MB average yields chunks between 2.5 MB and 40 MB, and any file of 2.5 MB or less is a single chunk. At the default 10 GB `blob_size_limit` a blob holds hundreds of chunks, so individual chunk lengths are not visible in the blob's size; lowering the limit toward the chunk size begins to expose them. - which blobs two snapshots share
4. **Decrypted data on local disk.** Several commands stage plaintext under `$TMPDIR`: `snapshot restore` writes decrypted blobs under `vaultik-blobcache-*/` (no size cap) and the decrypted metadata database at `vaultik-restore-*/snapshot.db`; `verify --deep` writes that database at `vaultik-verify-*/snapshot.db`; `snapshot create` keeps a plaintext copy of the index at `vaultik-snapshot-*/snapshot.db`. These files are created `0600` and removed on success, but a `kill -9` or a power loss leaves them behind — delete any leftover `vaultik-*` directory under `$TMPDIR` by hand. `$TMPDIR` should be trusted to the same degree as the restore target. - whether a blob holds data they already have in full
5. **Store permissions differ per command.** The backed-up host needs only PUT to run `snapshot create`: it writes blobs and metadata and neither reads nor deletes them. Other commands need more — `snapshot verify`, `snapshot restore`, and `prune` list and read; `prune`, `snapshot purge`, `snapshot remove`, and `remote nuke` also delete. The recommended cron line uses `--prune`, which runs `prune` on the backed-up host, so granting that host `--prune` gives it credentials that can delete its own backups. To keep the source host to PUT only, prune from a separate trusted host instead. - whether a snapshot ID they guess exists
6. **Changing recipients does not re-encrypt existing data.** Deduplicated chunks and same-named blobs already on the destination stay encrypted to the recipients in force when they were written. A new snapshot that reuses them cannot be restored with a newly added recipient's key alone, because those reused objects were never encrypted to it. To make everything readable by a new key, run `vaultik database delete` and take a full backup to a fresh destination or prefix.
7. **X25519 recipients only.** vaultik rejects age ssh and plugin recipients. Long-lived ciphertext held by a third party (the destination operator) has no fallback if X25519 is ever broken: there is no second recipient type and no post-quantum option.
## Consistency Guarantees They do not learn these:
1. **Blobs are immutable** - Once written, a blob is never modified - any hostname or snapshot name, unless they guess it
2. **Blobs are written before metadata** - A snapshot's metadata is only written after all its blobs are successfully uploaded - file names or paths
3. **Metadata is written atomically** - Both db.zst.age and manifest.json.zst are written as complete files - file contents
4. **A snapshot is marked complete in the local DB only after its metadata is uploaded** - `finalizeSnapshotMetadata` runs `ExportSnapshotMetadata` first and records completion (`MarkSnapshotComplete`) only once the export succeeds (see the backup data flow in [ARCHITECTURE.md](../ARCHITECTURE.md)). A crash during the export therefore leaves the snapshot incomplete, so the next backup's `PruneDatabase` drops it and re-backs-up its data, rather than leaving a completed-looking row in the local index with no matching metadata on the destination store. (A crash in the brief moment after the export succeeds but before completion is recorded leaves a fully-restorable snapshot on the destination that the local index drops as incomplete on the next run; `snapshot list` then reports it honestly as remote-only, which is the safe direction: the destination copy stays restorable.) - permissions or owners
- the directory structure
- which chunks belong to which file
## Pruning Safety The times and sizes could not be hidden by encrypting the manifest. The
destination itself shows when each object was written and how large it is.
The prune operation is safe because: ## Accepted risks
1. It keeps every blob listed in any snapshot's manifest and deletes only blobs that no manifest references
2. Manifests are unencrypted and can be read without keys
3. If any manifest cannot be downloaded or decoded, prune deletes nothing and exits with an error, rather than treating that snapshot's blobs as unreferenced
4. Prune requires exclusive access to the destination: running it during a concurrent backup can race a snapshot whose manifest is not yet written, so do not prune while a backup is in progress
## Restoration Requirements These are known properties of the format. They are written down so that you can
weigh them.
To restore from a backup, you need: ### No proof of who wrote the data
1. **The Age private key** - To decrypt blobs and database
2. **The snapshot metadata** - Both files from the snapshot's metadata directory
3. **All referenced blobs** - As listed in the manifest
The restoration process: A restore and `snapshot verify --deep` prove two things. The data decrypts with
1. Download and decrypt the database dump to understand file structure your private key, and it matches its hashes. They do not prove who wrote it.
2. Download and decrypt the required blobs
3. Reconstruct files from their chunks The hashes use no secret. Someone who knows a recipient's public key and can
4. Restore file metadata (permissions, timestamps, etc.) replace objects at the destination can put a snapshot of their own there. The
public key is not stored at the destination, but a backed-up machine that is
broken into has it.
The defences are on the destination's side:
- object versioning or object lock on the bucket
- credentials for the backed-up machine that cannot delete or overwrite
- running `prune` from a trusted machine
An S3 `PutObject` replaces an existing object. Permission to write alone
therefore does not stop overwrites.
### Sizes are visible
An object's size follows exactly from the size of its compressed contents.
All the new chunks in one blob share one zstd stream. The stream looks back 8
MiB, or 4 MiB at compression levels 1 and 2. A blob is closed at the
`blob_size_limit` setting and at the end of each configured path. A chunk that
the local index records as uploaded is never packed again.
Data that an outsider writes compresses smaller when it matches a secret near it
in the same blob, so the blob gets smaller. The blob's size therefore shows the
outsider whether their data matched the secret.
This works only when all of these hold:
- The outsider can write into a backed-up file.
- The outsider can see the blob sizes at the destination.
- The outsider's data and the secret are in the same blob, no further apart than
the stream looks back.
- The file keeps changing.
Back up a directory that outsiders can write to as a snapshot of its own.
### Chunk boundaries use no secret
`vaultik` finds chunk boundaries with the FastCDC algorithm. Its parameters are
fixed and public. `chunk_size` sets the average chunk size. No chunk is larger
than four times this size. Only a file's last chunk can be smaller than a
quarter of it. With the default of 10 MB, those limits are 40 MB and 2.5 MB. A
file of 2.5 MB or less is one chunk. An empty file has no chunk.
At the default `blob_size_limit` of 10 GB, a full blob holds hundreds of chunks.
The size of the blob then does not show the size of any one chunk. A limit close
to `chunk_size` begins to show chunk sizes.
### Decrypted data on the local disk
These commands write unencrypted data to the temporary directory:
- `snapshot restore` writes decrypted blobs under `vaultik-blobcache-*/`. There
is no limit on their total size.
- `snapshot restore` writes the decrypted database to
`vaultik-restore-*/snapshot.db`.
- `snapshot verify --deep` writes the decrypted database to
`vaultik-verify-*/snapshot.db`.
- `snapshot create` writes a copy of the local index to
`vaultik-snapshot-*/snapshot.db`.
These files are readable only by their owner, and are removed when the command
ends. A `kill -9` or a power loss leaves them behind. Delete any leftover
`vaultik-*` entry in the temporary directory yourself.
Trust the temporary directory as much as the directory you restore into.
### Commands need different permissions
`snapshot create` without `--prune` writes objects. On S3 it needs no other
permission. Other commands need more:
- These commands list and read:
- `snapshot list`
- `snapshot verify`
- `snapshot restore`
- `remote info`
- These commands list and delete:
- `snapshot purge`
- `snapshot remove`
- `remote nuke`
- `prune` lists, reads and deletes.
An rclone remote that can move objects also needs permission to read and delete.
An upload there looks up the object it replaces and deletes it. It stops when
the look-up is refused.
`snapshot verify` and `snapshot restore` list only when they are given a remote
key, or a snapshot ID that is not in the local index.
`snapshot remove --local-only` does not reach the destination.
`snapshot remove --dry-run` deletes nothing.
`snapshot create --prune` runs a prune on the backed-up machine. That machine
then holds credentials that can delete its own backups. To keep it to write-only
credentials, run `prune` from a separate, trusted machine. `prune` there deletes
only data that no snapshot uses. It does not remove old snapshots.
### New recipients cannot read old data
Changing `age_recipients` does not re-encrypt what is already stored. A chunk
that the local index records as uploaded is not written again, so it stays
encrypted to the earlier recipients.
A new snapshot that uses such chunks cannot be restored with a new recipient's
key alone. To make everything readable by a new key, run
`vaultik database delete` and take a full backup to a new destination.
### X25519 recipients only
`vaultik` accepts age X25519 public keys only. It rejects age SSH and plugin
recipients.
The encrypted data is held by a third party for a long time. There is no second
kind of recipient to fall back on if X25519 is broken, and no kind that resists
a quantum computer.
## Order of writes
A backup writes in a fixed order:
- the marker file
- the blobs
- `db.zst.age`
- `manifest.json.zst`
A snapshot's metadata is written only after all its blobs are uploaded. The
manifest is written last. A directory under `metadata/` that has no manifest
therefore belongs to a backup that did not finish.
The local index marks a snapshot complete only after both metadata files are
uploaded. A backup that stops during that upload leaves the snapshot unfinished.
The next backup removes it from the local index and backs up its files again.
One short gap remains. A backup can stop after the upload and before the
snapshot is marked complete. The destination then holds a snapshot that can be
restored, and the local index marks it unfinished. `snapshot list` shows it as
remote only.
An upload that is cut off leaves no partial object under the final name on S3 or
on a local directory. The same holds for an rclone remote that can move objects.
An rclone remote that cannot move objects can keep a partial object.
## Pruning
`prune` deletes two kinds of object at the destination:
- a blob that no manifest lists
- a directory under `metadata/` that has no manifest
It reads the manifests, which are not encrypted, so it needs no private key.
These rules keep it from deleting data that is in use:
- It keeps every blob that any manifest lists.
- It deletes nothing when any manifest cannot be downloaded or decoded.
- It deletes a blob only once the blob is older than `prune_grace_period`.
- It deletes a directory only once every object in it is that old.
- It keeps a directory whose snapshot is in the local index.
- It leaves alone a directory whose name is not a remote key.
`prune_grace_period` defaults to `24h`. It protects a backup that is still
running. Such a backup has uploaded blobs and perhaps `db.zst.age`, but not yet
its manifest. A prune from this machine or another keeps those objects, as long
as no backup takes longer than the grace period.
The grace period does not protect one case. A backup does not upload a blob that
is already at the destination, so that blob keeps the time of its first upload.
The blob can be left over from an earlier backup that stopped before its
manifest. A prune that runs before the new manifest is written can then delete
that blob. The new snapshot cannot be restored afterwards.
To avoid this case, give each machine and each user its own destination.
`remote nuke` applies none of these rules.
## What a restore needs
- the age private key
- the snapshot's `db.zst.age`
- the blobs that hold the files to restore
A restore does not read the manifest, or the file, chunk and blob rows of the
local index.
It works in these steps:
- It downloads and decrypts `db.zst.age`.
- It checks that the database is the one for the snapshot that was asked for.
- It downloads and decrypts the blobs it needs. It checks each blob against its
name.
- It writes each file from its chunks.
- It sets each file's permissions and times, and its owner when it runs as root.
+80
View File
@@ -0,0 +1,80 @@
# Workflow
- branch (from `main`)
- do the work in Next Step
- delete Next Step from `TODO.md`
- move the top item of Future Steps into Next Step
- commit (`TODO.md` changes in the same commit as the work)
- merge to `main` if the branch is not protected, otherwise open a PR
- push
# Status
pre-1.0
# Next Step
Fix the open bugs on the issue tracker that block the release. Merging `next`
into `main` is the owner's.
# Future Steps
Work planned after 1.0, loosely ordered by priority.
## Correctness and operability
- **Outside security audit.** Before 1.0 the encryption and blob-generation code
was reviewed. Every bug the review found was fixed. The risks it accepted are
listed in [Accepted risks](REPOSTRUCTURE.md#accepted-risks). No outside audit
has been done. age, zstd and content-defined chunking are mostly off-the-shelf
pieces. The code that joins them needs an outside read:
- key handling
- recipient parsing
- manifest trust boundary
- restore-time identity validation
- **Verify restored content end-to-end in CI.** The current integration test
does this for a small synthetic snapshot but not at scale. A nightly job
against a multi-GB representative snapshot would catch silent regressions in
the chunker, packer, or restore planner.
## Performance
- **Parallel blob downloads during restore.** A restore downloads one blob at a
time now. With a fast S3 endpoint and a multi-core machine, restore speed is
bound by the fetch, decryption and decompression of each blob. Fetching,
decrypting and decompressing several blobs at once would make such a restore
faster. The change must work with `restorePlan`, which orders the files, and
with `restoreSweeper`, which deletes a cached blob once no remaining file
needs it.
- **Bandwidth limiting (`--bwlimit`).** For both upload and download. It is
useful for backing up over a shared link. It is tricky to make work correctly
with parallel downloads.
- **Restart of interrupted restore.** Today a restore can be started again, and
it then overwrites the partial output. It does not resume from where it
stopped, and it does not skip files that are already present. A `--resume`
mode that checks targets before fetching blobs would matter for very large
restores.
- **Daemon mode.** A long-running mode that watches for file changes, so that
frequent backups, such as hourly ones, skip the full scan. It adds little for
the usual backups from cron every 12 to 36 hours. See
[issue #204](https://git.eeqj.de/sneak/vaultik/issues/204).
## Usability
- **Man pages and richer `--help` examples.** Cobra generates basic help. Man
pages would be a separate target.
- **`vaultik snapshot diff <a> <b>`.** Show which files changed between two
snapshots, without restoring either.
- **Status reporting hook for `--cron`.** When a backup fails silently in cron,
the user has no idea. A configurable hook on completion, for both success and
failure, would close the loop. It could be a webhook, an email or
`notify-send`.
## Infrastructure
- **Schema migrations after 1.0.** Migrations are supported, but none are added
before 1.0 because there is no installed base yet. After 1.0, each schema
change is a new migration, so an existing local index is migrated when
`vaultik` is updated. See [`DATAMODEL.md`](DATAMODEL.md#schema-migrations).
- **Storage backend coverage tests.** S3, `file://` and `rclone://` all share
the `Storer` interface, but the rclone path is the least exercised in CI.
+2 -1
View File
@@ -22,7 +22,9 @@ require (
github.com/spf13/cobra v1.10.1 github.com/spf13/cobra v1.10.1
github.com/spf13/pflag v1.0.10 github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
go.uber.org/dig v1.19.0
go.uber.org/fx v1.24.0 go.uber.org/fx v1.24.0
golang.org/x/mod v0.29.0
golang.org/x/sync v0.18.0 golang.org/x/sync v0.18.0
golang.org/x/sys v0.38.0 golang.org/x/sys v0.38.0
golang.org/x/term v0.37.0 golang.org/x/term v0.37.0
@@ -256,7 +258,6 @@ require (
go.opentelemetry.io/otel/metric v1.38.0 // indirect go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect
go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d // indirect go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d // indirect
go.uber.org/dig v1.19.0 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect go.uber.org/zap v1.27.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect
+34
View File
@@ -2,8 +2,10 @@ package blobgen_test
import ( import (
"bytes" "bytes"
"errors"
"fmt" "fmt"
"testing" "testing"
"testing/iotest"
"filippo.io/age" "filippo.io/age"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -120,6 +122,38 @@ func TestNewReaderTrailingAndGarbage(t *testing.T) {
}) })
} }
// TestNewReaderGarbageErrorQuotesNoBytes checks that lines of text in
// place of a blob fail with ErrNotAgeStream, and that the error carries
// none of their bytes. The lines end in a newline because age's own error
// quotes the data's first line only when it reads a whole line.
func TestNewReaderGarbageErrorQuotesNoBytes(t *testing.T) {
t.Parallel()
id, _ := makeIdentity(t)
garbage := bytes.Repeat([]byte("garbage-bytes-\n"), 100)
_, err := blobgen.NewReader(bytes.NewReader(garbage), id)
require.ErrorIs(t, err, blobgen.ErrNotAgeStream)
assert.NotContains(t, err.Error(), "garbage-bytes-")
}
// errConnectionReset stands for a storage read that fails.
var errConnectionReset = errors.New("connection reset")
// TestNewReaderReadFailure checks that a failed read of the age header is
// returned as that read error, not as ErrNotAgeStream.
func TestNewReaderReadFailure(t *testing.T) {
t.Parallel()
id, _ := makeIdentity(t)
_, err := blobgen.NewReader(iotest.ErrReader(errConnectionReset), id)
require.ErrorIs(t, err, errConnectionReset)
assert.NotErrorIs(t, err, blobgen.ErrNotAgeStream)
}
// TestNewWriterInvalidLevel covers the rejected end of issue case 9: an // TestNewWriterInvalidLevel covers the rejected end of issue case 9: an
// out-of-range compression level errors and writes nothing to the destination. // out-of-range compression level errors and writes nothing to the destination.
func TestNewWriterInvalidLevel(t *testing.T) { func TestNewWriterInvalidLevel(t *testing.T) {
+38 -2
View File
@@ -11,6 +11,11 @@ import (
"github.com/klauspost/compress/zstd" "github.com/klauspost/compress/zstd"
) )
// ErrNotAgeStream is returned by NewReader when the data cannot be opened
// as an age stream, for example because its header is malformed. It
// replaces age's own error, which can quote the data's first bytes.
var ErrNotAgeStream = errors.New("not a valid age stream")
// Reader wraps decompression and decryption with SHA256 verification // Reader wraps decompression and decryption with SHA256 verification
type Reader struct { type Reader struct {
reader io.Reader reader io.Reader
@@ -24,11 +29,25 @@ type Reader struct {
// NewReader creates a new Reader that decrypts, decompresses, and verifies // NewReader creates a new Reader that decrypts, decompresses, and verifies
// data. Every supplied identity is offered to age.Decrypt, so a blob // data. Every supplied identity is offered to age.Decrypt, so a blob
// encrypted to any one of them can be read. // encrypted to any one of them can be read.
//
// A failure to read r is returned as that error, and a blob encrypted to
// none of the identities as age.NoIdentityMatchError. Any other failure
// to open the age stream is ErrNotAgeStream.
func NewReader(r io.Reader, identities ...age.Identity) (*Reader, error) { func NewReader(r io.Reader, identities ...age.Identity) (*Reader, error) {
// Create decryption reader src := &readErrorRecorder{reader: r}
decReader, err := age.Decrypt(r, identities...)
decReader, err := age.Decrypt(src, identities...)
if err != nil { if err != nil {
var noMatch *age.NoIdentityMatchError
switch {
case src.err != nil:
return nil, fmt.Errorf("reading age header: %w", src.err)
case errors.As(err, &noMatch):
return nil, fmt.Errorf("creating decryption reader: %w", err) return nil, fmt.Errorf("creating decryption reader: %w", err)
default:
return nil, ErrNotAgeStream
}
} }
// Create decompression reader // Create decompression reader
@@ -52,6 +71,23 @@ func NewReader(r io.Reader, identities ...age.Identity) (*Reader, error) {
}, nil }, nil
} }
// readErrorRecorder passes reads through to reader and keeps the first
// error other than io.EOF, so NewReader can tell a failed read from bad
// data.
type readErrorRecorder struct {
reader io.Reader
err error
}
func (r *readErrorRecorder) Read(p []byte) (int, error) {
n, err := r.reader.Read(p)
if err != nil && !errors.Is(err, io.EOF) && r.err == nil {
r.err = err
}
return n, err
}
// Read implements io.Reader // Read implements io.Reader
func (r *Reader) Read(p []byte) (int, error) { func (r *Reader) Read(p []byte) (int, error) {
n, err := r.teeReader.Read(p) n, err := r.teeReader.Read(p)
+9 -16
View File
@@ -7,13 +7,11 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"path/filepath"
"strings"
"sync" "sync"
"time" "time"
"github.com/adrg/xdg"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"go.uber.org/dig"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/vaultik/internal/config" "sneak.berlin/go/vaultik/internal/config"
"sneak.berlin/go/vaultik/internal/database" "sneak.berlin/go/vaultik/internal/database"
@@ -90,7 +88,7 @@ func setupGlobals(
// blank line. Used both from the fx hook (for subcommand invocations) and // blank line. Used both from the fx hook (for subcommand invocations) and
// from the root cobra Run handler (for `vaultik` with no subcommand). // from the root cobra Run handler (for `vaultik` with no subcommand).
func writeStartupBanner(w *ui.Writer, startTime time.Time, shortCommit string) { func writeStartupBanner(w *ui.Writer, startTime time.Time, shortCommit string) {
w.Bannerf("%s %s by %s (commit %s, built on %s) starting up at %s.", w.Bannerf("%s %s by %s (commit %s, commit date %s) starting up at %s.",
globals.Appname, globals.Version, globals.Author, globals.Appname, globals.Version, globals.Author,
shortCommit, globals.CommitDate, shortCommit, globals.CommitDate,
startTime.Format(time.RFC3339)) startTime.Format(time.RFC3339))
@@ -146,16 +144,12 @@ func (e *startupError) Error() string {
// could not build arguments for function "Y" (file:line): failed to build U: // could not build arguments for function "Y" (file:line): failed to build U:
// received non-nil error from function "Z" (file:line): <real error> // received non-nil error from function "Z" (file:line): <real error>
// //
// Users care about the real error, not the DI plumbing. We strip everything // Users care about the real error, not the DI plumbing. dig.RootCause,
// up through the last "): " (which is always the close-paren of an fx // from the library fx is built on, unwraps those layers and returns the
// function-location annotation followed by the wrapped error). // real error whole. Cutting the text at "): " instead would also cut a
// real error that contains it, such as the blob_size_limit one.
func cleanStartupError(err error) error { func cleanStartupError(err error) error {
msg := err.Error() return &startupError{msg: dig.RootCause(err).Error()}
if idx := strings.LastIndex(msg, "): "); idx >= 0 {
msg = msg[idx+3:]
}
return &startupError{msg: msg}
} }
// RunApp starts the fx application, blocks until it is asked to stop, and // RunApp starts the fx application, blocks until it is asked to stop, and
@@ -343,7 +337,7 @@ func runVaultikApp(
}, },
Mode: mode, Mode: mode,
}, op, func(err error) { }, op, func(err error) {
log.Error(failMsg, "error", err) log.Info(failMsg, "error", err)
ReportErrorf("%s: %v", failMsg, err) ReportErrorf("%s: %v", failMsg, err)
}) })
} }
@@ -355,8 +349,7 @@ func runVaultikApp(
// only one runs at a time; a read-only command runs without it and is not // only one runs at a time; a read-only command runs without it and is not
// blocked while a mutator holds the lock (opts.Mode). // blocked while a mutator holds the lock (opts.Mode).
func RunWithApp(ctx context.Context, opts AppOptions) error { func RunWithApp(ctx context.Context, opts AppOptions) error {
release, err := acquireLockIfMutating(opts.Mode, release, err := acquireLockIfMutating(opts.Mode, pidlock.Dir())
filepath.Join(xdg.DataHome, "vaultik"))
if err != nil { if err != nil {
return err return err
} }
+46 -39
View File
@@ -2,58 +2,65 @@ package cli //nolint:testpackage // needs access to unexported cleanStartupError
import ( import (
"errors" "errors"
"os"
"path/filepath" "path/filepath"
"testing" "testing"
"go.uber.org/fx"
"sneak.berlin/go/vaultik/internal/config"
"sneak.berlin/go/vaultik/internal/pidlock" "sneak.berlin/go/vaultik/internal/pidlock"
) )
// TestCleanStartupError starts a real fx app whose config has a
// blob_size_limit below the minimum. fx puts annotations naming the
// functions it was building in front of the config error; only those
// may be removed, although the config error itself contains "): ".
func TestCleanStartupError(t *testing.T) { func TestCleanStartupError(t *testing.T) {
t.Parallel() t.Parallel()
tests := []struct { path := filepath.Join(t.TempDir(), "config.yml")
name string
in string err := os.WriteFile(path, []byte("snapshots:\n"+
want string " test:\n"+
}{ " paths: [/tmp/vaultik-test-source]\n"+
{ "storage_url: file:///tmp/vaultik-test-storage\n"+
name: "real fx error chain", "chunk_size: 10MB\n"+
in: `could not build arguments for function ` + "blob_size_limit: 30MB\n"), 0o600)
`"sneak.berlin/go/vaultik/internal/cli".newSnapshotCreateCommand.func1.1 ` + if err != nil {
`(/Users/user/dev/vaultik/internal/cli/snapshot.go:71): ` + t.Fatalf("write config: %v", err)
`failed to build *vaultik.Vaultik: ` +
`could not build arguments for function ` +
`"sneak.berlin/go/vaultik/internal/vaultik".New ` +
`(/Users/user/dev/vaultik/internal/vaultik/vaultik.go:59): ` +
`failed to build storage.Storer: ` +
`received non-nil error from function ` +
`"sneak.berlin/go/vaultik/internal/storage".NewStorer ` +
`(/Users/user/dev/vaultik/internal/storage/module.go:23): ` +
`creating base path: mkdir /Volumes/BACKUPS: permission denied`,
want: `creating base path: mkdir /Volumes/BACKUPS: permission denied`,
},
{
name: "no fx wrapping",
in: "plain error",
want: "plain error",
},
{
name: "single fx wrapping",
in: `received non-nil error from function "foo" (file.go:1): underlying problem`,
want: "underlying problem",
},
} }
for _, tt := range tests { app := fx.New(
t.Run(tt.name, func(t *testing.T) { fx.NopLogger,
fx.Supply(config.Path(path)),
config.Module,
fx.Invoke(func(*config.Config) {}),
)
err = app.Start(t.Context())
if err == nil {
t.Fatal("app started; want a blob_size_limit error")
}
want := "failed to load config: invalid config: blob_size_limit must " +
"be at least the largest chunk the chunker can emit (chunk_size " +
"times the FastCDC size spread): need at least 40000000 bytes"
got := cleanStartupError(err).Error()
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
// TestCleanStartupErrorNotFromFx checks that an error fx did not wrap is
// shown unchanged.
func TestCleanStartupErrorNotFromFx(t *testing.T) {
t.Parallel() t.Parallel()
//nolint:err113 // test constructs errors from table input //nolint:err113 // a one-off error standing in for a startup failure
got := cleanStartupError(errors.New(tt.in)).Error() got := cleanStartupError(errors.New("plain error")).Error()
if got != tt.want { if got != "plain error" {
t.Errorf("got %q, want %q", got, tt.want) t.Errorf("got %q, want %q", got, "plain error")
}
})
} }
} }
+87 -52
View File
@@ -8,6 +8,7 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"reflect" "reflect"
"runtime"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
@@ -30,7 +31,7 @@ const configSetArgs = 2
// parent config dirs (e.g. ~/.config) are conventionally traversable. // parent config dirs (e.g. ~/.config) are conventionally traversable.
const configDirMode = 0o755 const configDirMode = 0o755
// configYAMLIndent matches the 2-space indentation of defaultConfigTemplate, // configYAMLIndent matches the 2-space indentation of the default config,
// so `config set` writes the file back with the same indentation rather than // so `config set` writes the file back with the same indentation rather than
// yaml.Marshal's 4-space default. // yaml.Marshal's 4-space default.
const configYAMLIndent = 2 const configYAMLIndent = 2
@@ -48,9 +49,30 @@ var (
errWouldNotLoad = errors.New( errWouldNotLoad = errors.New(
"vaultik would not load the config after this change, " + "vaultik would not load the config after this change, " +
"so the file was not changed") "so the file was not changed")
errSetS3KeysFirst = errors.New(
"set s3.access_key_id and s3.secret_access_key first, " +
"then storage_url")
) )
const defaultConfigTemplate = `# vaultik configuration // storageURLKey is the config key that selects the storage backend.
const storageURLKey = "storage_url"
// macOS is the value of runtime.GOOS on macOS.
const macOS = "darwin"
// defaultConfigText returns the file `config init` writes on the operating
// system goos, named as runtime.GOOS names it. Only macOS gets the apps
// snapshot, since /Applications exists nowhere else.
func defaultConfigText(goos string) string {
if goos == macOS {
return defaultConfigHead + appsSnapshotConfig + defaultConfigTail
}
return defaultConfigHead + defaultConfigTail
}
// defaultConfigHead is the default config up to the end of the home snapshot.
const defaultConfigHead = `# vaultik configuration
# Documentation: https://sneak.berlin/go/vaultik # Documentation: https://sneak.berlin/go/vaultik
# ─── REQUIRED ──────────────────────────────────────────────────────────────── # ─── REQUIRED ────────────────────────────────────────────────────────────────
@@ -168,7 +190,10 @@ snapshots:
- "/Movies/ProxyMedia" - "/Movies/ProxyMedia"
- "/Music/iTunes/Album Artwork" - "/Music/iTunes/Album Artwork"
- "/Pictures/iPod Photo Cache" - "/Pictures/iPod Photo Cache"
`
// appsSnapshotConfig is the apps snapshot of the default config.
const appsSnapshotConfig = `
# Third-party applications. OS-provided apps live in /System/Applications # Third-party applications. OS-provided apps live in /System/Applications
# on modern macOS and are never in /Applications, but Apple-installed # on modern macOS and are never in /Applications, but Apple-installed
# App Store apps (Safari, GarageBand, iWork, iMovie) are excluded since # App Store apps (Safari, GarageBand, iWork, iMovie) are excluded since
@@ -189,7 +214,10 @@ snapshots:
- "/Steam.app" - "/Steam.app"
- "/VirtualBox.app" - "/VirtualBox.app"
- "/Utilities/Adobe Installers" - "/Utilities/Adobe Installers"
`
// defaultConfigTail is the default config after the snapshots.
const defaultConfigTail = `
# Storage backend (pick ONE of the three forms below). # Storage backend (pick ONE of the three forms below).
# #
# S3-compatible: # S3-compatible:
@@ -239,10 +267,23 @@ storage_url: ""
# Default: 3 # Default: 3
# compression_level: 3 # compression_level: 3
# How old a blob that no snapshot lists must be before prune deletes it,
# and how old every object in a directory under metadata/ with no manifest
# must be before prune deletes that directory. A backup uploads its blobs
# and its db.zst.age before its manifest, so on a destination shared
# between hosts or users this must be longer than the longest backup run.
# It does not protect a blob a backup reuses from an earlier run that
# stopped before its manifest: that blob keeps the time of its first
# upload, so a prune can delete it.
# Accepts: 36h, 90m, etc. 0s deletes such blobs and directories at once.
# Default: 24h
# prune_grace_period: 24h
# Hostname used in snapshot IDs. Default: system hostname. # Hostname used in snapshot IDs. Default: system hostname.
# hostname: myserver # hostname: myserver
# Path to the local SQLite index database. # Path to the local SQLite index database. It must be an absolute path;
# ~ is expanded.
# Default: the platform data directory, e.g. # Default: the platform data directory, e.g.
# macOS: ~/Library/Application Support/vaultik/index.sqlite # macOS: ~/Library/Application Support/vaultik/index.sqlite
# Linux: ~/.local/share/vaultik/index.sqlite # Linux: ~/.local/share/vaultik/index.sqlite
@@ -277,12 +318,14 @@ func newConfigInitCommand() *cobra.Command {
Use: "init", Use: "init",
Short: "Write a default config file", Short: "Write a default config file",
Long: `Creates a default configuration file with commented explanations Long: `Creates a default configuration file with commented explanations
for every setting. If a config file already exists at the target path, for the settings it lists. If a config file already exists at the target
the command refuses to overwrite it. path, the command refuses to overwrite it. The file is created with mode
0600, because it can hold credentials.
The config is written to the path from --config, $VAULTIK_CONFIG, or The config is written to the path from --config, $VAULTIK_CONFIG, or
the platform default config directory (e.g. ~/Library/Application Support/ config.yml in the platform default config directory
on macOS, ~/.config/ on Linux, /etc/vaultik/ as root).`, (~/Library/Application Support/vaultik/ on macOS, ~/.config/vaultik/ on
Linux, /etc/vaultik/ as root).`,
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error { RunE: func(cmd *cobra.Command, _ []string) error {
path := configPathForInit() path := configPathForInit()
@@ -299,7 +342,8 @@ on macOS, ~/.config/ on Linux, /etc/vaultik/ as root).`,
return fmt.Errorf("creating config directory %s: %w", dir, err) return fmt.Errorf("creating config directory %s: %w", dir, err)
} }
err = os.WriteFile(path, []byte(defaultConfigTemplate), configFileMode) err = os.WriteFile(path, []byte(defaultConfigText(runtime.GOOS)),
configFileMode)
if err != nil { if err != nil {
return fmt.Errorf("writing config file: %w", err) return fmt.Errorf("writing config file: %w", err)
} }
@@ -320,7 +364,7 @@ on macOS, ~/.config/ on Linux, /etc/vaultik/ as root).`,
func newConfigEditCommand() *cobra.Command { func newConfigEditCommand() *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "edit", Use: "edit",
Short: "Open the config file in $EDITOR", Short: "Open the config file in $EDITOR (vi when it is not set)",
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error { RunE: func(cmd *cobra.Command, _ []string) error {
path, err := ResolveConfigPath() path, err := ResolveConfigPath()
@@ -402,17 +446,16 @@ func newConfigSetCommand() *cobra.Command {
Use: "set <key> <value>", Use: "set <key> <value>",
Short: "Set a config value by dotted path (e.g. compression_level 5)", Short: "Set a config value by dotted path (e.g. compression_level 5)",
Long: `Sets a scalar config value addressed by dotted YAML path and writes Long: `Sets a scalar config value addressed by dotted YAML path and writes
the file back, preserving comments and formatting. Missing keys along the file back. Comments in the file are kept. Blank lines can be
the path are created: as a list when the next path element is a removed. Missing keys along the path are created: as a list when the
number, otherwise as a map. next path element is a number, otherwise as a map.
If the config loads, a change after which it would not, such as If the config loads, a change after which it would not, such as
compression_level 99, is refused and the file is left unchanged. A compression_level 99, is refused and the file is left unchanged. A
config that does not load, such as the one config init writes before config that does not load is checked one setting at a time. Examples
storage_url is set or one with a ${ENV:...} reference to a variable are the file config init writes before storage_url is set, and one
not set here, is checked one setting at a time: only a change after with a ${ENV:...} reference to a variable not set in this shell. In
which the changed setting cannot be read, such as one giving it the such a config, an unknown key or a value of the wrong type is refused.
wrong type, is refused.
Examples: Examples:
vaultik config set storage_url "file:///mnt/backups" vaultik config set storage_url "file:///mnt/backups"
@@ -449,6 +492,13 @@ func writeConfigSet(out *ui.Writer, path, key, value string) error {
keys := strings.Split(key, ".") keys := strings.Split(key, ".")
if keys[0] != config.EnvSection {
_, err = config.SettingType(keys)
if err != nil {
return err
}
}
err = yamlPathSet(root, keys, value) err = yamlPathSet(root, keys, value)
if err != nil { if err != nil {
return err return err
@@ -471,7 +521,7 @@ func writeConfigSet(out *ui.Writer, path, key, value string) error {
err = checkConfigChange(oldData, data, settingData) err = checkConfigChange(oldData, data, settingData)
if err != nil { if err != nil {
return fmt.Errorf("%w: %w", errWouldNotLoad, err) return wouldNotLoadError(key, err)
} }
err = os.WriteFile(path, data, configFileMode) err = os.WriteFile(path, data, configFileMode)
@@ -495,6 +545,21 @@ func writeConfigSet(out *ui.Writer, path, key, value string) error {
return nil return nil
} }
// wouldNotLoadError returns the refusal of a change to key that vaultik
// would not load for reason. When storage_url moves to s3:// before the S3
// credentials are set, it says to set them first: they can be set while
// the old storage_url is still in place.
func wouldNotLoadError(key string, reason error) error {
if key == storageURLKey &&
(errors.Is(reason, config.ErrS3KeyIDRequired) ||
errors.Is(reason, config.ErrS3SecretRequired)) {
return fmt.Errorf("%w: %w; %w", errWouldNotLoad, reason,
errSetS3KeysFirst)
}
return fmt.Errorf("%w: %w", errWouldNotLoad, reason)
}
// checkConfigChange returns the reason vaultik would not load newData, the // checkConfigChange returns the reason vaultik would not load newData, the
// config after a change, if the change brings that failure in. If oldData, // config after a change, if the change brings that failure in. If oldData,
// the config before it, loads, the new one must load too. A config that // the config before it, loads, the new one must load too. A config that
@@ -548,7 +613,7 @@ func yamlPathOnly(root *yaml.Node, keys []string) (*yaml.Node, error) {
} }
// marshalConfigYAML renders a config document tree with 2-space indentation, // marshalConfigYAML renders a config document tree with 2-space indentation,
// matching defaultConfigTemplate. yaml.Marshal defaults to 4 spaces, which // matching the default config. yaml.Marshal defaults to 4 spaces, which
// would reindent the whole file on the first `config set` despite the promise // would reindent the whole file on the first `config set` despite the promise
// to preserve formatting. // to preserve formatting.
func marshalConfigYAML(root *yaml.Node) ([]byte, error) { func marshalConfigYAML(root *yaml.Node) ([]byte, error) {
@@ -704,41 +769,11 @@ func yamlPathSet(root *yaml.Node, keys []string, value string) error {
} }
// configKeyIsString reports whether the dotted key names a string in // configKeyIsString reports whether the dotted key names a string in
// config.Config, following the fields' yaml tags, as s3.access_key_id and // config.Config, as s3.access_key_id and snapshots.home.exclude.0 do.
// snapshots.home.exclude.0 do.
func configKeyIsString(keys []string) bool { func configKeyIsString(keys []string) bool {
typ := reflect.TypeFor[config.Config]() typ, err := config.SettingType(keys)
for _, key := range keys { return err == nil && typ.Kind() == reflect.String
switch {
case typ.Kind() == reflect.Map || typ.Kind() == reflect.Slice:
// The key is a snapshot name or a list index.
typ = typ.Elem()
case typ.Kind() == reflect.Struct:
field, ok := yamlField(typ, key)
if !ok {
return false
}
typ = field.Type
default:
return false
}
}
return typ.Kind() == reflect.String
}
// yamlField returns the field of struct type typ whose yaml tag names key.
func yamlField(typ reflect.Type, key string) (reflect.StructField, bool) {
for field := range typ.Fields() {
name, _, _ := strings.Cut(field.Tag.Get("yaml"), ",")
if name == key {
return field, true
}
}
return reflect.StructField{}, false
} }
// yamlSetInMapping resolves (creating if needed) the value node for the // yamlSetInMapping resolves (creating if needed) the value node for the
+180 -13
View File
@@ -2,6 +2,7 @@ package cli //nolint:testpackage // exercises unexported yamlPathGet/yamlPathSet
import ( import (
"bytes" "bytes"
"errors"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
@@ -13,14 +14,15 @@ import (
"sneak.berlin/go/vaultik/internal/ui" "sneak.berlin/go/vaultik/internal/ui"
) )
// TestDefaultConfigTemplateParses ensures the init template is valid YAML // TestDefaultConfigParsesOnMacOS ensures the config `config init` writes on
// that unmarshals into the Config struct with the expected snapshots. // macOS is valid YAML that unmarshals into the Config struct with the
func TestDefaultConfigTemplateParses(t *testing.T) { // expected snapshots.
func TestDefaultConfigParsesOnMacOS(t *testing.T) {
t.Parallel() t.Parallel()
var cfg config.Config var cfg config.Config
err := yaml.Unmarshal([]byte(defaultConfigTemplate), &cfg) err := yaml.Unmarshal([]byte(defaultConfigText("darwin")), &cfg)
if err != nil { if err != nil {
t.Fatalf("default config template is not valid YAML: %v", err) t.Fatalf("default config template is not valid YAML: %v", err)
} }
@@ -58,11 +60,44 @@ func TestDefaultConfigTemplateParses(t *testing.T) {
} }
} }
// TestDefaultConfigOnLinux checks that the config `config init` writes on
// Linux loads with its home snapshot and without the apps snapshot, whose
// /Applications exists only on macOS.
func TestDefaultConfigOnLinux(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "config.yml")
err := os.WriteFile(path, []byte(defaultConfigText("linux")), configFileMode)
if err != nil {
t.Fatalf("write config: %v", err)
}
err = writeConfigSet(ui.NewWithColor(&bytes.Buffer{}, false),
path, "storage_url", "file:///mnt/backups")
if err != nil {
t.Fatalf("config set storage_url: %v", err)
}
cfg, err := config.Load(path)
if err != nil {
t.Fatalf("config.Load: %v", err)
}
if _, ok := cfg.Snapshots["home"]; !ok {
t.Error("expected 'home' snapshot in the Linux default config")
}
if _, ok := cfg.Snapshots["apps"]; ok {
t.Error("the Linux default config should have no 'apps' snapshot")
}
}
// testRecipient is a valid age public key. // testRecipient is a valid age public key.
const testRecipient = "age1278m9q7dp3chsh2dcy82qk27v047zywyvtxwnj4cvt0z65jw6a7q5dqhfj" const testRecipient = "age1278m9q7dp3chsh2dcy82qk27v047zywyvtxwnj4cvt0z65jw6a7q5dqhfj"
// TestConfigSetRecipientOnFreshConfig follows the README quickstart: on the // TestConfigSetRecipientOnFreshConfig follows the README's getting started
// file `config init` writes, `config set age_recipients.0` and // steps: on the file `config init` writes, `config set age_recipients.0` and
// `config set storage_url` give a config that loads with that recipient. // `config set storage_url` give a config that loads with that recipient.
// Neither is refused, although the config does not load until both are set; // Neither is refused, although the config does not load until both are set;
// once it loads, a recipient that is not a valid key is. // once it loads, a recipient that is not a valid key is.
@@ -71,7 +106,7 @@ func TestConfigSetRecipientOnFreshConfig(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yml") path := filepath.Join(t.TempDir(), "config.yml")
err := os.WriteFile(path, []byte(defaultConfigTemplate), configFileMode) err := os.WriteFile(path, []byte(defaultConfigText(macOS)), configFileMode)
if err != nil { if err != nil {
t.Fatalf("write config: %v", err) t.Fatalf("write config: %v", err)
} }
@@ -118,6 +153,21 @@ snapshots:
- "~" - "~"
` `
// testS3URL is an S3 storage_url, which needs credentials to load.
const testS3URL = "s3://bucket/prefix?endpoint=https://s3.example.com"
// configWithS3Keys loads: its storage_url is testS3URL and both S3 keys are
// set.
const configWithS3Keys = storageURLKey + ": " + testS3URL + `
s3:
access_key_id: AKIAEXAMPLE
secret_access_key: example-secret
snapshots:
home:
paths:
- "~"
`
// configWithUnsetReference loads only where VAULTIK_TEST_UNSET_SECRET is // configWithUnsetReference loads only where VAULTIK_TEST_UNSET_SECRET is
// set, as a backup job's environment might set it. No test sets it. // set, as a backup job's environment might set it. No test sets it.
const configWithUnsetReference = configWithoutRecipients + `s3: const configWithUnsetReference = configWithoutRecipients + `s3:
@@ -191,7 +241,7 @@ func TestConfigSetIndexUnderMissingKey(t *testing.T) {
// change after which the config would not load, with an error giving the // change after which the config would not load, with an error giving the
// reason, and leaves the file as it was. On the file `config init` writes, // reason, and leaves the file as it was. On the file `config init` writes,
// which does not load until storage_url is set, and on one that cannot be // which does not load until storage_url is set, and on one that cannot be
// read here, only a value of the wrong type is refused. // read here, only an unknown key or a value of the wrong type is refused.
func TestConfigSetKeepsFileThatWouldNotLoad(t *testing.T) { func TestConfigSetKeepsFileThatWouldNotLoad(t *testing.T) {
t.Parallel() t.Parallel()
@@ -203,6 +253,14 @@ func TestConfigSetKeepsFileThatWouldNotLoad(t *testing.T) {
value string value string
reason string reason string
}{ }{
// Keys that no setting has, on a config that loads and on one
// that does not.
{configWithoutRecipients, "nosuchkey", "1",
`unknown config key "nosuchkey"`},
{configWithoutRecipients, "snapshots.home.exlude", "/tmp",
`unknown config key "snapshots.home.exlude"`},
{defaultConfigText(macOS), "compression_lvl", "9",
`unknown config key "compression_lvl"`},
// Index 1 of the empty list the missing key gives. // Index 1 of the empty list the missing key gives.
{configWithoutRecipients, "age_recipients.1", testRecipient, {configWithoutRecipients, "age_recipients.1", testRecipient,
"index out of range"}, "index out of range"},
@@ -210,17 +268,25 @@ func TestConfigSetKeepsFileThatWouldNotLoad(t *testing.T) {
{configWithoutRecipients, "snapshots.home.paths", "/srv", {configWithoutRecipients, "snapshots.home.paths", "/srv",
"into []string"}, "into []string"},
{configWithoutRecipients, "chunk_size", "fast", {configWithoutRecipients, "chunk_size", "fast",
"invalid size format"}, `chunk_size: "fast" is not a size`},
// Values of the right type that fail validation. // Values of the right type that fail validation.
{configWithoutRecipients, "compression_level", "99", {configWithoutRecipients, "compression_level", "99",
"compression_level must be between 1 and 19"}, "compression_level must be between 1 and 19"},
{configWithoutRecipients, "chunk_size", "1KB", {configWithoutRecipients, "chunk_size", "1KB",
"chunk_size must be at least 1MB"}, "chunk_size must be at least 1MB"},
// An s3:// URL before its credentials names them.
{configWithoutRecipients, storageURLKey, testS3URL,
"s3.access_key_id is required for s3:// URLs; " +
"set s3.access_key_id and s3.secret_access_key first"},
{configWithoutRecipients + "s3:\n access_key_id: AKIAEXAMPLE\n",
storageURLKey, testS3URL,
"s3.secret_access_key is required for s3:// URLs; " +
"set s3.access_key_id and s3.secret_access_key first"},
// With its reference filled in, the config loads. // With its reference filled in, the config loads.
{levelReference, "blob_size_limit", "1MB", {levelReference, "blob_size_limit", "1MB",
"blob_size_limit must be at least"}, "blob_size_limit must be at least"},
// The type check on a config that does not load yet. // The type check on a config that does not load yet.
{defaultConfigTemplate, "snapshots.apps.paths", "/srv", {defaultConfigText(macOS), "snapshots.apps.paths", "/srv",
"into []string"}, "into []string"},
// The type check on a config that cannot be read here. // The type check on a config that cannot be read here.
{configWithUnsetReference, "snapshots.home.paths", "/x", {configWithUnsetReference, "snapshots.home.paths", "/x",
@@ -301,6 +367,109 @@ func TestConfigSetRefusesOnlyItsOwnFailure(t *testing.T) {
} }
} }
// TestConfigSetS3KeysBeforeStorageURL checks that the order the refusal
// gives works on a config that loads with a file:// storage_url.
func TestConfigSetS3KeysBeforeStorageURL(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "config.yml")
err := os.WriteFile(path, []byte(configWithoutRecipients), configFileMode)
if err != nil {
t.Fatalf("write config: %v", err)
}
for _, set := range [][2]string{
{"s3.access_key_id", "AKIAEXAMPLE"},
{"s3.secret_access_key", "example-secret"},
{storageURLKey, testS3URL},
} {
err = writeConfigSet(ui.NewWithColor(&bytes.Buffer{}, false),
path, set[0], set[1])
if err != nil {
t.Fatalf("config set %s: %v", set[0], err)
}
}
data, err := os.ReadFile(path) //nolint:gosec // G304: test-controlled path
if err != nil {
t.Fatalf("read config: %v", err)
}
if !strings.Contains(string(data), storageURLKey+": "+testS3URL) {
t.Errorf("config set storage_url wrote:\n%s", data)
}
}
// TestConfigSetRefusesClearingS3Key checks that clearing s3.access_key_id on
// a config with an s3:// storage_url is refused without saying to set the
// S3 keys first. That advice is only for a change to storage_url.
func TestConfigSetRefusesClearingS3Key(t *testing.T) {
t.Parallel()
const key = "s3.access_key_id"
path := filepath.Join(t.TempDir(), "config.yml")
err := os.WriteFile(path, []byte(configWithS3Keys), configFileMode)
if err != nil {
t.Fatalf("write config: %v", err)
}
err = writeConfigSet(ui.NewWithColor(&bytes.Buffer{}, false), path, key, "")
if !errors.Is(err, errWouldNotLoad) {
t.Errorf("clearing %s gives error %v, want one saying %q",
key, err, errWouldNotLoad)
}
if errors.Is(err, errSetS3KeysFirst) {
t.Errorf("clearing %s gives error %v, want one not saying %q",
key, err, errSetS3KeysFirst)
}
data, err := os.ReadFile(path) //nolint:gosec // G304: test-controlled path
if err != nil {
t.Fatalf("read config: %v", err)
}
if string(data) != configWithS3Keys {
t.Errorf("clearing %s changed the file to:\n%s", key, data)
}
}
// TestConfigSetWritesEnvSection checks that config set writes a key under
// the top-level env section, whose keys are not settings, on a config that
// loads.
func TestConfigSetWritesEnvSection(t *testing.T) {
const name = "VAULTIK_TEST_CONFIG_SET_ENV"
// Loading the changed config sets the variable; t.Setenv restores it
// after the test.
t.Setenv(name, "")
path := filepath.Join(t.TempDir(), "config.yml")
err := os.WriteFile(path, []byte(configWithoutRecipients), configFileMode)
if err != nil {
t.Fatalf("write config: %v", err)
}
err = writeConfigSet(ui.NewWithColor(&bytes.Buffer{}, false),
path, "env."+name, "value")
if err != nil {
t.Fatalf("config set env.%s: %v", name, err)
}
data, err := os.ReadFile(path) //nolint:gosec // G304: test-controlled path
if err != nil {
t.Fatalf("read config: %v", err)
}
if !strings.Contains(string(data), "env:\n "+name+": value\n") {
t.Errorf("config set env.%s wrote:\n%s", name, data)
}
}
// TestConfigSetStringLooksLikeNumber sets string settings to values that // TestConfigSetStringLooksLikeNumber sets string settings to values that
// YAML reads as numbers or booleans when they are unquoted, and checks that // YAML reads as numbers or booleans when they are unquoted, and checks that
// config.Load returns each one unchanged. // config.Load returns each one unchanged.
@@ -380,7 +549,7 @@ func loadAfterConfigSet(t *testing.T, key, value string) *config.Config {
path := filepath.Join(t.TempDir(), "config.yml") path := filepath.Join(t.TempDir(), "config.yml")
err := os.WriteFile(path, []byte(defaultConfigTemplate), configFileMode) err := os.WriteFile(path, []byte(defaultConfigText(macOS)), configFileMode)
if err != nil { if err != nil {
t.Fatalf("write config: %v", err) t.Fatalf("write config: %v", err)
} }
@@ -651,8 +820,6 @@ func TestWriteConfigSetTightensMode(t *testing.T) {
// argument works and that a config path with a space reaches it whole. // argument works and that a config path with a space reaches it whole.
// The "editor" is cp with the replacement file as its argument, so the // The "editor" is cp with the replacement file as its argument, so the
// config ends up with the replacement's content only if both arrived. // config ends up with the replacement's content only if both arrived.
//
//nolint:paralleltest // sets $EDITOR and the process-global rootFlags
func TestConfigEditEditorWithArgument(t *testing.T) { func TestConfigEditEditorWithArgument(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
path := seedFile(t, dir, "my config.yml", "compression_level: 3\n") path := seedFile(t, dir, "my config.yml", "compression_level: 3\n")
+12 -12
View File
@@ -6,7 +6,9 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"sneak.berlin/go/vaultik/internal/config" "sneak.berlin/go/vaultik/internal/config"
"sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/log" "sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/vaultik"
) )
// NewDatabaseCommand creates the database command group // NewDatabaseCommand creates the database command group
@@ -90,26 +92,24 @@ Use --force to skip the confirmation prompt.`,
var confirm string var confirm string
_, err = fmt.Scanln(&confirm) // A failure to read the answer means no confirmation.
_, err = fmt.Fscanln(cmd.InOrStdin(), &confirm)
if err != nil || confirm != "yes" { if err != nil || confirm != "yes" {
_, _ = fmt.Fprintln(w, "Aborted.") _, _ = fmt.Fprintln(w, "Aborted.")
//nolint:nilerr // a failed/aborted confirmation is a clean abort return vaultik.ErrNotConfirmed
return nil
} }
} }
// Delete the database file // Delete the database file and its side files. A side file
err = os.Remove(dbPath) // may not exist, so only failing to delete the database file
if err != nil { // is an error.
for _, indexFile := range database.IndexFiles(dbPath) {
err = os.Remove(indexFile)
if err != nil && indexFile == dbPath {
return fmt.Errorf("failed to delete database: %w", err) return fmt.Errorf("failed to delete database: %w", err)
} }
}
// Also delete WAL and SHM files if they exist
walPath := dbPath + "-wal"
shmPath := dbPath + "-shm"
_ = os.Remove(walPath) // Ignore errors - files may not exist
_ = os.Remove(shmPath)
out.Infof("Local state database deleted: %s.", dbPath) out.Infof("Local state database deleted: %s.", dbPath)
log.Info("Local state database deleted", "path", dbPath) log.Info("Local state database deleted", "path", dbPath)
+75
View File
@@ -0,0 +1,75 @@
package cli //nolint:testpackage // sets the unexported rootFlags directly
import (
"bytes"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/vaultik"
)
// TestDatabaseDeleteDeclinedIsError checks that answering anything but
// "yes" keeps the database and returns ErrNotConfirmed, so the command
// exits non-zero.
//
//nolint:paralleltest // mutates the process-global rootFlags
func TestDatabaseDeleteDeclinedIsError(t *testing.T) {
dir := t.TempDir()
dbPath, cfgPath := seedDatabaseDeleteConfig(t, dir)
setRootFlags(t, RootFlags{ConfigPath: cfgPath})
var out bytes.Buffer
cmd := newDatabaseDeleteCommand()
cmd.SetOut(&out)
cmd.SetErr(io.Discard)
cmd.SetIn(strings.NewReader("no\n"))
cmd.SetArgs(nil)
err := cmd.Execute()
if !errors.Is(err, vaultik.ErrNotConfirmed) {
t.Errorf("declined database delete returned %v, want %v",
err, vaultik.ErrNotConfirmed)
}
_, err = os.Stat(dbPath)
if err != nil {
t.Errorf("declined database delete removed the file: %v", err)
}
if !strings.Contains(out.String(), "Aborted.") {
t.Errorf("declined database delete printed %q, want Aborted.",
out.String())
}
}
// TestDatabaseDeleteRemovesSideFiles checks that the side files SQLite
// keeps beside the database file are deleted with it.
//
//nolint:paralleltest // mutates the process-global rootFlags
func TestDatabaseDeleteRemovesSideFiles(t *testing.T) {
dir := t.TempDir()
dbPath, cfgPath := seedDatabaseDeleteConfig(t, dir)
indexFiles := database.IndexFiles(dbPath)
for _, indexFile := range indexFiles {
seedFile(t, dir, filepath.Base(indexFile), "data")
}
setRootFlags(t, RootFlags{ConfigPath: cfgPath})
mustExecute(t, newDatabaseDeleteCommand(), "--force")
for _, indexFile := range indexFiles {
_, err := os.Stat(indexFile)
if !os.IsNotExist(err) {
t.Errorf("database delete left %s: stat err = %v", indexFile, err)
}
}
}
+7
View File
@@ -21,6 +21,8 @@ const shortCommitLen = 12
const exitCodeInterrupted = 130 const exitCodeInterrupted = 130
// Entry is the main entry point for the CLI application. // Entry is the main entry point for the CLI application.
// It first calls globals.FillUnsetFromBuildInfo, which fills in the
// version, commit and commit date that the linker flags did not set.
// It prints the startup banner to stderr (unless a banner-suppressing // It prints the startup banner to stderr (unless a banner-suppressing
// flag is present in os.Args — see bannerSuppressedInArgs), executes the // flag is present in os.Args — see bannerSuppressedInArgs), executes the
// root cobra command, and routes any returned error through the // root cobra command, and routes any returned error through the
@@ -33,6 +35,7 @@ const exitCodeInterrupted = 130
// deferred profile writers run before the process ends. See run in // deferred profile writers run before the process ends. See run in
// cmd/vaultik/main.go. // cmd/vaultik/main.go.
func Entry() int { func Entry() int {
globals.FillUnsetFromBuildInfo()
emitStartupBanner(os.Args[1:], os.Stderr) emitStartupBanner(os.Args[1:], os.Stderr)
rootCmd := NewRootCommand() rootCmd := NewRootCommand()
@@ -82,6 +85,10 @@ func emitStartupBanner(args []string, w io.Writer) {
// 🛑 ERROR: format. Use it from goroutine error paths (where returning // 🛑 ERROR: format. Use it from goroutine error paths (where returning
// an error to cobra isn't an option) and anywhere else a CLI command // an error to cobra isn't an option) and anywhere else a CLI command
// must surface a failure outside the normal RunE return path. // must surface a failure outside the normal RunE return path.
//
// This line is the one report of the failure. A caller that also logs
// the failure logs it at info level, so the record appears only under
// --verbose or --debug and the error is not printed twice.
func ReportErrorf(format string, args ...any) { func ReportErrorf(format string, args ...any) {
ui.New(os.Stderr).Errorf(format, args...) ui.New(os.Stderr).Errorf(format, args...)
} }
+30
View File
@@ -13,6 +13,7 @@ import (
"github.com/adrg/xdg" "github.com/adrg/xdg"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/globals"
) )
// Command words and flags used to build argument vectors below. They are // Command words and flags used to build argument vectors below. They are
@@ -112,6 +113,35 @@ func TestBannerStillPrintedWithoutSuppressingFlag(t *testing.T) {
} }
} }
// TestBannerLabelsCommitDate checks that the banner prints the commit
// and the commit date, each after its own label. Commit and CommitDate
// get different values, so printing the wrong one fails.
//
//nolint:paralleltest // sets globals.Commit and globals.CommitDate
func TestBannerLabelsCommitDate(t *testing.T) {
const (
commit = "abc1234"
commitDate = "2026-11-09"
)
previousCommit, previousCommitDate := globals.Commit, globals.CommitDate
t.Cleanup(func() {
globals.Commit = previousCommit
globals.CommitDate = previousCommitDate
})
globals.Commit = commit
globals.CommitDate = commitDate
var banner bytes.Buffer
emitStartupBanner(nil, &banner)
assert.Contains(t, banner.String(),
"(commit "+commit+", commit date "+commitDate+")")
}
// TestBannerSuppressedInArgs covers the suppression scan directly, // TestBannerSuppressedInArgs covers the suppression scan directly,
// including the flags that suppressed the banner before --json joined // including the flags that suppressed the banner before --json joined
// them, so that adding --json cannot regress them. // them, so that adding --json cannot regress them.
+205
View File
@@ -0,0 +1,205 @@
package cli //nolint:testpackage // shares the fixtures and capture helpers
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"filippo.io/age"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/ui"
)
const (
// errorLinePrefix starts the final line that reports a failed command.
errorLinePrefix = "ERROR: "
flagVerbose = "--verbose"
cmdRestore = "restore"
// blobsDir is the directory of a destination store that holds the
// blobs.
blobsDir = "blobs"
)
// TestEntryFailureIsPrintedOnce runs commands that fail after startup
// against a destination they cannot use. `info` is not among them: it
// cannot fail after startup.
//
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
func TestEntryFailureIsPrintedOnce(t *testing.T) {
for _, command := range [][]string{
{cmdSnapshot, cmdCreate},
{cmdSnapshot, cmdRemove, someSnapshotID},
{cmdSnapshot, cmdVerify, someSnapshotID},
{cmdRemote, cmdInfo},
{cmdPrune, flagJSON},
} {
for _, verbose := range []bool{false, true} {
name := fmt.Sprintf("%s verbose=%t", strings.Join(command, " "), verbose)
t.Run(name, func(t *testing.T) {
requireErrorPrintedOnce(t, verbose, append([]string{flagConfig,
writeUnusableDestinationConfig(t)}, command...)...)
})
}
}
}
// TestEntryBlobUploadFailureIsPrintedOnce backs up to a destination
// whose blobs directory is a file, so the blob upload fails.
//
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
func TestEntryBlobUploadFailureIsPrintedOnce(t *testing.T) {
for _, verbose := range []bool{false, true} {
t.Run(fmt.Sprintf("verbose=%t", verbose), func(t *testing.T) {
configPath, storeDir := writeBackupConfig(t)
require.NoError(t, os.WriteFile(
filepath.Join(storeDir, blobsDir), nil, 0o600))
requireErrorPrintedOnce(t, verbose,
flagConfig, configPath, cmdSnapshot, cmdCreate)
})
}
}
// TestEntryBlobDownloadFailureIsPrintedOnce backs up, deletes the blobs
// from the destination, and restores, so the blob download fails.
//
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
func TestEntryBlobDownloadFailureIsPrintedOnce(t *testing.T) {
for _, verbose := range []bool{false, true} {
t.Run(fmt.Sprintf("verbose=%t", verbose), func(t *testing.T) {
configPath, storeDir := writeBackupConfigWithPrivateKey(t)
code, _, _ := runEntry(t,
flagConfig, configPath, cmdSnapshot, cmdCreate)
require.Equal(t, 0, code)
require.NoError(t, os.RemoveAll(filepath.Join(storeDir, blobsDir)))
requireErrorPrintedOnce(t, verbose, flagConfig, configPath,
cmdSnapshot, cmdRestore, onlySnapshotID(t, configPath), t.TempDir())
})
}
}
// requireErrorPrintedOnce runs Entry with args, adding --verbose when
// verbose is set. The command must fail, and stderr must carry its error
// once, as the final ERROR: line. A log record carrying the same error
// appears only under --verbose.
func requireErrorPrintedOnce(t *testing.T, verbose bool, args ...string) {
t.Helper()
if verbose {
args = append(args, flagVerbose)
}
code, _, stderr := runEntry(t, args...)
require.Equal(t, 1, code)
errorLine, records := failureOutput(t, stderr)
if !verbose {
assert.Zero(t, records,
"without --verbose no log record repeats the error:\n%s", stderr)
return
}
assert.NotZero(t, records,
"--verbose must keep the log record of %q", errorLine)
}
// failureOutput requires stderr to hold exactly one ERROR: line and
// returns it, with the number of log records whose error attribute is
// the error that line reports. stderr is a pipe here, so every log
// record is a JSON object on its own line.
func failureOutput(t *testing.T, stderr string) (string, int) {
t.Helper()
var (
errorLines []string
records []map[string]any
)
for line := range strings.Lines(stderr) {
var record map[string]any
if json.Unmarshal([]byte(line), &record) == nil {
records = append(records, record)
continue
}
if strings.Contains(line, errorLinePrefix) {
errorLines = append(errorLines, strings.TrimSpace(line))
}
}
require.Len(t, errorLines, 1,
"the error must be printed once as an ERROR: line:\n%s", stderr)
// An error can run over several lines; the ERROR: line holds the
// first of them.
repeats := 0
for _, record := range records {
failure, ok := record["error"].(string)
firstLine, _, _ := strings.Cut(failure, "\n")
if ok && strings.Contains(errorLines[0], firstLine) {
repeats++
}
}
return errorLines[0], repeats
}
// writeBackupConfigWithPrivateKey builds what writeBackupConfig builds,
// with a generated key pair in place of its recipient. The config also
// holds the private key, so that restore can decrypt the backup. Returns
// the config path and the store directory.
func writeBackupConfigWithPrivateKey(t *testing.T) (string, string) {
t.Helper()
configPath, storeDir := writeBackupConfig(t)
identity, err := age.GenerateX25519Identity()
require.NoError(t, err)
out := ui.NewWithColor(io.Discard, false)
require.NoError(t, writeConfigSet(out, configPath,
"age_recipients.0", identity.Recipient().String()))
require.NoError(t, writeConfigSet(out, configPath,
"age_secret_key", identity.String()))
return configPath, storeDir
}
// onlySnapshotID returns the ID of the one snapshot in the local index
// that writeBackupConfig puts beside the config file.
func onlySnapshotID(t *testing.T, configPath string) string {
t.Helper()
ctx := context.Background()
db, err := database.New(ctx,
filepath.Join(filepath.Dir(configPath), "index.sqlite"))
require.NoError(t, err)
defer func() { require.NoError(t, db.Close()) }()
backedUp, err := database.NewRepositories(db).Snapshots.GetOnlySnapshot(ctx)
require.NoError(t, err)
return backedUp.ID.String()
}
+7 -1
View File
@@ -101,7 +101,8 @@ func TestEntryPruneJSONStdoutIsExactlyOneDocument(t *testing.T) {
// writeHermeticPruneConfig builds a config over a temp directory with an // writeHermeticPruneConfig builds a config over a temp directory with an
// empty destination directory and, if seedStale is set, creates the // empty destination directory and, if seedStale is set, creates the
// index database up front with one snapshot record that has no // index database up front with one snapshot record that has no
// counterpart on the destination store. Returns the config path. // counterpart on the destination store, and gives the destination the
// marker a backup writes. Returns the config path.
func writeHermeticPruneConfig(t *testing.T, seedStale bool) string { func writeHermeticPruneConfig(t *testing.T, seedStale bool) string {
t.Helper() t.Helper()
@@ -131,6 +132,11 @@ func writeHermeticPruneConfig(t *testing.T, seedStale bool) string {
if seedStale { if seedStale {
seedStaleSnapshotRecord(t, indexPath) seedStaleSnapshotRecord(t, indexPath)
// While the local index holds a snapshot, prune fails on a
// destination that answers that it has no marker.
require.NoError(t, os.WriteFile(
filepath.Join(storeDir, "vaultik-destination.txt"), nil, 0o600))
} }
return configPath return configPath
+127
View File
@@ -0,0 +1,127 @@
package cli //nolint:testpackage // shares runEntry and the hermetic config helpers
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// flagCron is used to build the argument vectors below.
const flagCron = "--cron"
// plantUnreferencedBlob writes a blob that no snapshot references to the
// destination store at storeDir, dated long before any prune grace period,
// so that a prune deletes it. Returns its path.
func plantUnreferencedBlob(t *testing.T, storeDir string) string {
t.Helper()
hash := strings.Repeat("b", 64)
path := filepath.Join(storeDir, "blobs", hash[:2], hash[2:4], hash)
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o750))
require.NoError(t, os.WriteFile(path, []byte("unreferenced"), 0o600))
longAgo := time.Unix(0, 0)
require.NoError(t, os.Chtimes(path, longAgo, longAgo))
return path
}
// TestEntrySnapshotCreatePruneIsSilent checks that a successful
// `snapshot create --prune` under --cron or --quiet prints nothing on
// stdout or stderr, whether or not the prune finds a blob to delete.
//
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
func TestEntrySnapshotCreatePruneIsSilent(t *testing.T) {
for _, testCase := range []struct {
name string
args []string
plantBlob bool
}{
{
name: "cron, no blob to delete",
args: []string{cmdSnapshot, cmdCreate, flagCron, flagPrune},
},
{
name: "cron, blob to delete",
args: []string{cmdSnapshot, cmdCreate, flagCron, flagPrune},
plantBlob: true,
},
{
name: "cron with keep-newer-than",
args: []string{
cmdSnapshot, cmdCreate, flagCron, flagPrune,
flagKeepNewerThan, "4w",
},
plantBlob: true,
},
{
name: "quiet",
args: []string{flagQuiet, cmdSnapshot, cmdCreate, flagPrune},
plantBlob: true,
},
} {
t.Run(testCase.name, func(t *testing.T) {
configPath, storeDir := writeBackupConfig(t)
var blob string
if testCase.plantBlob {
blob = plantUnreferencedBlob(t, storeDir)
}
code, stdout, stderr := runEntry(t,
append([]string{flagConfig, configPath}, testCase.args...)...)
assert.Equal(t, 0, code)
assert.Empty(t, stdout)
assert.Empty(t, stderr)
if testCase.plantBlob {
assert.NoFileExists(t, blob, "the prune must delete the blob")
}
})
}
}
// TestEntryPruneQuietIsSilent checks that a successful `prune --force`
// under --quiet prints nothing on stdout or stderr, both when there is
// nothing to remove and when it removes a stale local snapshot record
// and an unreferenced blob.
//
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
func TestEntryPruneQuietIsSilent(t *testing.T) {
for _, testCase := range []struct {
name string
staleRecordAndBlob bool
}{
{name: "nothing to remove"},
{name: "stale record and blob to remove", staleRecordAndBlob: true},
} {
t.Run(testCase.name, func(t *testing.T) {
configPath := writeHermeticPruneConfig(t, testCase.staleRecordAndBlob)
var blob string
if testCase.staleRecordAndBlob {
// writeHermeticPruneConfig puts the store beside the config.
blob = plantUnreferencedBlob(t,
filepath.Join(filepath.Dir(configPath), "store"))
}
code, stdout, stderr := runEntry(t,
flagConfig, configPath, flagQuiet, cmdPrune, flagForce)
assert.Equal(t, 0, code)
assert.Empty(t, stdout)
assert.Empty(t, stderr)
if testCase.staleRecordAndBlob {
assert.NoFileExists(t, blob, "the prune must delete the blob")
}
})
}
}
@@ -0,0 +1,69 @@
package cli //nolint:testpackage // shares runEntry and the hermetic config helpers
import (
"fmt"
"os"
"path/filepath"
"testing"
"github.com/adrg/xdg"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestEntrySnapshotCreateSkipsIndexAndLockFiles backs up a directory
// that holds vaultik's index and PID file beside one other file, with no
// exclude pattern set. The summary counts only the other file. See
// https://git.eeqj.de/sneak/vaultik/issues/421.
//
// Not parallel: it replaces os.Args, os.Stdout, os.Stderr and the xdg
// globals.
func TestEntrySnapshotCreateSkipsIndexAndLockFiles(t *testing.T) {
for _, testCase := range []struct {
name string
// linked sets index_path to a symlink outside the backed-up
// directory that points to the index inside it.
linked bool
}{
{name: "index in the backed-up directory"},
{name: "index_path links into the backed-up directory", linked: true},
} {
t.Run(testCase.name, func(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.yml")
sourceDir := filepath.Join(dir, "source")
storeDir := filepath.Join(dir, "store")
indexPath := filepath.Join(sourceDir, "index.sqlite")
if testCase.linked {
link := filepath.Join(dir, "index.sqlite")
require.NoError(t, os.Symlink(indexPath, link))
indexPath = link
}
contents := fmt.Sprintf(hermeticConfig,
sourceDir, storeDir, indexPath)
require.NoError(t,
os.WriteFile(configPath, []byte(contents), configFileMode))
require.NoError(t, os.Mkdir(storeDir, 0o750))
require.NoError(t, os.Mkdir(sourceDir, 0o750))
require.NoError(t, os.WriteFile(
filepath.Join(sourceDir, "file.txt"), []byte("hello"), 0o600))
// The PID file goes in a directory under XDG_DATA_HOME, which
// xdg reads at package init.
t.Setenv("XDG_DATA_HOME", filepath.Join(sourceDir, "data"))
xdg.Reload()
t.Cleanup(xdg.Reload)
code, stdout, _ := runEntry(t,
flagConfig, configPath, cmdSnapshot, cmdCreate)
require.Equal(t, 0, code)
assert.Contains(t, stdout,
"Files: 1 examined, 1 backed up, 0 unchanged.")
})
}
}
@@ -1,6 +1,7 @@
package cli //nolint:testpackage // shares runEntry and the hermetic config helpers package cli //nolint:testpackage // shares runEntry and the hermetic config helpers
import ( import (
"context"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -8,6 +9,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/database"
) )
// Flags of `snapshot create` used to build argument vectors below. // Flags of `snapshot create` used to build argument vectors below.
@@ -59,8 +61,8 @@ func TestEntrySnapshotCreateRejectsKeepNewerThanBeforeBackup(t *testing.T) {
// TestEntrySnapshotCreateReportsPostBackupPruneFailure makes the // TestEntrySnapshotCreateReportsPostBackupPruneFailure makes the
// post-backup prune fail after a good backup, by planting a manifest // post-backup prune fail after a good backup, by planting a manifest
// that cannot be decoded, and checks that the failure is reported as the // that cannot be decoded, and checks that the failure is reported once,
// prune's and not as a failed snapshot creation. // as the prune's and not as a failed snapshot creation.
// //
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals //nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
func TestEntrySnapshotCreateReportsPostBackupPruneFailure(t *testing.T) { func TestEntrySnapshotCreateReportsPostBackupPruneFailure(t *testing.T) {
@@ -81,12 +83,98 @@ func TestEntrySnapshotCreateReportsPostBackupPruneFailure(t *testing.T) {
assert.Contains(t, stderr, "Backup completed, but post-backup prune failed") assert.Contains(t, stderr, "Backup completed, but post-backup prune failed")
assert.NotContains(t, stderr, "Snapshot creation failed") assert.NotContains(t, stderr, "Snapshot creation failed")
_, repeats := failureOutput(t, stderr)
assert.Zero(t, repeats, "no log record may repeat the error:\n%s", stderr)
snapshots, err := os.ReadDir(filepath.Join(storeDir, "metadata")) snapshots, err := os.ReadDir(filepath.Join(storeDir, "metadata"))
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, snapshots, 2, assert.Len(t, snapshots, 2,
"the new snapshot is on the store beside the planted one") "the new snapshot is on the store beside the planted one")
} }
// TestEntrySnapshotCreatePruneExitsNonZeroWhenSourceIsEmpty backs up,
// empties the source directory, as a volume that is not mounted leaves
// its mount point, and runs `snapshot create --prune`. The command exits
// non-zero and reports the backup as completed, not as a failed snapshot
// creation. The error is printed once. See
// https://git.eeqj.de/sneak/vaultik/issues/388.
//
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
func TestEntrySnapshotCreatePruneExitsNonZeroWhenSourceIsEmpty(t *testing.T) {
configPath, storeDir := writeBackupConfig(t)
code, _, _ := runEntry(t, flagConfig, configPath, cmdSnapshot, cmdCreate)
require.Equal(t, 0, code)
// writeBackupConfig puts the source directory beside the config file.
require.NoError(t, os.Remove(
filepath.Join(filepath.Dir(configPath), "source", "file.txt")))
code, _, stderr := runEntry(t,
flagConfig, configPath, cmdSnapshot, cmdCreate, flagPrune)
assert.Equal(t, 1, code)
assert.Contains(t, stderr,
"Backup completed, but 1 source path(s) were empty")
assert.NotContains(t, stderr, "Snapshot creation failed")
_, repeats := failureOutput(t, stderr)
assert.Zero(t, repeats, "no log record may repeat the error:\n%s", stderr)
snapshots, err := os.ReadDir(filepath.Join(storeDir, "metadata"))
require.NoError(t, err)
assert.Len(t, snapshots, 2, "the older snapshot is kept")
}
// TestEntrySnapshotCreatePruneExitsNonZeroWhenMountPointIsMissing backs
// up, records in the local index that this backup found the source
// directory as a mount point, and runs `snapshot create --prune`, which
// does not find it as one. The command exits non-zero, reports the
// backup as completed, not as a failed snapshot creation, and keeps the
// older snapshot. The error is printed once. See
// https://git.eeqj.de/sneak/vaultik/issues/395.
//
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
func TestEntrySnapshotCreatePruneExitsNonZeroWhenMountPointIsMissing(
t *testing.T,
) {
configPath, storeDir := writeBackupConfig(t)
code, _, _ := runEntry(t, flagConfig, configPath, cmdSnapshot, cmdCreate)
require.Equal(t, 0, code)
// writeBackupConfig puts the source directory and the local index
// beside the config file.
dir := filepath.Dir(configPath)
ctx := context.Background()
db, err := database.New(ctx, filepath.Join(dir, "index.sqlite"))
require.NoError(t, err)
repos := database.NewRepositories(db)
backedUp, err := repos.Snapshots.GetOnlySnapshot(ctx)
require.NoError(t, err)
require.NoError(t, repos.Snapshots.AddMountPoint(ctx, nil,
backedUp.ID.String(), filepath.Join(dir, "source")))
require.NoError(t, db.Close())
code, _, stderr := runEntry(t,
flagConfig, configPath, cmdSnapshot, cmdCreate, flagPrune)
assert.Equal(t, 1, code)
assert.Contains(t, stderr, "Backup completed, but 1 mount point(s) "+
"an earlier snapshot recorded were not found")
assert.NotContains(t, stderr, "Snapshot creation failed")
_, repeats := failureOutput(t, stderr)
assert.Zero(t, repeats, "no log record may repeat the error:\n%s", stderr)
snapshots, err := os.ReadDir(filepath.Join(storeDir, "metadata"))
require.NoError(t, err)
assert.Len(t, snapshots, 2, "the older snapshot is kept")
}
// writeBackupConfig builds a hermetic config with one small file in its // writeBackupConfig builds a hermetic config with one small file in its
// source directory and an empty destination store directory. Returns the // source directory and an empty destination store directory. Returns the
// config path and the store directory. // config path and the store directory.
@@ -0,0 +1,30 @@
package cli //nolint:testpackage // shares runEntry and the hermetic config helpers
import (
"testing"
"github.com/stretchr/testify/assert"
)
// TestEntrySnapshotCreateSkipErrorsExitsNonZeroWhenItSkips backs up,
// under --skip-errors, a source directory that does not exist, which the
// walk skips. The command exits non-zero and reports the backup as
// completed, not as a failed snapshot creation. See
// https://git.eeqj.de/sneak/vaultik/issues/310.
//
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
func TestEntrySnapshotCreateSkipErrorsExitsNonZeroWhenItSkips(t *testing.T) {
// writeBackupConfig would create the source directory.
configPath := writeHermeticPruneConfig(t, false)
code, stdout, stderr := runEntry(t,
flagConfig, configPath, "--skip-errors", cmdSnapshot, cmdCreate)
assert.Equal(t, 1, code)
assert.Contains(t, stdout,
"Files: 1 examined, 0 backed up, 0 unchanged, 1 failed.")
assert.NotContains(t, stdout, "Finished successfully.")
assert.Contains(t, stderr,
"Backup completed, but 1 file(s) could not be read")
assert.NotContains(t, stderr, "Snapshot creation failed")
}
+8 -1
View File
@@ -41,6 +41,11 @@ func TestEntrySnapshotPurgeKeepsMetadataWhenLocalIndexDeleteFails(t *testing.T)
// too, so the purge selects it rather than dropping it as stale. // too, so the purge selects it rather than dropping it as stale.
seedStaleSnapshotRecord(t, indexPath) seedStaleSnapshotRecord(t, indexPath)
// While the local index holds a snapshot, purge fails on a
// destination that answers that it has no marker.
require.NoError(t, os.WriteFile(
filepath.Join(dir, "store", "vaultik-destination.txt"), nil, 0o600))
manifestDir := filepath.Join(dir, "store", "metadata", manifestDir := filepath.Join(dir, "store", "metadata",
snapshot.RemoteSnapshotKey(stalePruneSnapshotID)) snapshot.RemoteSnapshotKey(stalePruneSnapshotID))
require.NoError(t, os.MkdirAll(manifestDir, 0o750)) require.NoError(t, os.MkdirAll(manifestDir, 0o750))
@@ -59,10 +64,12 @@ func TestEntrySnapshotPurgeKeepsMetadataWhenLocalIndexDeleteFails(t *testing.T)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, db.Close()) require.NoError(t, db.Close())
code, _, _ := runEntry(t, flagConfig, configPath, code, _, stderr := runEntry(t, flagConfig, configPath,
cmdSnapshot, cmdPurge, flagOlderThan, "30d", flagForce) cmdSnapshot, cmdPurge, flagOlderThan, "30d", flagForce)
assert.Equal(t, 1, code) assert.Equal(t, 1, code)
assert.Contains(t, stderr, "simulated index error",
"the purge must fail at the local-index delete")
assert.FileExists(t, manifestPath, assert.FileExists(t, manifestPath,
"the snapshot is still in the local index, so its metadata must stay") "the snapshot is still in the local index, so its metadata must stay")
} }
+1 -1
View File
@@ -39,7 +39,7 @@ func NewInfoCommand() *cobra.Command {
}, func(v *vaultik.Vaultik) error { }, func(v *vaultik.Vaultik) error {
return v.ShowInfo() return v.ShowInfo()
}, func(err error) { }, func(err error) {
log.Error("Failed to show info", "error", err) log.Info("Failed to show info", "error", err)
ReportErrorf("Failed to show info: %v", err) ReportErrorf("Failed to show info: %v", err)
}) })
}, },
+19 -5
View File
@@ -13,14 +13,28 @@ func NewPruneCommand() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "prune", Use: "prune",
Short: "Tidy local database and remote storage", Short: "Tidy local database and remote storage",
Long: `Removes orphaned data from both the local index database and Long: `Removes orphaned data from the local index database, and
unreferenced blobs from the backup destination store. unreferenced blobs and directories under metadata/ with no manifest
from the backup destination store.
Local cleanup drops incomplete snapshots and any files, chunks, or Needs permission to list, read and delete objects on the destination
store. It does not need the age private key.
Local cleanup drops snapshots whose manifest is gone from the
destination store, incomplete snapshots, and any files, chunks, or
blobs no longer referenced by a completed snapshot. Remote cleanup blobs no longer referenced by a completed snapshot. Remote cleanup
scans every snapshot manifest in the destination store, builds the scans every snapshot manifest in the destination store, builds the
set of still-referenced blob hashes, and deletes any blob not in that set of still-referenced blob hashes, and deletes any blob not in that
set. set that is older than prune_grace_period (default 24h). A newer one
is kept: it may belong to a backup still running, on this host or
another sharing the destination store, that has not yet uploaded its
manifest. The grace period does not protect a blob such a backup
reuses from an earlier run that stopped before its manifest: that
blob keeps the time of its first upload, so it can be deleted.
Remote cleanup also deletes each directory under metadata/ with no
manifest, as a backup that stopped before its manifest leaves, once
all its objects are older than prune_grace_period and its snapshot is
not in the local index.
Snapshot create --prune runs the same cleanup automatically; this Snapshot create --prune runs the same cleanup automatically; this
command is the manual entry point for the same work (e.g. after a command is the manual entry point for the same work (e.g. after a
@@ -50,7 +64,7 @@ referenced.`,
}, func(v *vaultik.Vaultik) error { }, func(v *vaultik.Vaultik) error {
return v.Prune(opts) return v.Prune(opts)
}, func(err error) { }, func(err error) {
log.Error("Prune operation failed", "error", err) log.Info("Prune operation failed", "error", err)
ReportErrorf("Prune failed: %v", err) ReportErrorf("Prune failed: %v", err)
}) })
}, },
+11 -6
View File
@@ -40,11 +40,13 @@ func newRemoteNukeCommand() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "nuke", Use: "nuke",
Short: "Delete ALL snapshot metadata and blobs from the backup destination store", Short: "Delete ALL snapshot metadata and blobs from the backup destination store",
Long: `Removes every snapshot's metadata and every blob from remote Long: `Removes every snapshot's metadata, every blob and the destination
storage. After this command completes successfully the bucket prefix is marker from the backup destination store, and each removed snapshot from
empty and the next backup starts from scratch. the local index. After this command completes successfully the backup
destination store is empty and the next backup starts from scratch.
This is destructive and irreversible. Requires --force.`, This is destructive and irreversible. Requires --force, and permission
to list and delete objects on the destination store.`,
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error { RunE: func(cmd *cobra.Command, _ []string) error {
if !force { if !force {
@@ -75,7 +77,10 @@ func newRemoteInfoCommand() *cobra.Command {
- Size of all snapshot metadata (per snapshot and total) - Size of all snapshot metadata (per snapshot and total)
- Count and total size of all blobs - Count and total size of all blobs
- Count and size of referenced blobs (from all manifests) - Count and size of referenced blobs (from all manifests)
- Count and size of orphaned blobs (not referenced by any manifest)`, - Count and size of orphaned blobs (not referenced by any manifest)
Needs permission to list and read the destination store. It does not
need the age private key.`,
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error { RunE: func(cmd *cobra.Command, _ []string) error {
// Use unified config resolution // Use unified config resolution
@@ -98,7 +103,7 @@ func newRemoteInfoCommand() *cobra.Command {
}, func(v *vaultik.Vaultik) error { }, func(v *vaultik.Vaultik) error {
return v.RemoteInfo(jsonOutput) return v.RemoteInfo(jsonOutput)
}, func(err error) { }, func(err error) {
log.Error("Failed to get remote info", "error", err) log.Info("Failed to get remote info", "error", err)
ReportErrorf("Failed to get remote info: %v", err) ReportErrorf("Failed to get remote info: %v", err)
}) })
}, },
+9 -5
View File
@@ -35,9 +35,10 @@ func NewRootCommand() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "vaultik", Use: "vaultik",
Short: "Secure incremental backup tool with asymmetric encryption", Short: "Secure incremental backup tool with asymmetric encryption",
Long: `vaultik is a secure incremental backup tool that encrypts data using age Long: `vaultik is an incremental backup tool that encrypts data to age public
public keys and uploads to S3-compatible storage. No private keys are needed keys and uploads it to an S3-compatible object store, a local directory or
on the source system.`, an rclone remote. The backed-up machine needs no private key: only
'snapshot restore' and 'snapshot verify --deep' need one.`,
SilenceUsage: true, SilenceUsage: true,
// Bare 'vaultik' (no subcommand): print help. The banner is // Bare 'vaultik' (no subcommand): print help. The banner is
// printed once at process startup by Entry, before cobra // printed once at process startup by Entry, before cobra
@@ -50,13 +51,16 @@ on the source system.`,
// Add global flags // Add global flags
cmd.PersistentFlags().StringVar(&rootFlags.ConfigPath, "config", "", cmd.PersistentFlags().StringVar(&rootFlags.ConfigPath, "config", "",
"Path to config file (default: $VAULTIK_CONFIG or platform config dir)") "Path to config file (default: $VAULTIK_CONFIG, then the platform "+
"config directory, then /etc/vaultik/config.yml)")
cmd.PersistentFlags().BoolVarP(&rootFlags.Verbose, "verbose", "v", false, cmd.PersistentFlags().BoolVarP(&rootFlags.Verbose, "verbose", "v", false,
"Enable verbose output") "Enable verbose output")
cmd.PersistentFlags().BoolVar(&rootFlags.Debug, "debug", false, cmd.PersistentFlags().BoolVar(&rootFlags.Debug, "debug", false,
"Enable debug output") "Enable debug output")
cmd.PersistentFlags().BoolVarP(&rootFlags.Quiet, "quiet", "q", false, cmd.PersistentFlags().BoolVarP(&rootFlags.Quiet, "quiet", "q", false,
"Suppress non-error output") "Leave out the banner, status lines, tables and reports (warnings, "+
"errors, JSON documents, 'config get' values and confirmation "+
"prompts are still printed)")
cmd.PersistentFlags().BoolVar(&rootFlags.SkipErrors, "skip-errors", false, cmd.PersistentFlags().BoolVar(&rootFlags.SkipErrors, "skip-errors", false,
"Skip files that cannot be read when creating a snapshot, or "+ "Skip files that cannot be read when creating a snapshot, or "+
"that cannot be restored when restoring, instead of aborting "+ "that cannot be restored when restoring, instead of aborting "+
+99 -36
View File
@@ -60,6 +60,22 @@ func NewSnapshotCommand() *cobra.Command {
return cmd return cmd
} }
// snapshotCreateLong is the long help text of 'snapshot create'.
const snapshotCreateLong = `Creates new snapshots of the configured directories.
If snapshot names are provided, only those snapshots are created.
If no names are provided, all configured snapshots are created.
Needs at least one public key in age_recipients, and permission to write
objects at the backup destination store. It does not need the age private
key. With --prune it also needs permission to list, read and delete objects
there. An rclone remote that can move objects also needs permission to
read and delete.
The config is read from the path given by --config or VAULTIK_CONFIG;
otherwise from the platform config directory (~/.config/vaultik/config.yml
on Linux), then /etc/vaultik/config.yml.`
// newSnapshotCreateCommand creates the 'snapshot create' subcommand // newSnapshotCreateCommand creates the 'snapshot create' subcommand
func newSnapshotCreateCommand() *cobra.Command { func newSnapshotCreateCommand() *cobra.Command {
opts := &vaultik.SnapshotCreateOptions{} opts := &vaultik.SnapshotCreateOptions{}
@@ -67,14 +83,7 @@ func newSnapshotCreateCommand() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "create [snapshot-names...]", Use: "create [snapshot-names...]",
Short: "Create new snapshots", Short: "Create new snapshots",
Long: `Creates new snapshots of the configured directories. Long: snapshotCreateLong,
If snapshot names are provided, only those snapshots are created.
If no names are provided, all configured snapshots are created.
The config is read from the path given by --config or VAULTIK_CONFIG;
otherwise from the platform config directory (~/.config/vaultik/config.yml
on Linux), then /etc/vaultik/config.yml.`,
Args: cobra.ArbitraryArgs, Args: cobra.ArbitraryArgs,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
err := opts.Validate() err := opts.Validate()
@@ -109,13 +118,22 @@ on Linux), then /etc/vaultik/config.yml.`,
return v.CreateSnapshot(opts) return v.CreateSnapshot(opts)
}, func(err error) { }, func(err error) {
if errors.Is(err, vaultik.ErrPostBackupPruneFailed) { if errors.Is(err, vaultik.ErrPostBackupPruneFailed) {
log.Error("Post-backup prune failed", "error", err) log.Info("Post-backup prune failed", "error", err)
ReportErrorf("Backup completed, but %v", err) ReportErrorf("Backup completed, but %v", err)
return return
} }
log.Error("Snapshot creation failed", "error", err) if errors.Is(err, vaultik.ErrFilesUnreadable) ||
errors.Is(err, vaultik.ErrSourcePathEmpty) ||
errors.Is(err, vaultik.ErrVolumeNotMounted) {
log.Info("Backup completed with an error", "error", err)
ReportErrorf("Backup completed, but %v", err)
return
}
log.Info("Snapshot creation failed", "error", err)
ReportErrorf("Snapshot creation failed: %v", err) ReportErrorf("Snapshot creation failed: %v", err)
}) })
}, },
@@ -124,8 +142,13 @@ on Linux), then /etc/vaultik/config.yml.`,
cmd.Flags().BoolVar(&opts.Cron, "cron", false, cmd.Flags().BoolVar(&opts.Cron, "cron", false,
"Run in cron mode (silent unless warning or error)") "Run in cron mode (silent unless warning or error)")
cmd.Flags().BoolVar(&opts.Prune, "prune", false, cmd.Flags().BoolVar(&opts.Prune, "prune", false,
"After backup, drop older snapshots of the same name and remove "+ "After backup, drop older snapshots of the same name, remove "+
"orphaned blobs") "orphaned blobs older than prune_grace_period, and delete each "+
"directory under metadata/ with no manifest once all its "+
"objects are older than prune_grace_period and its snapshot is "+
"not in the local index (not after a backup that skipped "+
"unreadable files, found a configured path empty, or did not "+
"find a mount point an earlier snapshot found)")
cmd.Flags().StringVar(&opts.KeepNewerThan, "keep-newer-than", "", cmd.Flags().StringVar(&opts.KeepNewerThan, "keep-newer-than", "",
"With --prune: keep snapshots newer than this duration "+ "With --prune: keep snapshots newer than this duration "+
"(e.g. 30d, 4w, 6mo, 1y; m is minutes, mo is months) "+ "(e.g. 30d, 4w, 6mo, 1y; m is minutes, mo is months) "+
@@ -142,7 +165,14 @@ func newSnapshotListCommand() *cobra.Command {
Use: "list", Use: "list",
Aliases: []string{"ls"}, Aliases: []string{"ls"},
Short: "List all snapshots", Short: "List all snapshots",
Long: "Lists all snapshots with their ID, timestamp, and compressed size", Long: `Lists the snapshots in the local index and on the backup destination
store, with their ID, timestamp, and sizes.
Needs permission to list and read the destination store. It does not need
the age private key. A snapshot found only on the destination store is
listed by its remote key, because its ID is stored only in the local index
and in the snapshot's encrypted database. If the destination store cannot
be listed, the command warns and lists the local index alone.`,
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error { RunE: func(cmd *cobra.Command, _ []string) error {
return runVaultikApp(cmd, readOnly, false, return runVaultikApp(cmd, readOnly, false,
@@ -165,11 +195,19 @@ func newSnapshotPurgeCommand() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "purge", Use: "purge",
Short: "Purge old snapshots", Short: "Purge old snapshots",
Long: `Removes snapshots based on age or count criteria. Long: `Removes snapshots based on age or count criteria. Exactly one of
--keep-latest and --older-than is required.
Retention is per-snapshot-name: --keep-latest keeps the latest of each Retention is per-snapshot-name: --keep-latest keeps the latest snapshot of
configured snapshot name, not the latest globally. Use --snapshot to each name, not the latest globally. Use --snapshot to restrict the
restrict the operation to specific snapshot names.`, operation to specific snapshot names.
Only snapshots in the local index are removed. Each one is removed from
the local index and its metadata is deleted from the backup destination
store. Blobs are not touched: run 'vaultik prune' afterwards.
Needs permission to list and delete objects on the destination store. If
the destination store cannot be listed, nothing is removed.`,
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error { RunE: func(cmd *cobra.Command, _ []string) error {
// Validate flags // Validate flags
@@ -208,14 +246,30 @@ func newSnapshotVerifyCommand() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "verify <snapshot-id>", Use: "verify <snapshot-id>",
Short: "Check a snapshot's blobs are present with the listed size", Short: "Check a snapshot's blobs are present with the listed size",
Long: "Checks that every blob the snapshot's manifest lists is present\n" + Long: `Checks that every blob the snapshot's manifest lists is present
"in storage with the size the manifest records, and that the\n" + in storage with the size the manifest records, and that the
"snapshot's encrypted database is present. It does not read blob\n" + snapshot's encrypted database is present. It does not read blob
"contents; use --deep to download, decrypt, and re-hash every blob\n" + contents and does not need the age private key.
"to detect corruption -- integrity, not who wrote it.\n\n" +
"The snapshot may be named by its ID or, on a host with no local\n" + Needs permission to read the backup destination store, and to list it
"index, by the remote key that 'snapshot list' prints for a\n" + when given a remote key, or a snapshot ID that is not in the local
"remote-only snapshot (an unambiguous leading part is enough).", index.
Use --deep to download, decrypt, and re-hash every blob to detect
corruption -- integrity, not who wrote it.
--deep requires the age private key, in the VAULTIK_AGE_SECRET_KEY
environment variable or as age_secret_key in the config file; the
environment variable wins. The variable may hold the whole age-keygen
file, with its comments and all of its keys. Read the key from the
file rather than typing it, so that it does not land in your shell
history:
export VAULTIK_AGE_SECRET_KEY="$(cat vaultik_backup_private_key.txt)"
The snapshot may be named by its ID or, on a host with no local
index, by the remote key that 'snapshot list' prints for a
remote-only snapshot (an unambiguous leading part is enough).`,
Args: requireSnapshotIDArg, Args: requireSnapshotIDArg,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
snapshotID := args[0] snapshotID := args[0]
@@ -244,13 +298,16 @@ func newSnapshotVerifyCommand() *cobra.Command {
return return
} }
log.Error("Verification failed", "error", err) log.Info("Verification failed", "error", err)
ReportErrorf("Verification failed: %v", err) ReportErrorf("Verification failed: %v", err)
}) })
}, },
} }
cmd.Flags().BoolVar(&opts.Deep, "deep", false, "Download and verify blob hashes") cmd.Flags().BoolVar(&opts.Deep, "deep", false,
"Download, decrypt and re-hash every blob; requires the age private "+
"key in VAULTIK_AGE_SECRET_KEY, or as age_secret_key in the config "+
"file")
cmd.Flags().BoolVar(&opts.JSON, "json", false, "Output verification results as JSON") cmd.Flags().BoolVar(&opts.JSON, "json", false, "Output verification results as JSON")
return cmd return cmd
@@ -270,20 +327,25 @@ By default, this removes the snapshot from the local index database and
strips the snapshot's metadata from the backup destination store. Blobs strips the snapshot's metadata from the backup destination store. Blobs
are NOT touched: deleting them requires reading every remaining remote are NOT touched: deleting them requires reading every remaining remote
manifest (the destination store may hold snapshots this host doesn't manifest (the destination store may hold snapshots this host doesn't
know about), which is what 'vaultik prune' does. On success the command know about), which is what 'vaultik prune' does. The command prints the
prints the exact 'vaultik prune' invocation to run as a follow-up. 'vaultik prune' invocation to run as a follow-up only when it removed the
snapshot's metadata from the destination store, and never under --json.
An ID that names no snapshot in the local index or on the destination The argument must be a snapshot ID: a remote key is not accepted. An ID
store is an error. that names no snapshot in the local index or on the destination store is
an error. Needs permission to list and delete objects on the destination
store, unless --local-only is given.
Use --local-only to skip the remote half (e.g. when you want to forget a Use --local-only to skip the remote half (e.g. when you want to forget a
snapshot locally without touching the destination store); the snapshot snapshot locally without touching the destination store); the snapshot
must then be in the local index. must then be in the local index.
If the remote is unreachable, the local-database removal still completes If removing the snapshot's metadata from the destination store fails (for
and a warning is emitted; run 'vaultik snapshot remove <snapshot-id>' again example because the remote is unreachable), the local-database removal
once the destination store is reachable to remove the snapshot's metadata still completes and a warning is emitted; run 'vaultik snapshot remove
from it ('vaultik prune' does not). <snapshot-id>' again once the destination store is reachable to remove
the snapshot's metadata from it ('vaultik prune' does not). For a
snapshot that is only on the destination store, that failure is an error.
To wipe the entire destination store and start over, use 'vaultik remote To wipe the entire destination store and start over, use 'vaultik remote
nuke --force' — it is the single supported entry point for that.`, nuke --force' — it is the single supported entry point for that.`,
@@ -301,7 +363,8 @@ nuke --force' — it is the single supported entry point for that.`,
cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "Skip confirmation prompt") cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "Skip confirmation prompt")
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false,
"Show what would be removed without removing") "Show what would be removed without removing; needs no permission "+
"to delete")
cmd.Flags().BoolVar(&opts.JSON, "json", false, cmd.Flags().BoolVar(&opts.JSON, "json", false,
"Output result as JSON; skips the confirmation prompt, as --force does") "Output result as JSON; skips the confirmation prompt, as --force does")
cmd.Flags().BoolVar(&opts.LocalOnly, "local-only", false, cmd.Flags().BoolVar(&opts.LocalOnly, "local-only", false,
+14 -5
View File
@@ -37,13 +37,22 @@ The snapshot may be named by its ID or, when restoring on a host with no
local index, by the remote key that 'snapshot list' prints for a local index, by the remote key that 'snapshot list' prints for a
remote-only snapshot (an unambiguous leading part is enough). remote-only snapshot (an unambiguous leading part is enough).
Requires the age private key in the VAULTIK_AGE_SECRET_KEY environment Each file is restored under its original absolute path inside the target
variable. The variable may hold the whole age-keygen file (comments and directory. File owners are restored only when the command runs as root.
all of its identities are accepted); read it from the file rather than
typing the key, so it does not land in your shell history: Requires the age private key, in the VAULTIK_AGE_SECRET_KEY environment
variable or as age_secret_key in the config file; the environment
variable wins. The variable may hold the whole age-keygen file, with its
comments and all of its keys. Read the key from the file rather than
typing it, so that it does not land in your shell history:
export VAULTIK_AGE_SECRET_KEY="$(cat vaultik_backup_private_key.txt)" export VAULTIK_AGE_SECRET_KEY="$(cat vaultik_backup_private_key.txt)"
Also needs permission to read the backup destination store, and to list
it when given a remote key, or a snapshot ID that is not in the local
index. It also needs free space in the temporary directory for the
decrypted blobs.
Examples: Examples:
# Restore entire snapshot # Restore entire snapshot
vaultik snapshot restore myhost_docs_2025-01-01T12:00:00Z /restore vaultik snapshot restore myhost_docs_2025-01-01T12:00:00Z /restore
@@ -104,7 +113,7 @@ func runRestore(cmd *cobra.Command, args []string, opts *RestoreOptions) error {
SkipErrors: rootFlags.SkipErrors, SkipErrors: rootFlags.SkipErrors,
}) })
}, func(err error) { }, func(err error) {
log.Error("Restore operation failed", "error", err) log.Info("Restore operation failed", "error", err)
ReportErrorf("Restore failed: %v", err) ReportErrorf("Restore failed: %v", err)
}) })
} }
+1 -1
View File
@@ -38,7 +38,7 @@ func writeVersion(out *ui.Writer) {
_, _ = fmt.Fprintf(w, "vaultik %s\n", globals.Version) _, _ = fmt.Fprintf(w, "vaultik %s\n", globals.Version)
_, _ = fmt.Fprintf(w, " commit: %s\n", globals.Commit) _, _ = fmt.Fprintf(w, " commit: %s\n", globals.Commit)
_, _ = fmt.Fprintf(w, " build date: %s\n", globals.CommitDate) _, _ = fmt.Fprintf(w, " commit date: %s\n", globals.CommitDate)
_, _ = fmt.Fprintf(w, " go: %s\n", runtime.Version()) _, _ = fmt.Fprintf(w, " go: %s\n", runtime.Version())
_, _ = fmt.Fprintf(w, " os/arch: %s/%s\n", runtime.GOOS, runtime.GOARCH) _, _ = fmt.Fprintf(w, " os/arch: %s/%s\n", runtime.GOOS, runtime.GOARCH)
_, _ = fmt.Fprintf(w, " author: %s\n", globals.Author) _, _ = fmt.Fprintf(w, " author: %s\n", globals.Author)
+37
View File
@@ -2,6 +2,7 @@ package cli_test
import ( import (
"bytes" "bytes"
"runtime"
"strings" "strings"
"testing" "testing"
@@ -49,6 +50,42 @@ func TestVersionCommandReportsBuildVersion(t *testing.T) {
} }
} }
// TestVersionCommandReportLines checks the label, padding and value of
// every line of the report. Commit and CommitDate get different values,
// so a line that prints the wrong one fails.
//
//nolint:paralleltest // sets globals.Commit and globals.CommitDate
func TestVersionCommandReportLines(t *testing.T) {
const (
commit = "abc1234"
commitDate = "2026-11-09"
)
previousCommit, previousCommitDate := globals.Commit, globals.CommitDate
t.Cleanup(func() {
globals.Commit = previousCommit
globals.CommitDate = previousCommitDate
})
globals.Commit = commit
globals.CommitDate = commitDate
want := "vaultik " + globals.Version + "\n" +
" commit: " + commit + "\n" +
" commit date: " + commitDate + "\n" +
" go: " + runtime.Version() + "\n" +
" os/arch: " + runtime.GOOS + "/" + runtime.GOARCH + "\n" +
" author: " + globals.Author + "\n" +
" homepage: " + globals.Homepage + "\n" +
" license: " + globals.License + "\n"
out := runVersionCommand(t)
if !strings.HasPrefix(out, want) {
t.Errorf("report:\n%s\nwant it to start with:\n%s", out, want)
}
}
// TestVersionCommandFlagsDevelopmentBuild is the regression test for // TestVersionCommandFlagsDevelopmentBuild is the regression test for
// the thing this command exists to prevent: a build that is not a // the thing this command exists to prevent: a build that is not a
// release must say so. The notice used to be gated on the version // release must say so. The notice used to be gated on the version
+67 -9
View File
@@ -9,8 +9,10 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"reflect"
"sort" "sort"
"strings" "strings"
"time"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/smartconfig" "git.eeqj.de/sneak/smartconfig"
@@ -38,6 +40,7 @@ const (
defaultChunkSize = Size(10 * 1000 * 1000) // 10MB defaultChunkSize = Size(10 * 1000 * 1000) // 10MB
defaultS3PartSize = Size(5 * 1024 * 1024) // 5MiB defaultS3PartSize = Size(5 * 1024 * 1024) // 5MiB
defaultCompressionLevel = 3 defaultCompressionLevel = 3
defaultPruneGracePeriod = 24 * time.Hour
minChunkSize = 1000 * 1000 // 1MB minChunkSize = 1000 * 1000 // 1MB
minCompressionLevel = 1 minCompressionLevel = 1
maxCompressionLevel = 19 maxCompressionLevel = 19
@@ -54,7 +57,7 @@ var (
errRecipientNotX25519 = errors.New( errRecipientNotX25519 = errors.New(
"not a valid recipient; only X25519 age1... public keys are supported") "not a valid recipient; only X25519 age1... public keys are supported")
errNoSnapshots = errors.New( errNoSnapshots = errors.New(
"at least one snapshot must be configured (see config.example.yml)") "at least one snapshot must be configured (see configs/config.example.yml)")
errSnapshotNoPaths = errors.New("snapshot must have at least one path") errSnapshotNoPaths = errors.New("snapshot must have at least one path")
errChunkSizeTooSmall = errors.New("chunk_size must be at least 1MB") errChunkSizeTooSmall = errors.New("chunk_size must be at least 1MB")
errBlobSizeTooSmall = errors.New( errBlobSizeTooSmall = errors.New(
@@ -67,12 +70,22 @@ var (
errFileURLNotAbsolute = errors.New( errFileURLNotAbsolute = errors.New(
"a file:// storage_url needs an absolute path, " + "a file:// storage_url needs an absolute path, " +
"as in file:///path/to/backup") "as in file:///path/to/backup")
errIndexPathNotAbsolute = errors.New(
"index_path and VAULTIK_INDEX_PATH need an absolute path, " +
"as in ~/.local/share/vaultik/index.sqlite")
errNegativePruneGracePeriod = errors.New(
"prune_grace_period must not be negative")
errStorageNotConfigured = errors.New( errStorageNotConfigured = errors.New(
"storage not configured; set storage_url or provide s3.endpoint + " + "storage not configured; set storage_url or provide s3.endpoint + " +
"s3.bucket + credentials") "s3.bucket + credentials")
errS3BucketRequired = errors.New("s3.bucket is required (or set storage_url)") errS3BucketRequired = errors.New("s3.bucket is required (or set storage_url)")
errS3KeyIDRequired = errors.New("s3.access_key_id is required") )
errS3SecretRequired = errors.New("s3.secret_access_key is required")
// Validate returns these when S3 storage is configured without its
// credentials.
var (
ErrS3KeyIDRequired = errors.New("s3.access_key_id is required")
ErrS3SecretRequired = errors.New("s3.secret_access_key is required")
) )
// expandTilde expands ~ at the start of a path to the user's home directory. // expandTilde expands ~ at the start of a path to the user's home directory.
@@ -187,6 +200,15 @@ type Config struct {
Snapshots map[string]SnapshotConfig `yaml:"snapshots"` Snapshots map[string]SnapshotConfig `yaml:"snapshots"`
CompressionLevel int `yaml:"compression_level"` CompressionLevel int `yaml:"compression_level"`
// PruneGracePeriod is how old a blob no manifest lists must be before
// a prune deletes it, and how old every object in a directory under
// metadata/ with no manifest must be before a prune deletes that
// directory. A backup writes its manifest only once it has uploaded
// all its blobs and its db.zst.age, so a younger blob or directory may
// belong to a backup still running, on this host or another sharing
// the destination store.
PruneGracePeriod time.Duration `yaml:"prune_grace_period"`
// StorageURL specifies the storage backend using a URL format. // StorageURL specifies the storage backend using a URL format.
// Takes precedence over S3Config if set. // Takes precedence over S3Config if set.
// Supported formats: // Supported formats:
@@ -273,21 +295,46 @@ func Load(path string) (*Config, error) {
} }
// Parse reads YAML config data as Load does, without validating it. It // Parse reads YAML config data as Load does, without validating it. It
// applies default values for optional fields, fills in ${...} references // refuses a key that no setting has and a size setting whose value is not a
// using smartconfig, and expands ~ in paths. // size, naming the setting. It applies default values for optional
// fields, fills in ${...} references using smartconfig, and expands ~ in
// paths.
func Parse(data []byte) (*Config, error) { func Parse(data []byte) (*Config, error) {
// smartconfig drops line numbers, so keys are checked in the data as
// written.
var root yaml.Node
err := yaml.Unmarshal(data, &root)
if err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
err = checkKeys(&root, reflect.TypeFor[Config](), "",
map[*yaml.Node]bool{}, map[nodeAndType]bool{})
if err != nil {
return nil, err
}
// Load config using smartconfig for interpolation // Load config using smartconfig for interpolation
sc, err := smartconfig.NewFromReader(bytes.NewReader(data)) sc, err := smartconfig.NewFromReader(bytes.NewReader(data))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to load config file: %w", err) return nil, fmt.Errorf("failed to load config file: %w", err)
} }
// A ${...} reference can bring in keys that are not in the file, and
// is the value of a size setting only once it is filled in.
err = checkFilledConfig(sc.Data())
if err != nil {
return nil, err
}
cfg := &Config{ cfg := &Config{
// Set defaults // Set defaults
BlobSizeLimit: defaultBlobSizeLimit, BlobSizeLimit: defaultBlobSizeLimit,
ChunkSize: defaultChunkSize, ChunkSize: defaultChunkSize,
IndexPath: filepath.Join(xdg.DataHome, appName, "index.sqlite"), IndexPath: filepath.Join(xdg.DataHome, appName, "index.sqlite"),
CompressionLevel: defaultCompressionLevel, CompressionLevel: defaultCompressionLevel,
PruneGracePeriod: defaultPruneGracePeriod,
S3: S3Config{PartSize: defaultS3PartSize}, S3: S3Config{PartSize: defaultS3PartSize},
} }
@@ -350,12 +397,15 @@ func Parse(data []byte) (*Config, error) {
// create needs a recipient and it checks for one itself // create needs a recipient and it checks for one itself
// - At least one snapshot must be configured with at least one path // - At least one snapshot must be configured with at least one path
// - Storage must be configured (either storage_url or s3.* fields) // - Storage must be configured (either storage_url or s3.* fields)
// - The index path must be absolute: a relative one would resolve against
// the working directory, so each directory would get its own index
// - Chunk size must be at least 1MB // - Chunk size must be at least 1MB
// - Blob size limit must be at least the largest chunk the chunker can emit // - Blob size limit must be at least the largest chunk the chunker can emit
// (chunk_size times chunker.ChunkSizeSpread), so a single-chunk blob never // (chunk_size times chunker.ChunkSizeSpread), so a single-chunk blob never
// exceeds the configured limit // exceeds the configured limit
// - Compression level must be between 1 and 19 // - Compression level must be between 1 and 19
// - S3 part size must be between 5MiB and 5GiB, the part sizes S3 accepts // - S3 part size must be between 5MiB and 5GiB, the part sizes S3 accepts
// - Prune grace period must not be negative
// //
// Returns an error describing the first validation failure encountered. // Returns an error describing the first validation failure encountered.
func (c *Config) Validate() error { func (c *Config) Validate() error {
@@ -382,6 +432,10 @@ func (c *Config) Validate() error {
return err return err
} }
if !filepath.IsAbs(c.IndexPath) {
return fmt.Errorf("%w: %q", errIndexPathNotAbsolute, c.IndexPath)
}
if c.ChunkSize.Int64() < minChunkSize { if c.ChunkSize.Int64() < minChunkSize {
return errChunkSizeTooSmall return errChunkSizeTooSmall
} }
@@ -405,6 +459,10 @@ func (c *Config) Validate() error {
return errBadS3PartSize return errBadS3PartSize
} }
if c.PruneGracePeriod < 0 {
return errNegativePruneGracePeriod
}
return nil return nil
} }
@@ -471,11 +529,11 @@ func (c *Config) validateStorage() error {
} }
if c.S3.AccessKeyID == "" { if c.S3.AccessKeyID == "" {
return errS3KeyIDRequired return ErrS3KeyIDRequired
} }
if c.S3.SecretAccessKey == "" { if c.S3.SecretAccessKey == "" {
return errS3SecretRequired return ErrS3SecretRequired
} }
return nil return nil
@@ -502,11 +560,11 @@ func (c *Config) validateStorageURL() error {
case strings.HasPrefix(c.StorageURL, "s3://"): case strings.HasPrefix(c.StorageURL, "s3://"):
// S3 storage needs credentials // S3 storage needs credentials
if c.S3.AccessKeyID == "" { if c.S3.AccessKeyID == "" {
return fmt.Errorf("%w for s3:// URLs", errS3KeyIDRequired) return fmt.Errorf("%w for s3:// URLs", ErrS3KeyIDRequired)
} }
if c.S3.SecretAccessKey == "" { if c.S3.SecretAccessKey == "" {
return fmt.Errorf("%w for s3:// URLs", errS3SecretRequired) return fmt.Errorf("%w for s3:// URLs", ErrS3SecretRequired)
} }
return nil return nil
+134 -4
View File
@@ -6,6 +6,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time"
"sneak.berlin/go/vaultik/internal/chunker" "sneak.berlin/go/vaultik/internal/chunker"
"sneak.berlin/go/vaultik/internal/log" "sneak.berlin/go/vaultik/internal/log"
@@ -89,12 +90,12 @@ func TestConfigLoad(t *testing.T) {
} }
// TestExampleConfigIsScrubbedAndLoads checks that the shipped // TestExampleConfigIsScrubbedAndLoads checks that the shipped
// config.example.yml carries only neutral placeholders (no real credentials, // configs/config.example.yml carries only neutral placeholders (no real
// private addresses, or internal host names) and still parses. // credentials, private addresses, or internal host names) and still parses.
func TestExampleConfigIsScrubbedAndLoads(t *testing.T) { func TestExampleConfigIsScrubbedAndLoads(t *testing.T) {
t.Parallel() t.Parallel()
examplePath := filepath.Join("..", "..", "config.example.yml") examplePath := filepath.Join("..", "..", "configs", "config.example.yml")
cfg, err := Load(examplePath) cfg, err := Load(examplePath)
if err != nil { if err != nil {
@@ -164,6 +165,7 @@ func TestValidateBlobSizeLimit(t *testing.T) {
AgeRecipients: []string{testSneakAgePublicKey}, AgeRecipients: []string{testSneakAgePublicKey},
Snapshots: map[string]SnapshotConfig{"test": {Paths: []string{"/tmp/src"}}}, Snapshots: map[string]SnapshotConfig{"test": {Paths: []string{"/tmp/src"}}},
StorageURL: "file:///tmp/vaultik-test-store", StorageURL: "file:///tmp/vaultik-test-store",
IndexPath: "/tmp/vaultik-test-index.sqlite",
ChunkSize: chunkSize, ChunkSize: chunkSize,
BlobSizeLimit: blobLimit, BlobSizeLimit: blobLimit,
CompressionLevel: 3, CompressionLevel: 3,
@@ -360,7 +362,7 @@ func TestDocumentedSizeDefaults(t *testing.T) {
} }
// TestDocumentedSizeExamples checks the 1MB minimum chunk_size and that // TestDocumentedSizeExamples checks the 1MB minimum chunk_size and that
// every size the README, config.example.yml and the config init template // every size the README, configs/config.example.yml and the config init template
// list as an example is accepted. // list as an example is accepted.
func TestDocumentedSizeExamples(t *testing.T) { func TestDocumentedSizeExamples(t *testing.T) {
t.Parallel() t.Parallel()
@@ -412,6 +414,74 @@ func TestDocumentedSizeExamples(t *testing.T) {
} }
} }
// TestValidatePruneGracePeriod checks that a negative prune_grace_period
// is refused rather than acting like 0s, which deletes unreferenced blobs
// and directories under metadata/ with no manifest at once, and that 0s
// and the documented examples are accepted.
func TestValidatePruneGracePeriod(t *testing.T) {
t.Parallel()
const withoutGracePeriod = "snapshots:\n" +
" test:\n" +
" paths: [/tmp/vaultik-test-source]\n" +
"storage_url: file:///tmp/vaultik-test-storage\n"
tests := []struct {
setting string
wantErr error
}{
{"prune_grace_period: 0s", nil},
{"prune_grace_period: 90m", nil},
{"prune_grace_period: 36h", nil},
{"prune_grace_period: -1h", errNegativePruneGracePeriod},
}
for _, tt := range tests {
t.Run(tt.setting, func(t *testing.T) {
t.Parallel()
cfg, err := Parse([]byte(withoutGracePeriod + tt.setting + "\n"))
if err != nil {
t.Fatalf("Parse() unexpected error: %v", err)
}
err = cfg.Validate()
if !errors.Is(err, tt.wantErr) {
t.Fatalf("Validate() error = %v, want %v", err, tt.wantErr)
}
})
}
}
// TestLoadPruneGracePeriod checks that a config file without
// prune_grace_period loads with the documented 24h. The value is written out
// here rather than taken from defaultPruneGracePeriod, so that a change of the
// default fails this test.
func TestLoadPruneGracePeriod(t *testing.T) {
t.Parallel()
const withoutGracePeriod = "snapshots:\n" +
" test:\n" +
" paths: [/tmp/vaultik-test-source]\n" +
"storage_url: file:///tmp/vaultik-test-storage\n"
path := filepath.Join(t.TempDir(), "config.yml")
err := os.WriteFile(path, []byte(withoutGracePeriod), 0o600)
if err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load() unexpected error: %v", err)
}
if cfg.PruneGracePeriod != 24*time.Hour {
t.Errorf("prune_grace_period = %s, want 24h", cfg.PruneGracePeriod)
}
}
// TestLoadRejectsRelativeFileURL checks that a file:// storage_url with a // TestLoadRejectsRelativeFileURL checks that a file:// storage_url with a
// relative path fails at load. Accepted, it would resolve against the // relative path fails at load. Accepted, it would resolve against the
// working directory, so a backup run from another directory would write // working directory, so a backup run from another directory would write
@@ -441,6 +511,65 @@ func TestLoadRejectsRelativeFileURL(t *testing.T) {
} }
} }
// TestLoadIndexPath checks that a relative index_path fails at load, naming
// the value. Accepted, it would resolve against the working directory, so a
// backup run from another directory would start from an empty index. A path
// starting with ~ is expanded and loads.
func TestLoadIndexPath(t *testing.T) {
t.Parallel()
home, err := os.UserHomeDir()
if err != nil {
t.Fatalf("home directory: %v", err)
}
load := func(t *testing.T, indexPath string) (*Config, error) {
t.Helper()
data := "snapshots:\n" +
" test:\n" +
" paths: [/tmp/vaultik-test-source]\n" +
"storage_url: file:///tmp/vaultik-test-storage\n" +
"index_path: " + indexPath + "\n"
path := filepath.Join(t.TempDir(), "config.yml")
err := os.WriteFile(path, []byte(data), 0o600)
if err != nil {
t.Fatalf("write config: %v", err)
}
return Load(path)
}
t.Run("relative path is rejected", func(t *testing.T) {
t.Parallel()
_, err := load(t, "relative/index.sqlite")
if !errors.Is(err, errIndexPathNotAbsolute) {
t.Fatalf("Load() error = %v, want errIndexPathNotAbsolute", err)
}
if !strings.Contains(err.Error(), `"relative/index.sqlite"`) {
t.Errorf("error %q does not name the value", err.Error())
}
})
t.Run("path starting with ~ is expanded", func(t *testing.T) {
t.Parallel()
cfg, err := load(t, "~/vaultik/index.sqlite")
if err != nil {
t.Fatalf("Load() unexpected error: %v", err)
}
want := filepath.Join(home, "vaultik", "index.sqlite")
if cfg.IndexPath != want {
t.Errorf("IndexPath = %q, want %q", cfg.IndexPath, want)
}
})
}
// TestValidateAgeRecipients checks that recipients are parsed at config load // TestValidateAgeRecipients checks that recipients are parsed at config load
// (a bad entry fails immediately, not mid-backup) and that no invalid entry — // (a bad entry fails immediately, not mid-backup) and that no invalid entry —
// least of all a pasted secret key — is echoed in the error. An empty list // least of all a pasted secret key — is echoed in the error. An empty list
@@ -453,6 +582,7 @@ func TestValidateAgeRecipients(t *testing.T) {
AgeRecipients: recipients, AgeRecipients: recipients,
Snapshots: map[string]SnapshotConfig{"test": {Paths: []string{"/tmp/src"}}}, Snapshots: map[string]SnapshotConfig{"test": {Paths: []string{"/tmp/src"}}},
StorageURL: "file:///tmp/vaultik-test-store", StorageURL: "file:///tmp/vaultik-test-store",
IndexPath: "/tmp/vaultik-test-index.sqlite",
ChunkSize: Size(10 * 1024 * 1024), ChunkSize: Size(10 * 1024 * 1024),
BlobSizeLimit: Size(10 * 1024 * 1024 * 1024), BlobSizeLimit: Size(10 * 1024 * 1024 * 1024),
CompressionLevel: 3, CompressionLevel: 3,
+220
View File
@@ -0,0 +1,220 @@
package config
import (
"errors"
"fmt"
"reflect"
"strconv"
"strings"
"gopkg.in/yaml.v3"
)
// yamlMergeTag is the tag YAML gives a merge key (<<), which adds the keys
// of the map it names to the map it sits in.
const yamlMergeTag = "!!merge"
// EnvSection is the top-level key of a map of environment variables that
// smartconfig sets while it loads the config. It is not a vaultik setting,
// so its keys are not checked.
const EnvSection = "env"
var (
errUnknownKey = errors.New("unknown config key")
errAnchorContainsItself = errors.New("anchor contains itself")
)
// nodeAndType is a node of a parsed config and the setting type it is
// checked against.
type nodeAndType struct {
node *yaml.Node
typ reflect.Type
}
// SettingType returns the type of the setting that the dotted key names in
// Config, following the fields' yaml tags, as for s3.bucket or
// snapshots.home.exclude.0. A snapshot name or a list index may be any
// word. It returns an unknown key error when no setting has that key.
func SettingType(keys []string) (reflect.Type, error) {
typ := reflect.TypeFor[Config]()
for _, key := range keys {
switch {
case typ.Kind() == reflect.Map || typ.Kind() == reflect.Slice:
typ = typ.Elem()
case typ.Kind() == reflect.Struct:
field, ok := yamlField(typ, key)
if !ok {
return nil, fmt.Errorf("%w %q", errUnknownKey, strings.Join(keys, "."))
}
typ = field.Type
default:
return nil, fmt.Errorf("%w %q", errUnknownKey, strings.Join(keys, "."))
}
}
return typ, nil
}
// yamlField returns the field of struct type typ whose yaml tag names key.
func yamlField(typ reflect.Type, key string) (reflect.StructField, bool) {
for field := range typ.Fields() {
name, _, _ := strings.Cut(field.Tag.Get("yaml"), ",")
if name == key && name != "-" {
return field, true
}
}
return reflect.StructField{}, false
}
// checkKeys returns an error naming the first key in node, a parsed config
// or part of one at the dotted path, that no setting in typ has. The error
// gives the key's line in the file, when it has one. A value of the wrong
// type is left for the YAML decoder to report. following holds the aliases
// the walk is inside. Meeting one of them again means its anchor contains
// itself, which is an error. checked holds each anchored node the walk has
// checked completely, with the type it was checked against. An alias to
// that node is not followed again for that type.
func checkKeys(node *yaml.Node, typ reflect.Type, path string,
following map[*yaml.Node]bool, checked map[nodeAndType]bool,
) error {
switch node.Kind {
case yaml.DocumentNode:
for _, child := range node.Content {
err := checkKeys(child, typ, path, following, checked)
if err != nil {
return err
}
}
case yaml.AliasNode:
if following[node] {
return fmt.Errorf("%w: %q on line %d",
errAnchorContainsItself, node.Value, node.Line)
}
target := nodeAndType{node.Alias, typ}
if checked[target] {
return nil
}
following[node] = true
defer delete(following, node)
err := checkKeys(node.Alias, typ, path, following, checked)
if err != nil {
return err
}
checked[target] = true
case yaml.SequenceNode:
if typ.Kind() != reflect.Slice {
return nil
}
for i, child := range node.Content {
err := checkKeys(child, typ.Elem(), joinKey(path, strconv.Itoa(i)),
following, checked)
if err != nil {
return err
}
}
case yaml.MappingNode:
return checkMappingKeys(node, typ, path, following, checked)
case yaml.ScalarNode:
// A scalar has no keys.
}
return nil
}
// checkFilledConfig checks data, the config after smartconfig has filled in
// its ${...} references: its keys with checkKeys, then its size settings
// with checkSizes. A key that a reference brings in is not in the file, and
// the copy that Encode makes has no line numbers.
func checkFilledConfig(data map[string]any) error {
var filled yaml.Node
err := filled.Encode(data)
if err != nil {
return fmt.Errorf("failed to marshal config data: %w", err)
}
err = checkKeys(&filled, reflect.TypeFor[Config](), "",
map[*yaml.Node]bool{}, map[nodeAndType]bool{})
if err != nil {
return err
}
return checkSizes(&filled, nil)
}
// checkMappingKeys is checkKeys for a map.
func checkMappingKeys(node *yaml.Node, typ reflect.Type, path string,
following map[*yaml.Node]bool, checked map[nodeAndType]bool,
) error {
for i := 0; i+1 < len(node.Content); i += 2 {
key, value := node.Content[i], node.Content[i+1]
if key.Tag == yamlMergeTag {
// The value is one map or a list of maps.
merged := []*yaml.Node{value}
if value.Kind == yaml.SequenceNode {
merged = value.Content
}
for _, m := range merged {
err := checkKeys(m, typ, path, following, checked)
if err != nil {
return err
}
}
continue
}
keyPath := joinKey(path, key.Value)
if keyPath == EnvSection {
continue
}
var valueType reflect.Type
switch {
case typ.Kind() == reflect.Map:
valueType = typ.Elem()
case typ.Kind() == reflect.Struct:
field, ok := yamlField(typ, key.Value)
if !ok && key.Line == 0 {
// A key that a ${...} reference brought in has no line.
return fmt.Errorf("%w %q", errUnknownKey, keyPath)
}
if !ok {
return fmt.Errorf("%w %q on line %d",
errUnknownKey, keyPath, key.Line)
}
valueType = field.Type
default:
return nil
}
err := checkKeys(value, valueType, keyPath, following, checked)
if err != nil {
return err
}
}
return nil
}
// joinKey appends key to the dotted path.
func joinKey(path, key string) string {
if path == "" {
return key
}
return path + "." + key
}
+163
View File
@@ -0,0 +1,163 @@
package config //nolint:testpackage // checks the unexported errUnknownKey
import (
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"testing"
)
// keysTestConfig loads; each case below adds one line to it.
const keysTestConfig = "storage_url: file:///tmp/vaultik-test-storage\n" +
"s3:\n" +
" region: us-east-1\n" +
"snapshots:\n" +
" test:\n" +
" paths: [/tmp/vaultik-test-source]\n"
// TestParseRefusesUnknownKey checks that a key no setting has, at any
// depth, fails the parse with an error naming the key and its line. A key
// that a ${...} reference brings in is not in the file, so its error gives
// no line.
func TestParseRefusesUnknownKey(t *testing.T) {
t.Parallel()
snapshotPath := filepath.Join(t.TempDir(), "snapshot.yml")
err := os.WriteFile(snapshotPath, []byte("other:\n"+
" paths: [/tmp/vaultik-test-source]\n"+
" exlude: [/tmp/cache]\n"), 0o600)
if err != nil {
t.Fatalf("write snapshot: %v", err)
}
tests := []struct {
name string
yaml string
want string
}{
{"top level", keysTestConfig + "compression_lvl: 9\n",
`unknown config key "compression_lvl" on line 7`},
{"in a snapshot", keysTestConfig + " exlude: [/tmp/cache]\n",
`unknown config key "snapshots.test.exlude" on line 7`},
{"in s3", strings.Replace(keysTestConfig, " region:",
" bukcet: b\n region:", 1),
`unknown config key "s3.bukcet" on line 3`},
{"from a reference",
keysTestConfig + " other: ${YAML:" + snapshotPath + ":other}\n",
`unknown config key "snapshots.other.exlude"`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := Parse([]byte(tt.yaml))
if !errors.Is(err, errUnknownKey) {
t.Fatalf("Parse() error = %v, want errUnknownKey", err)
}
if err.Error() != tt.want {
t.Errorf("Parse() error = %q, want %q", err.Error(), tt.want)
}
})
}
}
// TestParseRefusesAnchorThatContainsItself checks that a YAML merge key (<<)
// naming the anchor of the map it sits in fails the parse with an error.
func TestParseRefusesAnchorThatContainsItself(t *testing.T) {
t.Parallel()
data := strings.Replace(keysTestConfig, " test:", " test: &test", 1) +
" <<: *test\n"
_, err := Parse([]byte(data))
if !errors.Is(err, errAnchorContainsItself) {
t.Fatalf("Parse() error = %v, want errAnchorContainsItself", err)
}
want := `anchor contains itself: "test" on line 7`
if err.Error() != want {
t.Errorf("Parse() error = %q, want %q", err.Error(), want)
}
}
// TestParseRefusesExcessiveAliasing checks that Parse returns an error for
// a file whose merge keys (<<) fan out. Each of eight snapshots merges the
// one before it ten times. A key check that followed every alias would walk
// the first snapshot over 10^8 times and run past the package's test
// timeout.
func TestParseRefusesExcessiveAliasing(t *testing.T) {
t.Parallel()
const snapshots, merges = 8, 10
var data strings.Builder
data.WriteString(strings.Replace(keysTestConfig, " test:", " test: &s0", 1))
for i := 1; i <= snapshots; i++ {
aliases := slices.Repeat([]string{"*s" + strconv.Itoa(i-1)}, merges)
fmt.Fprintf(&data, " s%d: &s%d\n <<: [%s]\n",
i, i, strings.Join(aliases, ", "))
}
_, err := Parse([]byte(data.String()))
if err == nil {
t.Fatal("Parse() returned no error")
}
}
// TestParseAcceptsKnownKeys checks that a config with a ${...} reference
// and a YAML merge key (<<) still parses.
func TestParseAcceptsKnownKeys(t *testing.T) {
t.Parallel()
levelPath := filepath.Join(t.TempDir(), "level")
err := os.WriteFile(levelPath, []byte("9"), 0o600)
if err != nil {
t.Fatalf("write level: %v", err)
}
data := strings.Replace(keysTestConfig, " test:", " test: &test", 1) +
" other:\n" +
" <<: *test\n" +
" exclude: [/tmp/vaultik-test-source/cache]\n" +
"compression_level: ${FILE:" + levelPath + "}\n"
cfg, err := Parse([]byte(data))
if err != nil {
t.Fatalf("Parse() unexpected error: %v", err)
}
if cfg.CompressionLevel != 9 {
t.Errorf("compression_level = %d, want 9", cfg.CompressionLevel)
}
other := cfg.Snapshots["other"]
if len(other.Paths) != 1 || len(other.Exclude) != 1 {
t.Errorf("snapshot other = %+v, want the paths of test and one exclude",
other)
}
}
// TestParseAcceptsEnvSection checks that a config with a top-level env
// section still parses. smartconfig sets the environment variables it
// lists, so its keys are not settings.
func TestParseAcceptsEnvSection(t *testing.T) {
const name = "VAULTIK_TEST_ENV_SECTION"
// Parse sets the variable; t.Setenv restores it after the test.
t.Setenv(name, "")
_, err := Parse([]byte(keysTestConfig + "env:\n " + name + ": set\n"))
if err != nil {
t.Fatalf("Parse() unexpected error: %v", err)
}
}
+77 -11
View File
@@ -4,13 +4,20 @@ import (
"errors" "errors"
"fmt" "fmt"
"math" "math"
"reflect"
"slices"
"strconv"
"strings"
"github.com/dustin/go-humanize" "github.com/dustin/go-humanize"
"gopkg.in/yaml.v3"
) )
var ( var (
errSizeType = errors.New("size must be a number or string") errSizeType = errors.New("size must be a number or string")
errSizeTooLarge = errors.New("size exceeds maximum supported value") errSizeTooLarge = errors.New("size exceeds maximum supported value")
errNotASize = errors.New(
"is not a size; use a number of bytes or a unit such as 10MB")
) )
// Size represents a byte size that can be specified in configuration files. // Size represents a byte size that can be specified in configuration files.
@@ -32,6 +39,15 @@ func (s *Size) UnmarshalYAML(unmarshal func(any) error) error {
return nil return nil
} }
// A number too large for an int64 decodes as a float64. Its text can be
// 1e+20, which is not a size string.
var floatVal float64
err = unmarshal(&floatVal)
if err == nil && floatVal > math.MaxInt64 {
return errSizeTooLarge
}
// Try to unmarshal as string // Try to unmarshal as string
var strVal string var strVal string
@@ -40,17 +56,66 @@ func (s *Size) UnmarshalYAML(unmarshal func(any) error) error {
return errSizeType return errSizeType
} }
// Parse the string using go-humanize size, err := ParseSize(strVal)
bytes, err := humanize.ParseBytes(strVal)
if err != nil { if err != nil {
return fmt.Errorf("invalid size format: %w", err) return err
} }
if bytes > math.MaxInt64 { *s = size
return fmt.Errorf("%w: %s", errSizeTooLarge, strVal)
return nil
} }
*s = Size(bytes) // checkSizes returns an error naming the first size setting in node whose
// value is not a size. node is the config, or the part of it at the dotted
// keys, after smartconfig has filled in its ${...} references.
func checkSizes(node *yaml.Node, keys []string) error {
typ, err := SettingType(keys)
if err != nil {
// Unknown keys are refused before this check, so keys is in one of
// two places. The env section holds no settings. A value of the
// wrong shape, such as a map given for hostname or inside exclude,
// is reported later by the YAML decoder.
return nil //nolint:nilerr // not an error here
}
if typ == reflect.TypeFor[Size]() {
var size Size
err = node.Decode(&size)
if err != nil {
return fmt.Errorf("%s: %w", strings.Join(keys, "."), err)
}
return nil
}
switch node.Kind {
case yaml.DocumentNode:
for _, child := range node.Content {
err = checkSizes(child, keys)
if err != nil {
return err
}
}
case yaml.MappingNode:
for i := 0; i+1 < len(node.Content); i += 2 {
err = checkSizes(node.Content[i+1],
slices.Concat(keys, []string{node.Content[i].Value}))
if err != nil {
return err
}
}
case yaml.SequenceNode:
for i, child := range node.Content {
err = checkSizes(child, slices.Concat(keys, []string{strconv.Itoa(i)}))
if err != nil {
return err
}
}
case yaml.ScalarNode, yaml.AliasNode:
// A scalar holds no settings, and the filled config has no aliases.
}
return nil return nil
} }
@@ -70,16 +135,17 @@ func (s Size) String() string {
return humanize.Bytes(uint64(s)) return humanize.Bytes(uint64(s))
} }
// ParseSize parses a size string into a Size value // ParseSize parses a size string, such as "10MB", into a Size value. An
// error quotes s.
func ParseSize(s string) (Size, error) { func ParseSize(s string) (Size, error) {
bytes, err := humanize.ParseBytes(s) bytes, err := humanize.ParseBigBytes(s)
if err != nil { if err != nil {
return 0, fmt.Errorf("invalid size format: %w", err) return 0, fmt.Errorf("%q %w", s, errNotASize)
} }
if bytes > math.MaxInt64 { if !bytes.IsInt64() {
return 0, fmt.Errorf("%w: %s", errSizeTooLarge, s) return 0, fmt.Errorf("%w: %s", errSizeTooLarge, s)
} }
return Size(bytes), nil return Size(bytes.Int64()), nil
} }
+120
View File
@@ -0,0 +1,120 @@
package config //nolint:testpackage // checks errNotASize and errSizeTooLarge
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
// TestParseRefusesValueThatIsNotASize checks that a size setting whose
// value is not a size fails the parse with an error naming the setting and
// quoting the value, also when a ${...} reference fills the value in.
func TestParseRefusesValueThatIsNotASize(t *testing.T) {
t.Parallel()
sizePath := filepath.Join(t.TempDir(), "size")
err := os.WriteFile(sizePath, []byte("lots"), 0o600)
if err != nil {
t.Fatalf("write size: %v", err)
}
tests := []struct {
name string
yaml string
want string
}{
{"chunk_size", keysTestConfig + "chunk_size: banana\n",
`chunk_size: "banana" is not a size; ` +
"use a number of bytes or a unit such as 10MB"},
{"blob_size_limit", keysTestConfig + "blob_size_limit: 10XB\n",
`blob_size_limit: "10XB" is not a size; ` +
"use a number of bytes or a unit such as 10MB"},
{"s3.part_size", strings.Replace(keysTestConfig, " region:",
" part_size: big\n region:", 1),
`s3.part_size: "big" is not a size; ` +
"use a number of bytes or a unit such as 10MB"},
{"from a reference",
keysTestConfig + "chunk_size: ${FILE:" + sizePath + "}\n",
`chunk_size: "lots" is not a size; ` +
"use a number of bytes or a unit such as 10MB"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := Parse([]byte(tt.yaml))
if !errors.Is(err, errNotASize) {
t.Fatalf("Parse() error = %v, want errNotASize", err)
}
if err.Error() != tt.want {
t.Errorf("Parse() error = %q, want %q", err.Error(), tt.want)
}
})
}
}
// TestParseRefusesSizeTooLarge checks that a size setting whose value is
// too large for a size fails the parse with an error naming the setting.
// YAML reads the second value as a float.
func TestParseRefusesSizeTooLarge(t *testing.T) {
t.Parallel()
tests := []struct {
value string
want string
}{
{"100EB", "chunk_size: size exceeds maximum supported value: 100EB"},
{"99999999999999999999",
"chunk_size: size exceeds maximum supported value"},
}
for _, tt := range tests {
t.Run(tt.value, func(t *testing.T) {
t.Parallel()
_, err := Parse([]byte(keysTestConfig + "chunk_size: " + tt.value + "\n"))
if !errors.Is(err, errSizeTooLarge) {
t.Fatalf("Parse() error = %v, want errSizeTooLarge", err)
}
if err.Error() != tt.want {
t.Errorf("Parse() error = %q, want %q", err.Error(), tt.want)
}
})
}
}
// TestParseAcceptsSizes checks that size settings given as a number of
// bytes, as a unit, and through a ${...} reference still parse.
func TestParseAcceptsSizes(t *testing.T) {
t.Parallel()
sizePath := filepath.Join(t.TempDir(), "size")
err := os.WriteFile(sizePath, []byte("20MB"), 0o600)
if err != nil {
t.Fatalf("write size: %v", err)
}
data := strings.Replace(keysTestConfig, " region:",
" part_size: 6000000\n region:", 1) +
"chunk_size: ${FILE:" + sizePath + "}\n" +
"blob_size_limit: 1GB\n"
cfg, err := Parse([]byte(data))
if err != nil {
t.Fatalf("Parse() unexpected error: %v", err)
}
if cfg.ChunkSize != 20_000_000 || cfg.BlobSizeLimit != 1_000_000_000 ||
cfg.S3.PartSize != 6_000_000 {
t.Errorf("chunk_size, blob_size_limit, s3.part_size = %d, %d, %d, "+
"want 20000000, 1000000000, 6000000",
cfg.ChunkSize, cfg.BlobSizeLimit, cfg.S3.PartSize)
}
}
+39
View File
@@ -267,6 +267,45 @@ func (r *FileRepository) ListUnderPath(
return files, rows.Err() return files, rows.Err()
} }
// ListUnderPathInSnapshot returns the files that ListUnderPath returns
// for path and that the snapshot lists, in no particular order.
func (r *FileRepository) ListUnderPathInSnapshot(
ctx context.Context, path, snapshotID string,
) ([]*File, error) {
path = strings.TrimRight(path, "/")
dirPrefix := path + "/"
query := `
SELECT f.id, f.path, f.source_path, f.mtime, f.mtime_nsec, f.size,
f.mode, f.uid, f.gid, f.link_target
FROM files f
JOIN snapshot_files sf ON sf.file_id = f.id
WHERE sf.snapshot_id = ?
AND (f.path = ? OR substr(f.path, 1, length(?)) = ?)
`
rows, err := r.db.conn.QueryContext(ctx, query,
snapshotID, path, dirPrefix, dirPrefix)
if err != nil {
return nil, fmt.Errorf("querying files: %w", err)
}
defer func() { _ = rows.Close() }()
var files []*File
for rows.Next() {
file, err := r.scanFileRows(rows)
if err != nil {
return nil, fmt.Errorf("scanning file: %w", err)
}
files = append(files, file)
}
return files, rows.Err()
}
// ListIDsWithChunksNotInUploadedBlobs returns the IDs of the files whose // ListIDsWithChunksNotInUploadedBlobs returns the IDs of the files whose
// path starts with prefix and that list at least one chunk held by no // path starts with prefix and that list at least one chunk held by no
// blob whose upload has completed (uploaded_ts set). A new snapshot // blob whose upload has completed (uploaded_ts set). A new snapshot
+59
View File
@@ -212,6 +212,65 @@ func TestFileRepositoryListUnderPath(t *testing.T) {
} }
} }
// ListUnderPathInSnapshot leaves out files outside path and files the
// snapshot does not list, though another snapshot lists them.
func TestFileRepositoryListUnderPathInSnapshot(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
files := database.NewFileRepository(db)
snapshots := database.NewSnapshotRepository(db)
listed := map[string][]string{
"one": {"/src/a.txt", "/src2/c.txt"},
"two": {"/src/b.txt"},
}
for snapshotID, paths := range listed {
err := snapshots.Create(ctx, nil, &database.Snapshot{
ID: types.SnapshotID(snapshotID),
Hostname: "host",
StartedAt: time.Now(),
})
if err != nil {
t.Fatalf("failed to create snapshot %s: %v", snapshotID, err)
}
for _, path := range paths {
err := files.Create(ctx, nil, &database.File{
Path: types.FilePath(path),
MTime: time.Now().Truncate(time.Second),
Mode: 0644,
})
if err != nil {
t.Fatalf("failed to create %s: %v", path, err)
}
err = snapshots.AddFile(ctx, nil, snapshotID, path)
if err != nil {
t.Fatalf("failed to add %s to %s: %v", path, snapshotID, err)
}
}
}
found, err := files.ListUnderPathInSnapshot(ctx, "/src", "one")
if err != nil {
t.Fatalf("failed to list files: %v", err)
}
got := make([]string, 0, len(found))
for _, f := range found {
got = append(got, f.Path.String())
}
if !slices.Equal(got, []string{"/src/a.txt"}) {
t.Errorf("got %q, want only /src/a.txt", got)
}
}
func TestFileRepositorySymlink(t *testing.T) { func TestFileRepositorySymlink(t *testing.T) {
t.Parallel() t.Parallel()
+11
View File
@@ -20,6 +20,17 @@ const indexDirPerm = 0o700
// backed-up path and chunk hash and must stay private. // backed-up path and chunk hash and must stay private.
const indexFilePerm = 0o600 const indexFilePerm = 0o600
// IndexFiles returns the path of the index at indexPath and the paths of
// the side files SQLite keeps beside it while it writes.
func IndexFiles(indexPath string) []string {
return []string{
indexPath,
indexPath + "-wal",
indexPath + "-shm",
indexPath + "-journal",
}
}
// ensureIndexFileMode makes the index file owner-only before the SQLite // ensureIndexFileMode makes the index file owner-only before the SQLite
// driver opens it: it creates the file 0600 if absent, or chmods an existing // driver opens it: it creates the file 0600 if absent, or chmods an existing
// one to 0600. Doing this first matters because SQLite creates its -wal and // one to 0600. Doing this first matters because SQLite creates its -wal and
+10
View File
@@ -123,6 +123,16 @@ CREATE TABLE IF NOT EXISTS snapshot_blobs (
-- Index for efficient blob lookups (used in orphan detection) -- Index for efficient blob lookups (used in orphan detection)
CREATE INDEX IF NOT EXISTS idx_snapshot_blobs_blob_id ON snapshot_blobs(blob_id); CREATE INDEX IF NOT EXISTS idx_snapshot_blobs_blob_id ON snapshot_blobs(blob_id);
-- Snapshot mount points table: the directories at or below a configured
-- path that the snapshot's backup found on a different filesystem from
-- their parent directory, as a mount point is, and found an entry in
CREATE TABLE IF NOT EXISTS snapshot_mount_points (
snapshot_id TEXT NOT NULL,
path TEXT NOT NULL,
PRIMARY KEY (snapshot_id, path),
FOREIGN KEY (snapshot_id) REFERENCES snapshots(id) ON DELETE CASCADE
);
-- Uploads table: tracks blob upload metrics -- Uploads table: tracks blob upload metrics
CREATE TABLE IF NOT EXISTS uploads ( CREATE TABLE IF NOT EXISTS uploads (
blob_hash TEXT PRIMARY KEY, blob_hash TEXT PRIMARY KEY,
+69 -10
View File
@@ -24,7 +24,8 @@ var (
) )
// SnapshotRepository provides access to the snapshots table and its // SnapshotRepository provides access to the snapshots table and its
// snapshot_files / snapshot_blobs association tables. // snapshot_files / snapshot_blobs association tables, and to the mount
// points each snapshot's backup found (snapshot_mount_points).
type SnapshotRepository struct { type SnapshotRepository struct {
db *DB db *DB
} }
@@ -500,27 +501,57 @@ func (r *SnapshotRepository) GetBlobHashes(
ORDER BY sb.blob_hash ORDER BY sb.blob_hash
` `
rows, err := r.db.conn.QueryContext(ctx, query, snapshotID) blobs, err := r.queryStrings(ctx, query, snapshotID)
if err != nil { if err != nil {
return nil, fmt.Errorf("querying blob hashes: %w", err) return nil, fmt.Errorf("querying blob hashes: %w", err)
} }
defer func() { _ = rows.Close() }() return blobs, nil
}
var blobs []string // AddMountPoint records dir as a directory that the backup of a snapshot
// found on a different filesystem from its parent directory, as a mount
// point is, using tx when non-nil.
func (r *SnapshotRepository) AddMountPoint(
ctx context.Context, tx *sql.Tx, snapshotID string, dir string,
) error {
query := `
INSERT OR IGNORE INTO snapshot_mount_points (snapshot_id, path)
VALUES (?, ?)
`
for rows.Next() { var err error
var blobHash string if tx != nil {
_, err = tx.ExecContext(ctx, query, snapshotID, dir)
} else {
_, err = r.db.ExecWithLog(ctx, query, snapshotID, dir)
}
err := rows.Scan(&blobHash)
if err != nil { if err != nil {
return nil, fmt.Errorf("scanning blob hash: %w", err) return fmt.Errorf("adding mount point to snapshot: %w", err)
} }
blobs = append(blobs, blobHash) return nil
} }
return blobs, rows.Err() // GetMountPoints returns the directories AddMountPoint recorded for a
// snapshot, sorted.
func (r *SnapshotRepository) GetMountPoints(
ctx context.Context, snapshotID string,
) ([]string, error) {
query := `
SELECT path
FROM snapshot_mount_points
WHERE snapshot_id = ?
ORDER BY path
`
dirs, err := r.queryStrings(ctx, query, snapshotID)
if err != nil {
return nil, fmt.Errorf("querying mount points: %w", err)
}
return dirs, nil
} }
// GetSnapshotTotalCompressedSize returns the total compressed size of all // GetSnapshotTotalCompressedSize returns the total compressed size of all
@@ -709,6 +740,34 @@ func (r *SnapshotRepository) DeleteSnapshotUploads(
return nil return nil
} }
// queryStrings runs query, which selects one text column, and returns
// its values in the order of the rows.
func (r *SnapshotRepository) queryStrings(
ctx context.Context, query string, args ...any,
) ([]string, error) {
rows, err := r.db.conn.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var values []string
for rows.Next() {
var value string
err := rows.Scan(&value)
if err != nil {
return nil, err
}
values = append(values, value)
}
return values, rows.Err()
}
// extendedCompressionRatio computes the compression ratio for a snapshot // extendedCompressionRatio computes the compression ratio for a snapshot
// from its stored blob_size and the given uncompressed size. Returns 1.0 // from its stored blob_size and the given uncompressed size. Returns 1.0
// when the uncompressed size is zero. // when the uncompressed size is zero.
+50
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"math" "math"
"slices"
"testing" "testing"
"time" "time"
@@ -422,3 +423,52 @@ func TestSnapshotRepositoryDuplicate(t *testing.T) {
t.Error("expected error for duplicate snapshot") t.Error("expected error for duplicate snapshot")
} }
} }
// Deleting a snapshot from the local index deletes the mount points its
// backup recorded, and no other snapshot's. See
// https://git.eeqj.de/sneak/vaultik/issues/395.
func TestSnapshotRepositoryDeleteRemovesMountPoints(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
repo := database.NewSnapshotRepository(db)
for _, id := range []types.SnapshotID{"kept", "deleted"} {
err := repo.Create(ctx, nil, &database.Snapshot{
ID: id,
Hostname: testHostname,
VaultikVersion: testVersion,
StartedAt: time.Now().Truncate(time.Second),
})
if err != nil {
t.Fatalf("failed to create snapshot: %v", err)
}
err = repo.AddMountPoint(ctx, nil, id.String(), "/data/nas")
if err != nil {
t.Fatalf("failed to add mount point: %v", err)
}
}
err := repo.Delete(ctx, "deleted")
if err != nil {
t.Fatalf("failed to delete snapshot: %v", err)
}
for id, want := range map[string][]string{
"kept": {"/data/nas"},
"deleted": nil,
} {
got, err := repo.GetMountPoints(ctx, id)
if err != nil {
t.Fatalf("failed to read mount points: %v", err)
}
if !slices.Equal(got, want) {
t.Errorf("mount points of %s: got %v, want %v", id, got, want)
}
}
}
+62 -6
View File
@@ -1,19 +1,24 @@
// Package globals holds application-wide metadata (name, version, // Package globals holds application-wide metadata (name, version,
// commit) that is populated at build time via linker flags. // commit) that is populated at build time via linker flags, or from the
// build info Go records in the binary when the linker flags set none.
package globals package globals
import ( import (
"regexp" "regexp"
"runtime/debug"
"strings" "strings"
"time" "time"
"golang.org/x/mod/module"
) )
// Appname is the application name, populated from main(). // Appname is the application name, populated from main().
var Appname = "vaultik" //nolint:gochecknoglobals // set via -ldflags at build time var Appname = "vaultik" //nolint:gochecknoglobals // set via -ldflags at build time
// DevVersion is the version a binary reports when it was built without // DevVersion is the version a binary reports when it was built without
// git metadata: script/version emits it outside a git checkout, and an // git metadata: script/version emits it outside a git checkout, and a
// unstamped `go build` keeps it. goreleaser's snapshot template stamps // binary keeps it when neither its linker flags nor its Go build info
// name a version. goreleaser's snapshot template stamps
// it followed by "-" and the commit it was built from. It is // it followed by "-" and the commit it was built from. It is
// deliberately not a number. // deliberately not a number.
const DevVersion = "dev" const DevVersion = "dev"
@@ -32,6 +37,54 @@ var Commit = Unknown //nolint:gochecknoglobals // set via -ldflags at build time
// CommitDate is the ISO-8601 date of the commit, populated from main(). // CommitDate is the ISO-8601 date of the commit, populated from main().
var CommitDate = Unknown //nolint:gochecknoglobals // set via -ldflags at build time var CommitDate = Unknown //nolint:gochecknoglobals // set via -ldflags at build time
// develVersion is the main module version Go records when it cannot
// tell which version it built.
const develVersion = "(devel)"
// FillUnsetFromBuildInfo applies FillUnset to Version, Commit and
// CommitDate, with the build info of the running binary.
func FillUnsetFromBuildInfo() {
info, ok := debug.ReadBuildInfo()
if !ok {
return
}
Version, Commit, CommitDate = FillUnset(
info, Version, Commit, CommitDate)
}
// FillUnset returns version, commit and commitDate. Each one that
// still holds its default ("dev" or empty for the version, "unknown"
// for the others) is replaced by the value Go recorded in info. A
// `go install` of a tag sets no linker flags, but Go records the tag
// as the main module's version. Go records the commit and its time
// only when it builds from a git checkout. The time is cut to a date,
// as the Makefile stamps it.
func FillUnset(
info *debug.BuildInfo, version, commit, commitDate string,
) (string, string, string) {
if (version == "" || version == DevVersion) &&
info.Main.Version != "" && info.Main.Version != develVersion {
version = info.Main.Version
}
for _, s := range info.Settings {
switch s.Key {
case "vcs.revision":
if commit == Unknown {
commit = s.Value
}
case "vcs.time":
t, err := time.Parse(time.RFC3339, s.Value)
if commitDate == Unknown && err == nil {
commitDate = t.UTC().Format(time.DateOnly)
}
}
}
return version, commit, commitDate
}
// Author identifies the upstream author of vaultik. // Author identifies the upstream author of vaultik.
const Author = "Jeffrey Paul <sneak@sneak.berlin>" const Author = "Jeffrey Paul <sneak@sneak.berlin>"
@@ -68,8 +121,10 @@ func New() (*Globals, error) {
// a release. "dev" and goreleaser's snapshot "dev-<sha>" count, and so // a release. "dev" and goreleaser's snapshot "dev-<sha>" count, and so
// does what `git describe --tags --always --dirty` gives a make or // does what `git describe --tags --always --dirty` gives a make or
// docker build of an untagged commit: the bare short commit, or // docker build of an untagged commit: the bare short commit, or
// tag-N-gHASH on a commit after a tag. Any version ending in "-dirty" // tag-N-gHASH on a commit after a tag. A Go pseudo-version counts: Go
// counts, a modified checkout of a tag ("v1.0.0-dirty") included. // records one for a build of an untagged commit without linker flags.
// Any version ending in "-dirty" or "+dirty" counts, a modified
// checkout of a tag ("v1.0.0-dirty", "v1.0.0+dirty") included.
// A plain tag such as "v1.0.0" or "1.0.0" is a release. // A plain tag such as "v1.0.0" or "1.0.0" is a release.
// //
// The empty string counts too. Nothing that knows its version reports // The empty string counts too. Nothing that knows its version reports
@@ -82,7 +137,8 @@ func New() (*Globals, error) {
func IsDevVersion(v string) bool { func IsDevVersion(v string) bool {
if v == "" || v == Unknown || v == DevVersion || if v == "" || v == Unknown || v == DevVersion ||
strings.HasPrefix(v, DevVersion+"-") || strings.HasPrefix(v, DevVersion+"-") ||
strings.HasSuffix(v, "-dirty") { strings.HasSuffix(v, "-dirty") || strings.HasSuffix(v, "+dirty") ||
module.IsPseudoVersion(v) {
return true return true
} }
+113 -4
View File
@@ -1,6 +1,7 @@
package globals_test package globals_test
import ( import (
"runtime/debug"
"testing" "testing"
"sneak.berlin/go/vaultik/internal/globals" "sneak.berlin/go/vaultik/internal/globals"
@@ -59,12 +60,20 @@ func TestIsDevVersion(t *testing.T) {
{"1.0.0-rc.1-12-g877eb2f", true}, {"1.0.0-rc.1-12-g877eb2f", true},
// A tagged commit with uncommitted changes is not that tag. // A tagged commit with uncommitted changes is not that tag.
{"v1.0.0-dirty", true}, {"v1.0.0-dirty", true},
// What Go records for a build without linker flags: a
// pseudo-version for an untagged commit, "+dirty" for a
// modified checkout.
{"v1.0.0-rc3.0.20261111001226-abcdefabcdef", true},
{"v0.0.0-20261111001226-abcdefabcdef", true},
{"v1.0.1-0.20261111001226-abcdefabcdef", true},
{"v1.0.0+dirty", true},
// What a tagged build produces (goreleaser's .Version strips // What a tagged build produces (goreleaser's .Version strips
// the leading "v"; script/version keeps it). // the leading "v"; script/version keeps it).
{"1.0.0", false}, {"1.0.0", false},
{"0.1.0", false}, {"0.1.0", false},
{"1.0.0-rc.1", false}, {"1.0.0-rc.1", false},
{"v1.0.0", false}, {"v1.0.0", false},
{"v1.0.0-rc3", false},
// A release must not be mistaken for a dev build just because // A release must not be mistaken for a dev build just because
// the string happens to contain "dev". // the string happens to contain "dev".
{"1.0.0-dev", false}, {"1.0.0-dev", false},
@@ -86,10 +95,9 @@ func TestIsDevVersion(t *testing.T) {
} }
} }
// TestDefaultVersionIsDev pins the linker-flag contract: an unstamped // TestDefaultVersionIsDev pins the default: a binary whose linker flags
// binary (no -ldflags at all, which is what `go build ./...` and `go // and build info name no version must report itself as a development
// install` produce) must report itself as a development build rather // build rather than as some default release number.
// than as some default release number.
func TestDefaultVersionIsDev(t *testing.T) { func TestDefaultVersionIsDev(t *testing.T) {
t.Parallel() t.Parallel()
@@ -98,3 +106,104 @@ func TestDefaultVersionIsDev(t *testing.T) {
globals.DevVersion) globals.DevVersion)
} }
} }
// buildInfo returns build info with the given main module version and,
// when revision is not empty, the commit settings Go records for a
// build from a git checkout.
func buildInfo(version, revision string) *debug.BuildInfo {
info := &debug.BuildInfo{
Main: debug.Module{Path: "sneak.berlin/go/vaultik", Version: version},
}
if revision != "" {
info.Settings = []debug.BuildSetting{
{Key: "vcs.revision", Value: revision},
{Key: "vcs.time", Value: "2026-11-10T23:12:26Z"},
}
}
return info
}
// TestFillUnset covers a binary from `go install` of a tag, which
// carries no linker flags: it must report the tag. Values the linker
// flags set are kept.
func TestFillUnset(t *testing.T) {
t.Parallel()
const (
tag = "v1.0.0-rc3"
rev = "0123456789abcdef0123456789abcdef01234567"
)
cases := []struct {
name string
info *debug.BuildInfo
version, commit, date string
wantVersion, wantCommit string
wantDate string
}{
{
name: "go install of a tag",
info: buildInfo(tag, ""),
version: globals.DevVersion,
commit: globals.Unknown,
date: globals.Unknown,
wantVersion: tag,
wantCommit: globals.Unknown,
wantDate: globals.Unknown,
},
{
name: "empty version",
info: buildInfo(tag, ""),
version: "",
commit: globals.Unknown,
date: globals.Unknown,
wantVersion: tag,
wantCommit: globals.Unknown,
wantDate: globals.Unknown,
},
{
name: "go build in a git checkout",
info: buildInfo(tag, rev),
version: globals.DevVersion,
commit: globals.Unknown,
date: globals.Unknown,
wantVersion: tag,
wantCommit: rev,
wantDate: "2026-11-10",
},
{
name: "Go could not tell the version",
info: buildInfo("(devel)", ""),
version: globals.DevVersion,
commit: globals.Unknown,
date: globals.Unknown,
wantVersion: globals.DevVersion,
wantCommit: globals.Unknown,
wantDate: globals.Unknown,
},
{
name: "linker flags set",
info: buildInfo(tag, rev),
version: "v1.0.0-rc2-4-gabcdef1",
commit: "abcdef1",
date: "2026-11-01",
wantVersion: "v1.0.0-rc2-4-gabcdef1",
wantCommit: "abcdef1",
wantDate: "2026-11-01",
},
}
for _, tc := range cases {
version, commit, date := globals.FillUnset(
tc.info, tc.version, tc.commit, tc.date)
if version != tc.wantVersion || commit != tc.wantCommit ||
date != tc.wantDate {
t.Errorf("%s: got %q, %q, %q; want %q, %q, %q", tc.name,
version, commit, date,
tc.wantVersion, tc.wantCommit, tc.wantDate)
}
}
}
+14 -1
View File
@@ -11,6 +11,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/adrg/xdg"
"golang.org/x/sys/unix" "golang.org/x/sys/unix"
) )
@@ -29,8 +30,20 @@ const (
lockDirPerm = 0o700 lockDirPerm = 0o700
// pidFilePerm is the mode for the PID file (owner-only). // pidFilePerm is the mode for the PID file (owner-only).
pidFilePerm = 0o600 pidFilePerm = 0o600
// pidFileName is the name of the PID file in the lock directory.
pidFileName = "vaultik.pid"
) )
// Dir returns the directory that holds vaultik's PID file.
func Dir() string {
return filepath.Join(xdg.DataHome, "vaultik")
}
// Path returns the path of the PID file that Acquire uses in lockDir.
func Path(lockDir string) string {
return filepath.Join(lockDir, pidFileName)
}
// Acquire attempts to acquire a PID lock in the specified directory. // Acquire attempts to acquire a PID lock in the specified directory.
// If another process holds the lock, it returns ErrAlreadyRunning with // If another process holds the lock, it returns ErrAlreadyRunning with
// that process's PID. On success, it writes the current PID to the lock // that process's PID. On success, it writes the current PID to the lock
@@ -42,7 +55,7 @@ func Acquire(lockDir string) (*Lock, error) {
return nil, fmt.Errorf("creating lock directory: %w", err) return nil, fmt.Errorf("creating lock directory: %w", err)
} }
lockPath := filepath.Join(lockDir, "vaultik.pid") lockPath := Path(lockDir)
// No O_TRUNC: the file may hold the PID of the process that has the // No O_TRUNC: the file may hold the PID of the process that has the
// lock, which the error below reports. // lock, which the error below reports.
+5
View File
@@ -8,6 +8,7 @@ import (
"io" "io"
"strings" "strings"
"sync/atomic" "sync/atomic"
"time"
"github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/config"
@@ -302,6 +303,9 @@ func IsNotFound(err error) bool {
type ObjectInfo struct { type ObjectInfo struct {
Key string Key string
Size int64 Size int64
// ModTime is the object's LastModified time. Only ListObjectsStream
// sets it.
ModTime time.Time
Err error Err error
} }
@@ -345,6 +349,7 @@ func (c *Client) ListObjectsStream(
ch <- ObjectInfo{ ch <- ObjectInfo{
Key: key, Key: key,
Size: *obj.Size, Size: *obj.Size,
ModTime: aws.ToTime(obj.LastModified),
} }
} }
} }
+40
View File
@@ -0,0 +1,40 @@
//nolint:testpackage // exercises the unexported deviceNumberFromStat
package snapshot
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// The device number a real backup reads is the same for a directory and
// a file in it, and differs between the temp directory and /dev.
// Without it no mount point is ever found. See
// https://git.eeqj.de/sneak/vaultik/issues/395.
func TestDeviceNumberFromStat(t *testing.T) {
t.Parallel()
dir := t.TempDir()
file := filepath.Join(dir, "file.txt")
require.NoError(t, os.WriteFile(file, nil, 0o600))
deviceOf := func(path string) uint64 {
t.Helper()
info, err := os.Stat(path)
require.NoError(t, err)
device, ok := deviceNumberFromStat(info)
require.True(t, ok, "no device number read for %s", path)
return device
}
assert.Equal(t, deviceOf(dir), deviceOf(file))
// /dev is a different filesystem from the temp dir on Linux, macOS and in Docker.
assert.NotEqual(t, deviceOf(dir), deviceOf("/dev"))
}
@@ -0,0 +1,65 @@
package snapshot_test
import (
"context"
"testing"
"github.com/spf13/afero"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/snapshot"
)
// A backup of the directory that holds vaultik's index and PID file
// stores neither, with no exclude pattern set, and still stores the
// files beside them. See https://git.eeqj.de/sneak/vaultik/issues/421.
func TestScanSkipsIndexAndLockFiles(t *testing.T) {
t.Parallel()
const dataDir = "/home/user/.local/share/vaultik"
indexAndLockFiles := append(database.IndexFiles(dataDir+"/index.sqlite"),
dataDir+"/vaultik.pid")
fs := afero.NewMemMapFs()
for _, p := range append(indexAndLockFiles, dataDir+"/notes.txt") {
require.NoError(t, afero.WriteFile(fs, p, []byte("data"), 0o600))
}
db, err := database.NewTestDB()
require.NoError(t, err)
defer func() { _ = db.Close() }()
repos := database.NewRepositories(db)
scanner := snapshot.NewScanner(snapshot.ScannerConfig{
FS: fs,
ChunkSize: 64 * 1024,
Repositories: repos,
MaxBlobSize: 1024 * 1024,
CompressionLevel: 3,
AgeRecipients: []string{
"age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p"},
IndexAndLockFiles: indexAndLockFiles,
})
require.NotNil(t, scanner)
ctx := context.Background()
createSnapshotRecord(ctx, t, repos, "test-snapshot")
result, err := scanner.Scan(ctx, "/home/user", "test-snapshot")
require.NoError(t, err)
require.Equal(t, 1, result.FilesScanned)
stored, err := repos.Files.GetByPath(ctx, dataDir+"/notes.txt")
require.NoError(t, err)
require.NotNil(t, stored, "a file beside the index is stored")
for _, p := range indexAndLockFiles {
f, err := repos.Files.GetByPath(ctx, p)
require.NoError(t, err)
require.Nil(t, f, "%s is not stored", p)
}
}
+55
View File
@@ -1,10 +1,14 @@
package snapshot package snapshot
import ( import (
"os"
"path/filepath"
"github.com/spf13/afero" "github.com/spf13/afero"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/vaultik/internal/config" "sneak.berlin/go/vaultik/internal/config"
"sneak.berlin/go/vaultik/internal/database" "sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/pidlock"
"sneak.berlin/go/vaultik/internal/storage" "sneak.berlin/go/vaultik/internal/storage"
"sneak.berlin/go/vaultik/internal/ui" "sneak.berlin/go/vaultik/internal/ui"
) )
@@ -16,6 +20,12 @@ type ScannerParams struct {
Fs afero.Fs Fs afero.Fs
Exclude []string // Exclude patterns (combined global + snapshot-specific) Exclude []string // Exclude patterns (combined global + snapshot-specific)
SkipErrors bool // Skip file read errors (log loudly but continue) SkipErrors bool // Skip file read errors (log loudly but continue)
// PreviousSnapshotID is the most recent completed snapshot of the
// same name, "" for none.
PreviousSnapshotID string
// DeviceNumber is ScannerConfig.DeviceNumber; nil reads it from the
// file's stat.
DeviceNumber func(info os.FileInfo) (uint64, bool)
} }
// Module exports backup functionality as an fx module. // Module exports backup functionality as an fx module.
@@ -54,7 +64,52 @@ func provideScannerFactory(
EnableProgress: params.EnableProgress, EnableProgress: params.EnableProgress,
UI: params.UI, UI: params.UI,
Exclude: excludes, Exclude: excludes,
IndexAndLockFiles: indexAndLockFiles(cfg.IndexPath, pidlock.Dir()),
SkipErrors: params.SkipErrors, SkipErrors: params.SkipErrors,
PreviousSnapshotID: params.PreviousSnapshotID,
DeviceNumber: params.DeviceNumber,
}) })
} }
} }
// indexAndLockFiles returns the paths of the index at indexPath, its side
// files and the PID file in lockDir. Symlinks in their directories are
// resolved, as they are in the snapshot's source paths, so the scan finds
// them under the same names. If indexPath is a symlink, the file it points
// to and that file's side files are returned too.
func indexAndLockFiles(indexPath, lockDir string) []string {
paths := append(database.IndexFiles(indexPath), pidlock.Path(lockDir))
for i, p := range paths {
paths[i] = resolveParentDir(p)
}
// SQLite writes the file a symlinked index points to and keeps the
// side files beside that file. The database package creates the index
// before the scanner is built, so the link resolves.
index := resolveParentDir(indexPath)
target, err := filepath.EvalSymlinks(index)
if err == nil && target != index {
paths = append(paths, database.IndexFiles(target)...)
}
return paths
}
// resolveParentDir returns the absolute form of path with the symlinks in
// its parent directory resolved. If the parent directory cannot be
// resolved, it returns the absolute form unresolved. If path has no
// absolute form, it returns path.
func resolveParentDir(path string) string {
abs, err := filepath.Abs(path)
if err != nil {
return path
}
dir, err := filepath.EvalSymlinks(filepath.Dir(abs))
if err != nil {
return abs
}
return filepath.Join(dir, filepath.Base(abs))
}
+32
View File
@@ -0,0 +1,32 @@
//nolint:testpackage // exercises the unexported indexAndLockFiles helper
package snapshot
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
// The scan walks source paths with symlinks resolved, so vaultik's own
// files are named the same way, or a symlinked home would back them up.
func TestIndexAndLockFilesResolvesSymlinkedDirectory(t *testing.T) {
t.Parallel()
realDir, err := filepath.EvalSymlinks(t.TempDir())
require.NoError(t, err)
link := filepath.Join(t.TempDir(), "link")
require.NoError(t, os.Symlink(realDir, link))
paths := indexAndLockFiles(filepath.Join(link, "index.sqlite"), link)
require.Equal(t, []string{
filepath.Join(realDir, "index.sqlite"),
filepath.Join(realDir, "index.sqlite-wal"),
filepath.Join(realDir, "index.sqlite-shm"),
filepath.Join(realDir, "index.sqlite-journal"),
filepath.Join(realDir, "vaultik.pid"),
}, paths)
}
+245
View File
@@ -0,0 +1,245 @@
package snapshot_test
import (
"context"
"os"
"path/filepath"
"slices"
"strings"
"syscall"
"testing"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/snapshot"
)
// A scan records with the snapshot each directory on a different device
// from its parent directory that the walk found an entry in, as a mount
// point is, the scanned path included. A filesystem that gives no device
// numbers, as the in-memory one, records none. See
// https://git.eeqj.de/sneak/vaultik/issues/395.
func TestScanRecordsMountPoints(t *testing.T) {
t.Parallel()
const (
scannedPath = "/data/source"
snapshotID = "mount-points"
)
tests := []struct {
name string
// onVolume names the directories on the volume's device; every
// other directory is on another one. nil leaves the device
// numbers to the in-memory filesystem, which has none.
onVolume []string
want []string
}{
{
name: "volume below the scanned path",
onVolume: []string{"nas"},
want: []string{scannedPath + "/nas"},
},
{
name: "volume at the scanned path",
onVolume: []string{"source", "nas"},
want: []string{scannedPath},
},
{name: "no device numbers", want: nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx := context.Background()
fs := afero.NewMemMapFs()
for _, path := range []string{
scannedPath + "/notes.txt", scannedPath + "/nas/a.txt",
} {
require.NoError(t, fs.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(t, afero.WriteFile(fs, path, []byte(path), 0o644))
}
db, err := database.NewTestDB()
require.NoError(t, err)
t.Cleanup(func() { _ = db.Close() })
repos := database.NewRepositories(db)
createTestSnapshotRecord(ctx, t, repos, snapshotID)
cfg := snapshot.ScannerConfig{
FS: fs,
ChunkSize: int64(1024 * 16),
Repositories: repos,
MaxBlobSize: int64(1024 * 1024),
CompressionLevel: 3,
AgeRecipients: []string{testAgePublicKey},
}
if tt.onVolume != nil {
cfg.DeviceNumber = func(info os.FileInfo) (uint64, bool) {
if slices.Contains(tt.onVolume, info.Name()) {
return 2, true
}
return 1, true
}
}
_, err = snapshot.NewScanner(cfg).Scan(ctx, scannedPath, snapshotID)
require.NoError(t, err)
got, err := repos.Snapshots.GetMountPoints(ctx, snapshotID)
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
// automountFs is an in-memory filesystem with a volume at mountPoint
// that is mounted on first access: opening mountPoint to list it mounts
// the volume or, if it cannot be mounted, fails with "no such file or
// directory", as with the autofs daemon. Stat records the device an
// entry is on at that moment, as a real stat does: unmountedDevice for
// mountPoint until the volume is mounted; the volume's, 2, for
// mountPoint once it is and for everything in it; 1 for everything else.
type automountFs struct {
afero.Fs
mountPoint string
unmountedDevice uint64
canMount bool
mounted bool
}
//nolint:ireturn // afero.Fs.Open is defined to return the interface.
func (fs *automountFs) Open(name string) (afero.File, error) {
if name == fs.mountPoint && !fs.canMount {
return nil, &os.PathError{Op: "open", Path: name, Err: syscall.ENOENT}
}
if name == fs.mountPoint {
fs.mounted = true
}
return fs.Fs.Open(name)
}
func (fs *automountFs) Stat(name string) (os.FileInfo, error) {
info, err := fs.Fs.Stat(name)
if err != nil {
return nil, err
}
device := uint64(1)
switch {
case name == fs.mountPoint && !fs.mounted:
device = fs.unmountedDevice
case name == fs.mountPoint || strings.HasPrefix(name, fs.mountPoint+"/"):
device = 2
}
return statWithDevice{FileInfo: info, device: device}, nil
}
// statWithDevice is an entry automountFs.Stat looked up, with the device
// it was on then.
type statWithDevice struct {
os.FileInfo
device uint64
}
// deviceAtLookup returns the device automountFs.Stat recorded for info.
func deviceAtLookup(info os.FileInfo) (uint64, bool) {
stat, ok := info.(statWithDevice)
return stat.device, ok
}
// A volume mounted on first access is mounted when the walk lists its
// mount point. Until then the mount point is on the automounter's own
// device (a systemd automount) or on its parent's (an autofs map that
// shows its mount points before mounting them). Either way only the
// mount point is recorded, by the backup that mounts the volume and by a
// later one that finds it mounted. Listing an automount point whose
// volume cannot be mounted fails; it is not recorded, because the walk
// finds no entry in it. See https://git.eeqj.de/sneak/vaultik/issues/395.
func TestScanRecordsOnlyTheMountPointOfAnAutomountedVolume(t *testing.T) {
t.Parallel()
const scannedPath = "/data/source"
mountPoint := scannedPath + "/nas"
tests := []struct {
name string
unmountedDevice uint64
canMount bool
want []string
}{
{
name: "on the automounter's device until listed",
unmountedDevice: 3, canMount: true, want: []string{mountPoint},
},
{
name: "on its parent's device until listed",
unmountedDevice: 1, canMount: true, want: []string{mountPoint},
},
{
name: "volume that cannot be mounted",
unmountedDevice: 1, canMount: false, want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx := context.Background()
fs := &automountFs{
Fs: afero.NewMemMapFs(), mountPoint: mountPoint,
unmountedDevice: tt.unmountedDevice, canMount: tt.canMount,
}
require.NoError(t, fs.MkdirAll(mountPoint+"/photos", 0o755))
for _, path := range []string{
scannedPath + "/notes.txt", mountPoint + "/photos/a.jpg",
} {
require.NoError(t, afero.WriteFile(fs, path, []byte(path), 0o644))
}
db, err := database.NewTestDB()
require.NoError(t, err)
t.Cleanup(func() { _ = db.Close() })
repos := database.NewRepositories(db)
for _, snapshotID := range []string{"first", "later"} {
createTestSnapshotRecord(ctx, t, repos, snapshotID)
scanner := snapshot.NewScanner(snapshot.ScannerConfig{
FS: fs,
ChunkSize: int64(1024 * 16),
Repositories: repos,
MaxBlobSize: int64(1024 * 1024),
CompressionLevel: 3,
AgeRecipients: []string{testAgePublicKey},
DeviceNumber: deviceAtLookup,
})
_, err = scanner.Scan(ctx, scannedPath, snapshotID)
require.NoError(t, err)
got, err := repos.Snapshots.GetMountPoints(ctx, snapshotID)
require.NoError(t, err)
assert.Equal(t, tt.want, got, "mount points of the %s backup", snapshotID)
}
})
}
}
+1 -2
View File
@@ -78,8 +78,7 @@ func TestProgressPercentWithSecondPathSmaller(t *testing.T) {
stats := progress.GetStats() stats := progress.GetStats()
// Directories count toward the total size but produce no chunks, so // With both paths counted, the percentage must not exceed 100%.
// the percentage ends just under 100%.
percent := float64(stats.BytesProcessed.Load()) / percent := float64(stats.BytesProcessed.Load()) /
float64(stats.TotalSize.Load()) * 100 float64(stats.TotalSize.Load()) * 100
if percent > 100 { if percent > 100 {
+422 -113
View File
@@ -58,17 +58,27 @@ type Scanner struct {
compressionLevel int compressionLevel int
ageRecipient string ageRecipient string
snapshotID string // Current snapshot being processed snapshotID string // Current snapshot being processed
// previousSnapshotID is the most recent completed snapshot of the
// same name, "" for none. Only a file it lists can be counted as
// deleted.
previousSnapshotID string
// currentSourcePath is the source directory being scanned, stored with // currentSourcePath is the source directory being scanned, stored with
// each file record. // each file record.
currentSourcePath string currentSourcePath string
exclude []string // Glob patterns for files/directories to exclude exclude []string // Glob patterns for files/directories to exclude
compiledExclude []compiledPattern // Compiled glob patterns compiledExclude []compiledPattern // Compiled glob patterns
// indexAndLockFiles holds the paths of vaultik's own working files,
// which the scan treats as excluded (ScannerConfig.IndexAndLockFiles).
indexAndLockFiles map[string]struct{}
progress *ProgressReporter progress *ProgressReporter
// skipErrors skips files that cannot be opened or read (logged loudly); // skipErrors skips files that cannot be opened or read (logged loudly);
// packer, database, encryption, and upload errors still abort the run. // packer, database, encryption, and upload errors still abort the run.
skipErrors bool skipErrors bool
// ui is the user-facing output; never nil (defaults to a discarding writer). // ui is the user-facing output; never nil (defaults to a discarding writer).
ui *ui.Writer ui *ui.Writer
// deviceNumber returns the number of the device that holds a file;
// never nil (defaults to deviceNumberFromStat).
deviceNumber func(info os.FileInfo) (uint64, bool)
// In-memory cache of known chunk hashes for fast existence checks // In-memory cache of known chunk hashes for fast existence checks
knownChunks map[string]struct{} knownChunks map[string]struct{}
@@ -126,18 +136,32 @@ type ScannerConfig struct {
EnableProgress bool // Enable the live progress reporter (ETAs, throughput) EnableProgress bool // Enable the live progress reporter (ETAs, throughput)
UI *ui.Writer // Where user-facing scanner messages go; nil = discard UI *ui.Writer // Where user-facing scanner messages go; nil = discard
Exclude []string // Glob patterns for files/directories to exclude Exclude []string // Glob patterns for files/directories to exclude
// IndexAndLockFiles are the absolute paths of vaultik's own working
// files: the index, its side files and the PID file. The scan treats
// them as excluded, whatever Exclude says, since a copy taken while
// they are being written is torn.
IndexAndLockFiles []string
// SkipErrors skips files that cannot be opened or read (log loudly but // SkipErrors skips files that cannot be opened or read (log loudly but
// continue); packer, database, encryption, and upload errors still abort. // continue); packer, database, encryption, and upload errors still abort.
SkipErrors bool SkipErrors bool
// PreviousSnapshotID is the most recent completed snapshot of the
// same name, "" for none.
PreviousSnapshotID string
// DeviceNumber returns the number of the device that holds a file,
// and false when there is none to read. nil reads it from the file's
// stat; tests replace it, since the in-memory test filesystem has none.
DeviceNumber func(info os.FileInfo) (uint64, bool)
} }
// ScanResult contains the results of a scan operation. Files and bytes // ScanResult contains the results of a scan operation. Files and bytes
// are counted per file: BytesScanned is the size of the new and changed // are counted per file: BytesScanned is the size of the new and changed
// files, BytesSkipped that of the unchanged ones. FilesFailed counts the // files, BytesSkipped that of the unchanged ones. FilesFailed counts the
// new and changed files that phase 2 could not store; FilesScanned // new and changed files that phase 2 could not store, each entry removed
// includes them and BytesScanned does not. ChunksCreated counts the // after the walk listed its directory and, under --skip-errors, each
// chunks that were not already stored, and NewChunkSize is their total // entry the walk could not read, such as a directory it could not list;
// size. // FilesScanned includes them and BytesScanned does not. ChunksCreated
// counts the chunks that were not already stored, and NewChunkSize is
// their total size.
type ScanResult struct { type ScanResult struct {
FilesScanned int FilesScanned int
FilesSkipped int FilesSkipped int
@@ -154,6 +178,17 @@ type ScanResult struct {
UploadDuration time.Duration UploadDuration time.Duration
StartTime time.Time StartTime time.Time
EndTime time.Time EndTime time.Time
// FilesUnreadable is the part of FilesFailed that --skip-errors
// skipped because it could not be read or listed; the rest are files
// removed after the walk listed them.
FilesUnreadable int
// EntriesFound counts the entries the walk found and could look up
// that no exclude pattern matches, leaving out the scanned path
// itself when it is a directory. It is zero for an empty directory,
// such as the mount point of a volume that is not mounted.
EntriesFound int
} }
// NewScanner creates a new scanner instance // NewScanner creates a new scanner instance
@@ -194,6 +229,16 @@ func NewScanner(cfg ScannerConfig) *Scanner {
uiw = ui.NewWithColor(io.Discard, false) uiw = ui.NewWithColor(io.Discard, false)
} }
deviceNumber := cfg.DeviceNumber
if deviceNumber == nil {
deviceNumber = deviceNumberFromStat
}
indexAndLockFiles := make(map[string]struct{}, len(cfg.IndexAndLockFiles))
for _, p := range cfg.IndexAndLockFiles {
indexAndLockFiles[filepath.Clean(p)] = struct{}{}
}
return &Scanner{ return &Scanner{
fs: cfg.FS, fs: cfg.FS,
chunker: chunker.NewChunker(cfg.ChunkSize), chunker: chunker.NewChunker(cfg.ChunkSize),
@@ -205,9 +250,12 @@ func NewScanner(cfg ScannerConfig) *Scanner {
ageRecipient: strings.Join(cfg.AgeRecipients, ","), ageRecipient: strings.Join(cfg.AgeRecipients, ","),
exclude: cfg.Exclude, exclude: cfg.Exclude,
compiledExclude: compiledExclude, compiledExclude: compiledExclude,
indexAndLockFiles: indexAndLockFiles,
progress: progress, progress: progress,
skipErrors: cfg.SkipErrors, skipErrors: cfg.SkipErrors,
previousSnapshotID: cfg.PreviousSnapshotID,
ui: uiw, ui: uiw,
deviceNumber: deviceNumber,
pendingChunkHashes: make(map[string]struct{}), pendingChunkHashes: make(map[string]struct{}),
} }
} }
@@ -258,8 +306,9 @@ func (s *Scanner) Scan(
filesToProcess := scanResult.FilesToProcess filesToProcess := scanResult.FilesToProcess
// Phase 1b: Detect deleted files by comparing DB against scanned files // Phase 1b: Detect deleted files by comparing the previous snapshot
err = s.detectDeletedFilesFromMap(ctx, knownFiles, existingFiles, result) // of this name against scanned files
err = s.detectDeletedFiles(ctx, path, existingFiles, result)
if err != nil { if err != nil {
return nil, fmt.Errorf("detecting deleted files: %w", err) return nil, fmt.Errorf("detecting deleted files: %w", err)
} }
@@ -275,6 +324,11 @@ func (s *Scanner) Scan(
} }
} }
err = s.recordDirectories(ctx, scanResult)
if err != nil {
return nil, err
}
// Summarize scan phase results and update progress // Summarize scan phase results and update progress
s.summarizeScanPhase(result, filesToProcess) s.summarizeScanPhase(result, filesToProcess)
@@ -439,9 +493,8 @@ func (s *Scanner) summarizeScanPhase(
} }
// loadKnownFiles loads the known files at and beneath path from the // loadKnownFiles loads the known files at and beneath path from the
// database into a map for fast lookup. Every loaded file the scan does // database into a map for fast lookup. This avoids per-file database
// not find is counted as deleted. This avoids per-file database queries // queries during the scan phase.
// during the scan phase.
func (s *Scanner) loadKnownFiles( func (s *Scanner) loadKnownFiles(
ctx context.Context, path string, ctx context.Context, path string,
) (map[string]*database.File, error) { ) (map[string]*database.File, error) {
@@ -848,13 +901,19 @@ func (s *Scanner) batchInsertFileData(
type ScanPhaseResult struct { type ScanPhaseResult struct {
FilesToProcess []*FileToProcess FilesToProcess []*FileToProcess
UnchangedFileIDs []types.FileID // IDs of unchanged files to associate with snapshot UnchangedFileIDs []types.FileID // IDs of unchanged files to associate with snapshot
Directories []*FileToProcess // Every directory found, to record
// MountPoints are the directories, the scanned path included, that
// the walk found an entry in and, once it had listed them, on a
// different device from their parent directory (see
// recordIfMountPoint).
MountPoints []string
} }
// scanPhase performs the initial directory scan to identify files to process // scanPhase performs the initial directory scan to identify files to process
// It uses the pre-loaded knownFiles map for fast change detection without DB // It uses the pre-loaded knownFiles map for fast change detection without DB
// queries. It also populates existingFiles map for deletion detection. // queries. It also populates existingFiles map for deletion detection.
// Returns files needing processing and IDs of unchanged files for snapshot // Returns files needing processing, IDs of unchanged files for snapshot
// association. // association, and the directories found.
func (s *Scanner) scanPhase( func (s *Scanner) scanPhase(
ctx context.Context, ctx context.Context,
path string, path string,
@@ -862,10 +921,12 @@ func (s *Scanner) scanPhase(
existingFiles map[string]struct{}, existingFiles map[string]struct{},
knownFiles map[string]*database.File, knownFiles map[string]*database.File,
) (*ScanPhaseResult, error) { ) (*ScanPhaseResult, error) {
// Use known file count as estimate for progress (accurate for subsequent backups) // Use known file count as estimate for progress (accurate for
estimatedTotal := int64(len(knownFiles)) // subsequent backups). The walk does not count directories, so the
// estimate leaves them out.
estimatedTotal := countNonDirectories(knownFiles)
collector := &scanCollector{} collector := &scanCollector{dirsWithEntries: make(map[string]struct{})}
// Set up periodic status output // Set up periodic status output
startTime := time.Now() startTime := time.Now()
@@ -874,8 +935,28 @@ func (s *Scanner) scanPhase(
log.Debug("Starting directory walk", "path", path) log.Debug("Starting directory walk", "path", path)
walkFn := func(filePath string, info os.FileInfo, err error) error { walkFn := func(filePath string, info os.FileInfo, err error) error {
// Check exclude patterns first, so an excluded entry that is gone
// or cannot be looked up is neither counted nor reported. For
// directories, skip the entire subtree. info is nil when the
// walk could not look the entry up.
if s.shouldExclude(filePath, path) {
if info != nil && info.IsDir() {
return filepath.SkipDir
}
return nil
}
// The source path is in no directory listing, so its absence is
// an error rather than a removal.
if errors.Is(err, os.ErrNotExist) && filePath != path {
countRemovedDuringWalk(filePath, existingFiles, result)
return nil
}
if err != nil { if err != nil {
return s.handleWalkError(filePath, err) return s.handleWalkError(filePath, err, result)
} }
// Check context cancellation // Check context cancellation
@@ -885,18 +966,16 @@ func (s *Scanner) scanPhase(
default: default:
} }
// Check exclude patterns - for directories, skip the entire subtree // The scanned directory itself is not an entry found in it.
if s.shouldExclude(filePath, path) { if filePath != path || !info.IsDir() {
if info.IsDir() { result.EntriesFound++
return filepath.SkipDir
} }
return nil s.recordIfMountPoint(filePath, path, collector)
}
// Handle symlinks and directories // Handle symlinks and directories
handled, err := s.recordSpecialEntry( handled, err := s.recordSpecialEntry(
filePath, info, existingFiles, collector, result) filePath, info, existingFiles, knownFiles, collector, result)
if handled { if handled {
return err return err
} }
@@ -906,17 +985,8 @@ func (s *Scanner) scanPhase(
return nil return nil
} }
// Track this file as existing (for deletion detection) filesScanned, changedCount := s.recordRegularFile(
existingFiles[filePath] = struct{}{} filePath, info, existingFiles, knownFiles, collector, result)
// Check file against in-memory map (no DB query!)
file, needsProcessing := s.checkFileInMemory(filePath, info, knownFiles)
filesScanned, changedCount := collector.recordRegularFile(
filePath, info, file, needsProcessing)
// Update result stats
s.updateScanEntryStats(result, needsProcessing, info)
// Output periodic status // Output periodic status
if time.Since(lastStatusTime) >= scanStatusInterval { if time.Since(lastStatusTime) >= scanStatusInterval {
@@ -936,27 +1006,46 @@ func (s *Scanner) scanPhase(
return &ScanPhaseResult{ return &ScanPhaseResult{
FilesToProcess: collector.filesToProcess, FilesToProcess: collector.filesToProcess,
UnchangedFileIDs: collector.unchangedFileIDs, UnchangedFileIDs: collector.unchangedFileIDs,
Directories: collector.directories,
MountPoints: collector.mountPoints,
}, nil }, nil
} }
// countNonDirectories returns how many of files are not directories.
func countNonDirectories(files map[string]*database.File) int64 {
var count int64
for _, f := range files {
if f.Mode&uint32(os.ModeDir) == 0 {
count++
}
}
return count
}
// recordSpecialEntry records symlinks and directories (which have no // recordSpecialEntry records symlinks and directories (which have no
// data to chunk) and reports whether it handled the entry. For a symlink // data to chunk) and reports whether it handled the entry. A symlink is
// whose target cannot be read it returns handleWalkError's result. // counted and checked for changes as a regular file is; a directory is
// set aside to be recorded and is not counted. For a symlink whose
// target cannot be read it returns handleWalkError's result.
func (s *Scanner) recordSpecialEntry( func (s *Scanner) recordSpecialEntry(
filePath string, info os.FileInfo, filePath string, info os.FileInfo,
existingFiles map[string]struct{}, existingFiles map[string]struct{},
knownFiles map[string]*database.File,
collector *scanCollector, result *ScanResult, collector *scanCollector, result *ScanResult,
) (bool, error) { ) (bool, error) {
// Handle symlinks // Handle symlinks
if info.Mode()&os.ModeSymlink != 0 { if info.Mode()&os.ModeSymlink != 0 {
file, err := s.buildSymlinkEntry(filePath, info) file, err := s.buildSymlinkEntry(filePath, info)
if err != nil { if err != nil {
return true, s.handleWalkError(filePath, err) return true, s.handleWalkError(filePath, err, result)
} }
existingFiles[filePath] = struct{}{} existingFiles[filePath] = struct{}{}
collector.addToProcess(filePath, info, file) needsProcessing := s.checkFileInMemory(file, knownFiles)
s.updateScanEntryStats(result, true, info) collector.recordFile(filePath, info, file, needsProcessing)
s.updateScanEntryStats(result, needsProcessing, info)
return true, nil return true, nil
} }
@@ -966,7 +1055,7 @@ func (s *Scanner) recordSpecialEntry(
if info.IsDir() { if info.IsDir() {
file := s.buildDirectoryEntry(filePath, info) file := s.buildDirectoryEntry(filePath, info)
existingFiles[filePath] = struct{}{} existingFiles[filePath] = struct{}{}
collector.addToProcess(filePath, info, file) collector.addDirectory(filePath, info, file)
return true, nil return true, nil
} }
@@ -974,15 +1063,62 @@ func (s *Scanner) recordSpecialEntry(
return false, nil return false, nil
} }
// recordRegularFile records a regular file as recordSpecialEntry does a
// symlink and returns the running scanned and changed counts.
func (s *Scanner) recordRegularFile(
filePath string, info os.FileInfo,
existingFiles map[string]struct{},
knownFiles map[string]*database.File,
collector *scanCollector, result *ScanResult,
) (int64, int) {
// Track this file as existing (for deletion detection)
existingFiles[filePath] = struct{}{}
// Check file against in-memory map (no DB query!)
file := s.buildRegularFileEntry(filePath, info)
needsProcessing := s.checkFileInMemory(file, knownFiles)
filesScanned, changedCount := collector.recordFile(
filePath, info, file, needsProcessing)
// Update result stats
s.updateScanEntryStats(result, needsProcessing, info)
return filesScanned, changedCount
}
// countRemovedDuringWalk counts an entry that was in its directory's
// listing but was gone when the walk looked it up, like a file removed
// before phase 2 reads it: as examined and failed, and not as an error,
// with or without --skip-errors.
func countRemovedDuringWalk(
filePath string, existingFiles map[string]struct{}, result *ScanResult,
) {
log.Warn("File was deleted during the directory walk, skipping",
"path", filePath)
// The walk found it, so it is not also counted as deleted.
existingFiles[filePath] = struct{}{}
result.FilesScanned++
result.FilesFailed++
}
// handleWalkError deals with a filesystem error surfaced by the walk: // handleWalkError deals with a filesystem error surfaced by the walk:
// skipped (with a UI notice) under --skip-errors, fatal otherwise. // under --skip-errors the entry is skipped with a UI notice and counted
func (s *Scanner) handleWalkError(filePath string, err error) error { // as examined, failed and unreadable, otherwise the error is fatal.
func (s *Scanner) handleWalkError(
filePath string, err error, result *ScanResult,
) error {
if s.skipErrors { if s.skipErrors {
log.Error("Failed to access file (skipping due to --skip-errors)", log.Error("Failed to access file (skipping due to --skip-errors)",
"path", filePath, "error", err) "path", filePath, "error", err)
s.ui.Errorf("Failed to access %s: %v. Skipping (--skip-errors).", s.ui.Errorf("Failed to access %s: %v. Skipping (--skip-errors).",
s.ui.Path(filePath), err) s.ui.Path(filePath), err)
result.FilesScanned++
result.FilesFailed++
result.FilesUnreadable++
return nil // Continue scanning return nil // Continue scanning
} }
@@ -996,28 +1132,52 @@ type scanCollector struct {
mu sync.Mutex mu sync.Mutex
filesToProcess []*FileToProcess filesToProcess []*FileToProcess
unchangedFileIDs []types.FileID // Just IDs - no new records needed unchangedFileIDs []types.FileID // Just IDs - no new records needed
directories []*FileToProcess
filesScanned int64 filesScanned int64
// dirsWithEntries holds each directory the walk has found an entry in.
dirsWithEntries map[string]struct{}
mountPoints []string
} }
// addToProcess queues a file entry for processing. // firstEntryIn notes that the walk found an entry in directory dir and
func (c *scanCollector) addToProcess( // reports whether it is the first one.
func (c *scanCollector) firstEntryIn(dir string) bool {
c.mu.Lock()
defer c.mu.Unlock()
_, seen := c.dirsWithEntries[dir]
c.dirsWithEntries[dir] = struct{}{}
return !seen
}
// addMountPoint sets a mount point aside to be recorded.
func (c *scanCollector) addMountPoint(dir string) {
c.mu.Lock()
defer c.mu.Unlock()
c.mountPoints = append(c.mountPoints, dir)
}
// addDirectory sets a directory aside to be recorded. It is not counted
// as a file.
func (c *scanCollector) addDirectory(
path string, info os.FileInfo, file *database.File, path string, info os.FileInfo, file *database.File,
) { ) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
c.filesToProcess = append(c.filesToProcess, &FileToProcess{ c.directories = append(c.directories, &FileToProcess{
Path: path, Path: path,
FileInfo: info, FileInfo: info,
File: file, File: file,
}) })
c.filesScanned++
} }
// recordRegularFile records a regular file as changed (to process) or // recordFile records a regular file or symlink as changed (to process)
// unchanged (snapshot association only) and returns the running scanned // or unchanged (snapshot association only) and returns the running
// and changed counts. // scanned and changed counts.
func (c *scanCollector) recordRegularFile( func (c *scanCollector) recordFile(
path string, info os.FileInfo, file *database.File, needsProcessing bool, path string, info os.FileInfo, file *database.File, needsProcessing bool,
) (int64, int) { ) (int64, int) {
c.mu.Lock() c.mu.Lock()
@@ -1120,7 +1280,28 @@ func (s *Scanner) printScanProgressLine(
} }
} }
// buildSymlinkEntry creates a File record for a symlink. // buildRegularFileEntry creates a File record for a regular file, with
// no ID: checkFileInMemory sets it.
func (s *Scanner) buildRegularFileEntry(path string, info os.FileInfo) *database.File {
var uid, gid uint32
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
uid = stat.Uid
gid = stat.Gid
}
return &database.File{
Path: types.FilePath(path),
SourcePath: types.SourcePath(s.currentSourcePath),
MTime: info.ModTime(),
Size: info.Size(),
Mode: uint32(info.Mode()),
UID: uid,
GID: gid,
}
}
// buildSymlinkEntry creates a File record for a symlink, with no ID:
// checkFileInMemory sets it.
func (s *Scanner) buildSymlinkEntry( func (s *Scanner) buildSymlinkEntry(
path string, info os.FileInfo, path string, info os.FileInfo,
) (*database.File, error) { ) (*database.File, error) {
@@ -1136,7 +1317,6 @@ func (s *Scanner) buildSymlinkEntry(
} }
return &database.File{ return &database.File{
ID: types.NewFileID(),
Path: types.FilePath(path), Path: types.FilePath(path),
SourcePath: types.SourcePath(s.currentSourcePath), SourcePath: types.SourcePath(s.currentSourcePath),
MTime: info.ModTime(), MTime: info.ModTime(),
@@ -1168,6 +1348,102 @@ func (s *Scanner) buildDirectoryEntry(path string, info os.FileInfo) *database.F
} }
} }
// recordDirectories records every directory the walk found, and its
// mount points (see recordMountPoints). A directory has no data to back
// up, so it is not among the files counted and backed up.
func (s *Scanner) recordDirectories(
ctx context.Context, scanned *ScanPhaseResult,
) error {
for _, dir := range scanned.Directories {
err := s.recordNonRegularFile(ctx, dir)
if err != nil {
return fmt.Errorf("recording directory %s: %w", dir.Path, err)
}
}
return s.recordMountPoints(ctx, scanned.MountPoints)
}
// recordMountPoints records with the snapshot the mount points the walk
// found (see ScanPhaseResult.MountPoints).
func (s *Scanner) recordMountPoints(
ctx context.Context, mountPoints []string,
) error {
return s.repos.WithTx(ctx, func(txCtx context.Context, tx *sql.Tx) error {
for _, dir := range mountPoints {
log.Info("Found a mount point", "path", dir)
err := s.repos.Snapshots.AddMountPoint(txCtx, tx, s.snapshotID, dir)
if err != nil {
return fmt.Errorf("recording mount point %s: %w", dir, err)
}
}
return nil
})
}
// recordIfMountPoint sets the directory that holds entry, a path the
// walk found, aside to be recorded as a mount point if it is on a
// different device from its parent directory. The walk calls it for each
// entry it finds, and it decides on the first entry in a directory, so a
// directory the walk found no entry in is never recorded. By then the
// walk has listed the directory, which mounts a volume mounted there on
// first access, so the directory and its parent are looked up again:
// before the listing, the directory can be on the automounter's own
// device (a systemd automount) or on its parent's (an autofs map that
// shows its mount points before mounting them). The scanned path is in
// no listing the walk made, so it is skipped as an entry. A directory
// that cannot be looked up then, or whose parent cannot, is not
// recorded, so this snapshot does not stop the prune of a later backup
// that misses its volume.
func (s *Scanner) recordIfMountPoint(
entry, scannedPath string, collector *scanCollector,
) {
dir := filepath.Dir(entry)
if entry == scannedPath || !collector.firstEntryIn(dir) {
return
}
dirDevice, ok := s.deviceNumberAt(dir)
if !ok {
return
}
parentDevice, ok := s.deviceNumberAt(filepath.Dir(dir))
if ok && dirDevice != parentDevice {
collector.addMountPoint(dir)
}
}
// deviceNumberAt looks path up now and returns the number of the device
// that holds it, and false when the lookup fails (logged) or gives no
// device number.
func (s *Scanner) deviceNumberAt(path string) (uint64, bool) {
info, err := s.fs.Stat(path)
if err != nil {
log.Warn("Could not look up a directory to check for a mount point",
"path", path, "error", err)
return 0, false
}
return s.deviceNumber(info)
}
// deviceNumberFromStat returns the number of the device that holds the
// file info describes, and false when info carries no stat, as from the
// in-memory test filesystem.
func deviceNumberFromStat(info os.FileInfo) (uint64, bool) {
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return 0, false
}
return uint64(stat.Dev), true //nolint:unconvert // Dev is an int32 on darwin
}
// recordNonRegularFile writes a symlink or directory entry to the database // recordNonRegularFile writes a symlink or directory entry to the database
// and associates it with the current snapshot. No chunking is performed. // and associates it with the current snapshot. No chunking is performed.
func (s *Scanner) recordNonRegularFile(ctx context.Context, ftp *FileToProcess) error { func (s *Scanner) recordNonRegularFile(ctx context.Context, ftp *FileToProcess) error {
@@ -1181,62 +1457,36 @@ func (s *Scanner) recordNonRegularFile(ctx context.Context, ftp *FileToProcess)
}) })
} }
// checkFileInMemory checks if a file needs processing using the in-memory map // checkFileInMemory reports whether a regular file or symlink needs
// processing, and gives file the ID of its known record, or a new ID for
// a new path.
// No database access is performed - this is purely CPU/memory work // No database access is performed - this is purely CPU/memory work
func (s *Scanner) checkFileInMemory( func (s *Scanner) checkFileInMemory(
path string, info os.FileInfo, knownFiles map[string]*database.File, file *database.File, knownFiles map[string]*database.File,
) (*database.File, bool) { ) bool {
var uid, gid uint32 existingFile, exists := knownFiles[file.Path.String()]
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
uid = stat.Uid
gid = stat.Gid
}
// Check against in-memory map first to get existing ID if available
existingFile, exists := knownFiles[path]
// Create file record with ID set upfront
// For new files, generate UUID immediately so it's available for chunk associations
// For existing files, reuse the existing ID
var fileID types.FileID
if exists {
fileID = existingFile.ID
} else {
fileID = types.NewFileID()
}
file := &database.File{
ID: fileID,
Path: types.FilePath(path),
SourcePath: types.SourcePath(s.currentSourcePath),
MTime: info.ModTime(),
Size: info.Size(),
Mode: uint32(info.Mode()),
UID: uid,
GID: gid,
}
// New file - needs processing // New file - needs processing
if !exists { if !exists {
return file, true file.ID = types.NewFileID()
return true
} }
file.ID = existingFile.ID
// No uploaded blob holds one of its chunks (see loadFilesToRechunk) // No uploaded blob holds one of its chunks (see loadFilesToRechunk)
if _, rechunk := s.filesToRechunk[fileID]; rechunk { if _, rechunk := s.filesToRechunk[file.ID]; rechunk {
return file, true return true
} }
// Check if file has changed // Check if file has changed
if existingFile.Size != file.Size || return existingFile.Size != file.Size ||
!existingFile.MTime.Equal(file.MTime) || !existingFile.MTime.Equal(file.MTime) ||
existingFile.Mode != file.Mode || existingFile.Mode != file.Mode ||
existingFile.UID != file.UID || existingFile.UID != file.UID ||
existingFile.GID != file.GID { existingFile.GID != file.GID ||
return file, true existingFile.LinkTarget != file.LinkTarget
}
// File unchanged
return file, false
} }
// batchAddFilesToSnapshot adds existing file IDs to the snapshot association table // batchAddFilesToSnapshot adds existing file IDs to the snapshot association table
@@ -1363,17 +1613,16 @@ func (s *Scanner) processPhase(
return s.finalizeProcessPhase(ctx, result) return s.finalizeProcessPhase(ctx, result)
} }
// processFileWithErrorHandling records a directory or symlink, or wraps // processFileWithErrorHandling records a symlink, or wraps
// processFileStreaming for a regular file with error recovery for deleted // processFileStreaming for a regular file with error recovery for deleted
// files and skip-errors mode. Returns (skipped, error). // files and skip-errors mode. Returns (skipped, error).
func (s *Scanner) processFileWithErrorHandling( func (s *Scanner) processFileWithErrorHandling(
ctx context.Context, fileToProcess *FileToProcess, result *ScanResult, ctx context.Context, fileToProcess *FileToProcess, result *ScanResult,
) (bool, error) { ) (bool, error) {
// A directory or symlink has no data to open or read; it is only // A symlink has no data to open or read; it is only recorded in the
// recorded in the local index. An error recording it stops the run // local index. An error recording it stops the run even under
// even under --skip-errors, or the snapshot would complete without it. // --skip-errors, or the snapshot would complete without it.
mode := os.FileMode(fileToProcess.File.Mode) if os.FileMode(fileToProcess.File.Mode)&os.ModeSymlink != 0 {
if mode&os.ModeSymlink != 0 || mode.IsDir() {
err := s.recordNonRegularFile(ctx, fileToProcess) err := s.recordNonRegularFile(ctx, fileToProcess)
if err != nil { if err != nil {
return false, fmt.Errorf("processing file %s: %w", fileToProcess.Path, err) return false, fmt.Errorf("processing file %s: %w", fileToProcess.Path, err)
@@ -1414,6 +1663,7 @@ func (s *Scanner) processFileWithErrorHandling(
s.ui.Path(fileToProcess.Path), err) s.ui.Path(fileToProcess.Path), err)
countFailedFile(fileToProcess, result) countFailedFile(fileToProcess, result)
result.FilesUnreadable++
return true, nil return true, nil
} }
@@ -1625,7 +1875,13 @@ func (s *Scanner) uploadBlobIfNeeded(
err = s.storage.PutWithProgress(ctx, blobPath, blobWithReader.Reader, err = s.storage.PutWithProgress(ctx, blobPath, blobWithReader.Reader,
finishedBlob.Compressed, progressCallback) finishedBlob.Compressed, progressCallback)
if err != nil { if err != nil {
log.Error("Failed to upload blob", "hash", finishedBlob.Hash, "error", err) // The record is at info level: the returned error ends the backup
// and is printed as its ERROR: line. Only an interrupt cancels
// ctx, and the user is told about it, so an upload it stopped is
// not logged as a storage failure.
if ctx.Err() == nil {
log.Info("Failed to upload blob", "hash", finishedBlob.Hash, "error", err)
}
return 0, false, fmt.Errorf("uploading blob to storage: %w", err) return 0, false, fmt.Errorf("uploading blob to storage: %w", err)
} }
@@ -1954,21 +2210,29 @@ func (s *Scanner) queueFileForBatchInsert(
}) })
} }
// detectDeletedFilesFromMap finds files that existed in previous snapshots // detectDeletedFiles counts as deleted each file at or beneath path that
// but no longer exist. Uses pre-loaded maps to avoid any filesystem or // the previous snapshot of this name lists, the walk did not find, and
// database access. // is gone from disk. A file that is excluded, or inside an excluded
func (s *Scanner) detectDeletedFilesFromMap( // directory, is not counted: the walk skips it whether or not it is
// still on disk.
func (s *Scanner) detectDeletedFiles(
ctx context.Context, ctx context.Context,
knownFiles map[string]*database.File, path string,
existingFiles map[string]struct{}, existingFiles map[string]struct{},
result *ScanResult, result *ScanResult,
) error { ) error {
if len(knownFiles) == 0 { if s.previousSnapshotID == "" {
return nil return nil
} }
// Check each known file against the enumerated set (no filesystem access needed) previousFiles, err := s.repos.Files.ListUnderPathInSnapshot(
for path, file := range knownFiles { ctx, path, s.previousSnapshotID)
if err != nil {
return fmt.Errorf("listing files of snapshot %s: %w",
s.previousSnapshotID, err)
}
for _, file := range previousFiles {
// Check context cancellation periodically // Check context cancellation periodically
select { select {
case <-ctx.Done(): case <-ctx.Done():
@@ -1976,13 +2240,22 @@ func (s *Scanner) detectDeletedFilesFromMap(
default: default:
} }
// Check if the file exists in our enumerated set filePath := file.Path.String()
if _, exists := existingFiles[path]; !exists {
// File has been deleted _, exists := existingFiles[filePath]
if exists || s.excludedOrInExcludedDir(filePath, path) {
continue
}
// Under --skip-errors the walk also misses what it cannot reach,
// such as the files in a directory it cannot open.
if !s.goneFromDisk(filePath) {
continue
}
result.FilesDeleted++ result.FilesDeleted++
result.BytesDeleted += file.Size result.BytesDeleted += file.Size
log.Debug("Detected deleted file", "path", path, "size", file.Size) log.Debug("Detected deleted file", "path", filePath, "size", file.Size)
}
} }
if result.FilesDeleted > 0 { if result.FilesDeleted > 0 {
@@ -1993,6 +2266,37 @@ func (s *Scanner) detectDeletedFilesFromMap(
return nil return nil
} }
// excludedOrInExcludedDir reports whether filePath, which is at or
// beneath rootPath, or any directory between rootPath and it, matches an
// exclude pattern or is one of vaultik's own files. A pattern such as
// */.local/share/Trash matches the directory but not the files inside it,
// which the walk skips too.
func (s *Scanner) excludedOrInExcludedDir(filePath, rootPath string) bool {
for p := filePath; len(p) > len(rootPath); p = filepath.Dir(p) {
if s.shouldExclude(p, rootPath) {
return true
}
}
return false
}
// goneFromDisk reports whether looking filePath up on disk finds that
// it does not exist. Like the walk, it does not follow a symlink, so a
// symlink whose target is gone is still on disk. A file whose directory
// was replaced by a regular file is gone too, though the lookup reports
// "not a directory".
func (s *Scanner) goneFromDisk(filePath string) bool {
var err error
if lstater, ok := s.fs.(afero.Lstater); ok {
_, _, err = lstater.LstatIfPossible(filePath)
} else {
_, err = s.fs.Stat(filePath)
}
return errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ENOTDIR)
}
// wrapPermissionError augments permission errors with platform-specific // wrapPermissionError augments permission errors with platform-specific
// remediation instructions. On macOS, TCC-protected directories (Calendars, // remediation instructions. On macOS, TCC-protected directories (Calendars,
// Reminders, Photos, etc.) return EPERM unless the running application has // Reminders, Photos, etc.) return EPERM unless the running application has
@@ -2090,9 +2394,14 @@ func doubleStarVariants(pattern string) []string {
} }
// shouldExclude checks if a path should be excluded based on exclude patterns // shouldExclude checks if a path should be excluded based on exclude patterns
// or because it is one of vaultik's own working files.
// filePath is the full path to the file // filePath is the full path to the file
// rootPath is the root of the backup source directory // rootPath is the root of the backup source directory
func (s *Scanner) shouldExclude(filePath, rootPath string) bool { func (s *Scanner) shouldExclude(filePath, rootPath string) bool {
if _, ok := s.indexAndLockFiles[filePath]; ok {
return true
}
if len(s.compiledExclude) == 0 { if len(s.compiledExclude) == 0 {
return false return false
} }
@@ -0,0 +1,28 @@
//nolint:testpackage // exercises the unexported countNonDirectories helper
package snapshot
import (
"os"
"testing"
"sneak.berlin/go/vaultik/internal/database"
)
// The walk counts regular files and symlinks, not directories, so the
// estimate of its count made from the local index's records leaves the
// directories out. See https://git.eeqj.de/sneak/vaultik/issues/305.
func TestCountNonDirectories(t *testing.T) {
t.Parallel()
known := map[string]*database.File{
"/src": {Mode: uint32(os.ModeDir | 0o755)},
"/src/sub": {Mode: uint32(os.ModeDir | 0o755)},
"/src/a.txt": {Mode: 0o644},
"/src/link": {Mode: uint32(os.ModeSymlink | 0o777), LinkTarget: "a.txt"},
}
got := countNonDirectories(known)
if got != 2 {
t.Errorf("countNonDirectories = %d, want 2", got)
}
}
+24 -9
View File
@@ -271,16 +271,18 @@ func (sm *SnapshotManager) CompleteSnapshot(
return sm.MarkSnapshotComplete(ctx, snapshotID) return sm.MarkSnapshotComplete(ctx, snapshotID)
} }
// ExportSnapshotMetadata exports snapshot metadata to S3 // ExportSnapshotMetadata exports snapshot metadata to the backup destination store
// //
// This method executes the complete snapshot metadata export process: // This method executes the complete snapshot metadata export process:
// 1. Creates a temporary directory for working files // 1. Creates a temporary directory for working files
// 2. Copies the main database to preserve its state // 2. Copies the main database to preserve its state
// 3. Cleans the copy to contain only current snapshot data // 3. Cleans the copy to contain only current snapshot data
// 4. Dumps the cleaned database to SQL // 4. Vacuums the copy to remove the deleted data
// 5. Compresses the SQL dump with zstd // 5. Compresses the copy with zstd
// 6. Encrypts the compressed data with age (always; recipients are required) // 6. Encrypts the compressed copy with age (always; recipients are required)
// 7. Uploads to S3 at: snapshots/{snapshot-id}.sql.zst[.age] // 7. Builds the blob manifest from the copy, compressed but not encrypted
// 8. Uploads the database to metadata/{remote-key}/db.zst.age
// 9. Uploads the manifest to metadata/{remote-key}/manifest.json.zst
// //
// The only caller (finalizeSnapshotMetadata) does not close the main database // The only caller (finalizeSnapshotMetadata) does not close the main database
// before calling this method: the index is copied at dbPath while it is still // before calling this method: the index is copied at dbPath while it is still
@@ -518,8 +520,8 @@ type CleanupStats struct {
// //
// The cleanup is performed in a specific order to maintain referential integrity: // The cleanup is performed in a specific order to maintain referential integrity:
// 1. Delete other snapshots // 1. Delete other snapshots
// 2. Delete orphaned snapshot associations (snapshot_files, snapshot_blobs) // 2. Delete orphaned snapshot associations (snapshot_files, snapshot_blobs,
// for deleted snapshots // snapshot_mount_points) for deleted snapshots
// 3. Delete orphaned files (not in the current snapshot) // 3. Delete orphaned files (not in the current snapshot)
// 4. Delete orphaned chunk-to-file mappings (references to deleted files) // 4. Delete orphaned chunk-to-file mappings (references to deleted files)
// 5. Delete orphaned blobs (not in the current snapshot) // 5. Delete orphaned blobs (not in the current snapshot)
@@ -948,8 +950,8 @@ func (sm *SnapshotManager) deleteOtherSnapshots(
return nil return nil
} }
// deleteOrphanedSnapshotAssociations deletes snapshot_files and // deleteOrphanedSnapshotAssociations deletes snapshot_files,
// snapshot_blobs for deleted snapshots // snapshot_blobs and snapshot_mount_points for deleted snapshots
func (sm *SnapshotManager) deleteOrphanedSnapshotAssociations( func (sm *SnapshotManager) deleteOrphanedSnapshotAssociations(
ctx context.Context, tx *sql.Tx, currentSnapshotID string, ctx context.Context, tx *sql.Tx, currentSnapshotID string,
) error { ) error {
@@ -983,6 +985,19 @@ func (sm *SnapshotManager) deleteOrphanedSnapshotAssociations(
log.Debug("[Temp DB Cleanup] Deleted snapshot_blobs associations", log.Debug("[Temp DB Cleanup] Deleted snapshot_blobs associations",
"count", rowsAffected) "count", rowsAffected)
// Delete the mount points other snapshots recorded
query := "DELETE FROM snapshot_mount_points WHERE snapshot_id != ?"
database.LogSQL("Execute", query, currentSnapshotID)
result, err = tx.ExecContext(ctx, query, currentSnapshotID)
if err != nil {
return fmt.Errorf("deleting orphaned snapshot_mount_points: %w", err)
}
rowsAffected, _ = result.RowsAffected()
log.Debug("[Temp DB Cleanup] Deleted snapshot_mount_points rows",
"count", rowsAffected)
return nil return nil
} }
+73
View File
@@ -7,12 +7,14 @@ import (
"database/sql" "database/sql"
"io" "io"
"path/filepath" "path/filepath"
"slices"
"testing" "testing"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/vaultik/internal/config" "sneak.berlin/go/vaultik/internal/config"
"sneak.berlin/go/vaultik/internal/database" "sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/log" "sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/types"
) )
const ( const (
@@ -387,6 +389,77 @@ func TestCleanSnapshotDBNonExistentSnapshot(t *testing.T) {
} }
} }
// The exported metadata database keeps only the mount points that the
// exported snapshot recorded. See
// https://git.eeqj.de/sneak/vaultik/issues/395.
func TestCleanSnapshotDBKeepsOnlyTheSnapshotsMountPoints(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
dbPath := filepath.Join(t.TempDir(), "export.db")
db, err := database.New(ctx, dbPath)
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
repos := database.NewRepositories(db)
for _, id := range []types.SnapshotID{"exported", "other"} {
err = repos.Snapshots.Create(ctx, nil, &database.Snapshot{ID: id})
if err != nil {
t.Fatalf("failed to create snapshot: %v", err)
}
err = repos.Snapshots.AddMountPoint(ctx, nil, id.String(), "/data/"+id.String())
if err != nil {
t.Fatalf("failed to add mount point: %v", err)
}
}
err = db.Close()
if err != nil {
t.Fatalf("failed to close database: %v", err)
}
sm := &SnapshotManager{
config: &config.Config{
CompressionLevel: 3,
AgeRecipients: []string{testAgeRecipient},
},
fs: afero.NewOsFs(),
}
_, err = sm.cleanSnapshotDB(ctx, dbPath, "exported")
if err != nil {
t.Fatalf("failed to clean snapshot database: %v", err)
}
cleaned, err := database.New(ctx, dbPath)
if err != nil {
t.Fatalf("failed to open cleaned database: %v", err)
}
defer func() { _ = cleaned.Close() }()
cleanedRepos := database.NewRepositories(cleaned)
for id, want := range map[string][]string{
"exported": {"/data/exported"},
"other": nil,
} {
got, err := cleanedRepos.Snapshots.GetMountPoints(ctx, id)
if err != nil {
t.Fatalf("failed to read mount points: %v", err)
}
if !slices.Equal(got, want) {
t.Errorf("mount points of %s: got %v, want %v", id, got, want)
}
}
}
// Two creates of one snapshot name back to back start within one second, // Two creates of one snapshot name back to back start within one second,
// the resolution of the timestamp in a snapshot ID. See // the resolution of the timestamp in a snapshot ID. See
// https://git.eeqj.de/sneak/vaultik/issues/270. // https://git.eeqj.de/sneak/vaultik/issues/270.
+135
View File
@@ -0,0 +1,135 @@
//nolint:testpackage // exercises the unexported uploadBlobIfNeeded
package snapshot
import (
"bytes"
"context"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
"sneak.berlin/go/vaultik/internal/blob"
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/storage"
"sneak.berlin/go/vaultik/internal/storage/faultstore"
"sneak.berlin/go/vaultik/internal/ui"
)
const uploadFailedLogMessage = "Failed to upload blob"
// uploadTestBlob uploads a small blob through store.
func uploadTestBlob(ctx context.Context, store storage.Storer) error {
data := []byte("blob data")
s := &Scanner{storage: store, ui: ui.NewWithColor(io.Discard, false)}
_, _, err := s.uploadBlobIfNeeded(ctx, "blobs/test", &blob.WithReader{
FinishedBlob: &blob.FinishedBlob{
Hash: "test",
Compressed: int64(len(data)),
},
Reader: bytes.NewReader(data),
}, time.Now(), &ScanResult{})
return err
}
// captureLog runs fn and returns what the logger wrote to stderr
// meanwhile, at the --verbose level. The logger writes to the os.Stderr
// it finds when it is initialized, so os.Stderr is pointed at a file
// first. Not parallel-safe: os.Stderr and the logger are process-global.
func captureLog(t *testing.T, fn func()) string {
t.Helper()
stderrPath := filepath.Join(t.TempDir(), "stderr")
stderrFile, err := os.Create(stderrPath) //nolint:gosec // G304: test temp path
if err != nil {
t.Fatalf("creating stderr file: %v", err)
}
previous := os.Stderr
os.Stderr = stderrFile
log.Initialize(log.Config{Verbose: true})
fn()
os.Stderr = previous
log.Initialize(log.Config{})
_ = stderrFile.Close()
captured, err := os.ReadFile(stderrPath) //nolint:gosec // G304: test temp path
if err != nil {
t.Fatalf("reading stderr file: %v", err)
}
return string(captured)
}
// An upload stopped by Ctrl-C or SIGTERM is not logged as an error; the
// interrupted message is all the user sees. See
// https://git.eeqj.de/sneak/vaultik/issues/434.
//
//nolint:paralleltest // captureLog replaces os.Stderr
func TestInterruptedUploadIsNotLoggedAsError(t *testing.T) {
inner, err := storage.NewFileStorer(t.TempDir())
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
// The user interrupts mid-upload. The injected error does not wrap
// context.Canceled, so only ctx shows why the upload failed.
store := faultstore.New(inner)
store.OnPut = func(string) faultstore.PutAction {
cancel()
return faultstore.PutFail
}
var uploadErr error
stderr := captureLog(t, func() { uploadErr = uploadTestBlob(ctx, store) })
if uploadErr == nil {
t.Fatal("expected the upload to fail")
}
if strings.Contains(stderr, uploadFailedLogMessage) {
t.Errorf("interrupted upload logged as a failure: %q", stderr)
}
}
// An upload that fails for any other reason is still logged.
//
//nolint:paralleltest // captureLog replaces os.Stderr
func TestFailedUploadIsLogged(t *testing.T) {
inner, err := storage.NewFileStorer(t.TempDir())
if err != nil {
t.Fatal(err)
}
store := faultstore.New(inner)
store.OnPut = func(string) faultstore.PutAction { return faultstore.PutFail }
var uploadErr error
stderr := captureLog(t, func() {
uploadErr = uploadTestBlob(context.Background(), store)
})
if uploadErr == nil {
t.Fatal("expected the upload to fail")
}
if !strings.Contains(stderr, uploadFailedLogMessage) {
t.Errorf("failed upload not logged: %q", stderr)
}
}
+35 -3
View File
@@ -8,6 +8,7 @@ import (
"reflect" "reflect"
"sort" "sort"
"testing" "testing"
"time"
"sneak.berlin/go/vaultik/internal/storage" "sneak.berlin/go/vaultik/internal/storage"
) )
@@ -19,15 +20,17 @@ import (
// constructor, so the contract is defined once. // constructor, so the contract is defined once.
// //
// It exercises the public Storer interface: round-trip, stat, list with // It exercises the public Storer interface: round-trip, stat, list with
// prefix filtering, overwrite, delete, delete-of-missing, and not-found on // prefix filtering, the modification time ListStream reports, overwrite,
// Get and Stat. Each section takes its own fresh backend instance, so the // delete, delete-of-missing, and not-found on Get and Stat. Each section
// order of sections never matters and no section sees another's objects. // takes its own fresh backend instance, so the order of sections never
// matters and no section sees another's objects.
func runStorerConformance(t *testing.T, newStorer func(*testing.T) storage.Storer) { func runStorerConformance(t *testing.T, newStorer func(*testing.T) storage.Storer) {
t.Helper() t.Helper()
conformanceRoundTrip(t, newStorer(t)) conformanceRoundTrip(t, newStorer(t))
conformanceOverwrite(t, newStorer(t)) conformanceOverwrite(t, newStorer(t))
conformanceList(t, newStorer(t)) conformanceList(t, newStorer(t))
conformanceListStreamModTime(t, newStorer(t))
conformanceDelete(t, newStorer(t)) conformanceDelete(t, newStorer(t))
conformanceNotFound(t, newStorer(t)) conformanceNotFound(t, newStorer(t))
} }
@@ -118,6 +121,35 @@ func conformanceList(t *testing.T, s storage.Storer) {
} }
} }
// conformanceListStreamModTime checks that ListStream gives an object
// just written a modification time of about now, which prune compares
// with its grace period.
func conformanceListStreamModTime(t *testing.T, s storage.Storer) {
t.Helper()
ctx := context.Background()
err := s.Put(ctx, "blobs/dd/written-now", bytes.NewReader([]byte("data")))
if err != nil {
t.Fatalf("Put: %v", err)
}
var listed []storage.ObjectInfo
for info := range s.ListStream(ctx, "blobs/") {
listed = append(listed, info)
}
if len(listed) != 1 || listed[0].Err != nil {
t.Fatalf("ListStream(\"blobs/\") = %v, want the one object", listed)
}
if time.Since(listed[0].ModTime).Abs() > time.Minute {
t.Errorf("ListStream ModTime = %v, want within a minute of now",
listed[0].ModTime)
}
}
// conformanceDelete checks that Delete removes an object and that deleting // conformanceDelete checks that Delete removes an object and that deleting
// a missing key is not an error. // a missing key is not an error.
func conformanceDelete(t *testing.T, s storage.Storer) { func conformanceDelete(t *testing.T, s storage.Storer) {
+23 -8
View File
@@ -2,15 +2,17 @@
// faults on demand, so tests can reproduce the failure modes a real // faults on demand, so tests can reproduce the failure modes a real
// backend exhibits: an upload that fails partway, a backend that reports // backend exhibits: an upload that fails partway, a backend that reports
// success while storing nothing, reads that return corrupt or truncated // success while storing nothing, reads that return corrupt or truncated
// bytes, and a delete the backend refuses. It is the seam called for by // bytes, and a delete or a stat the backend refuses. It is the seam
// the fault-injection tests (sneak/vaultik issue 72) and is meant to be // called for by the fault-injection tests (sneak/vaultik issue 72) and
// reused by future tests rather than re-implemented per case. // is meant to be reused by future tests rather than re-implemented per
// case.
// //
// The wrapper delegates every method to the inner Storer. Three hooks // The wrapper delegates every method to the inner Storer. Four hooks
// change that: OnPut decides the fate of each write, OnGet decides how // change that: OnPut decides the fate of each write, OnGet decides how
// each read's bytes are returned, and OnDelete decides whether each // each read's bytes are returned, OnDelete decides whether each delete
// delete fails. All are keyed by the object key, so a test can fault // fails, and OnStat whether each stat fails. All are keyed by the object
// only blobs, only metadata, or a single object. // key, so a test can fault only blobs, only metadata, or a single
// object.
package faultstore package faultstore
import ( import (
@@ -30,6 +32,11 @@ var ErrInjectedUpload = errors.New("faultstore: injected upload failure")
// fail. // fail.
var ErrInjectedDelete = errors.New("faultstore: injected delete failure") var ErrInjectedDelete = errors.New("faultstore: injected delete failure")
// ErrInjectedStat is returned by a Stat the OnStat hook chose to fail.
// It is not storage.ErrNotFound: the backend refused the request, as S3
// refuses a HEAD to credentials that allow only PUT.
var ErrInjectedStat = errors.New("faultstore: injected stat failure")
// PutAction is the disposition OnPut assigns to a write. // PutAction is the disposition OnPut assigns to a write.
type PutAction int type PutAction int
@@ -76,6 +83,10 @@ type Storer struct {
// object key. Returning true fails the delete and leaves the object // object key. Returning true fails the delete and leaves the object
// in place. // in place.
OnDelete func(key string) bool OnDelete func(key string) bool
// OnStat, when set, is consulted before every Stat with the object
// key. Returning true fails the stat with ErrInjectedStat.
OnStat func(key string) bool
} }
// New wraps inner. inner must be non-nil. // New wraps inner. inner must be non-nil.
@@ -159,8 +170,12 @@ func damage(fault GetFault, data []byte) []byte {
return out return out
} }
// Stat delegates unchanged. // Stat returns the object's info unless OnStat fails the stat.
func (f *Storer) Stat(ctx context.Context, key string) (*storage.ObjectInfo, error) { func (f *Storer) Stat(ctx context.Context, key string) (*storage.ObjectInfo, error) {
if f.OnStat != nil && f.OnStat(key) {
return nil, fmt.Errorf("%w for %q", ErrInjectedStat, key)
}
return f.inner.Stat(ctx, key) return f.inner.Stat(ctx, key)
} }
+21 -4
View File
@@ -21,9 +21,13 @@ type FileStorer struct {
// NewFileStorer creates a new filesystem storage backend. // NewFileStorer creates a new filesystem storage backend.
// //
// Construction is intentionally cheap and does not touch the filesystem. // Construction is intentionally cheap and does not touch the filesystem.
// The basePath is recorded; the directory is created lazily on first // The backend never creates basePath: the user does, so that when
// write. Write operations (Put/PutWithProgress) call MkdirAll for the // basePath is a directory on an unplugged volume, a write fails instead
// per-blob parent directory, which also covers basePath on first use. // of landing on the disk under the mount point. This does not hold when
// basePath is the mount point itself, which stays behind when its volume
// is unplugged.
// Put and PutWithProgress fail on a missing basePath and create the
// directories below it as needed.
// Get and Stat report a key under a missing basePath as ErrNotFound. // Get and Stat report a key under a missing basePath as ErrNotFound.
// List and ListStream fail on a missing basePath, because listing it as // List and ListStream fail on a missing basePath, because listing it as
// an empty store would make `prune` drop every local snapshot record. // an empty store would make `prune` drop every local snapshot record.
@@ -233,6 +237,7 @@ func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan Objec
ch <- ObjectInfo{ ch <- ObjectInfo{
Key: relPath, Key: relPath,
Size: info.Size(), Size: info.Size(),
ModTime: info.ModTime(),
} }
} }
@@ -299,7 +304,19 @@ func (f *FileStorer) writeAtomic(
path := f.fullPath(key) path := f.fullPath(key)
dir := filepath.Dir(path) dir := filepath.Dir(path)
err := f.fs.MkdirAll(dir, storageDirPerm) // Checked on every write, so a destination that disappears part-way
// through a backup fails it rather than being recreated by MkdirAll.
_, err := f.fs.Stat(f.basePath)
if os.IsNotExist(err) {
return fmt.Errorf("destination directory does not exist; "+
"create it, or mount the volume it is on: %w", err)
}
if err != nil {
return fmt.Errorf("checking destination directory: %w", err)
}
err = f.fs.MkdirAll(dir, storageDirPerm)
if err != nil { if err != nil {
return fmt.Errorf("creating directories: %w", err) return fmt.Errorf("creating directories: %w", err)
} }
+40
View File
@@ -292,3 +292,43 @@ func TestFileStorerDeletePartialUploadsThroughSymlinkedDirectory(t *testing.T) {
}) })
} }
} }
// TestFileStorerPutMissingDestination checks that a write fails when the
// destination directory has gone, even after earlier writes to it
// succeeded, and that it does not create the directory again.
func TestFileStorerPutMissingDestination(t *testing.T) {
t.Parallel()
ctx := context.Background()
base := filepath.Join(t.TempDir(), "usbstick")
err := os.Mkdir(base, 0o750)
if err != nil {
t.Fatalf("Mkdir: %v", err)
}
s, err := storage.NewFileStorer(base)
if err != nil {
t.Fatalf("NewFileStorer: %v", err)
}
err = s.Put(ctx, "blobs/aa/bb/first", strings.NewReader("first"))
if err != nil {
t.Fatalf("first Put: %v", err)
}
err = os.Rename(base, base+"-unplugged")
if err != nil {
t.Fatalf("Rename: %v", err)
}
err = s.Put(ctx, "blobs/aa/bb/second", strings.NewReader("second"))
if !errors.Is(err, fs.ErrNotExist) {
t.Errorf("Put = %v, want a not-exist error", err)
}
_, err = os.Stat(base)
if !errors.Is(err, fs.ErrNotExist) {
t.Errorf("Put created the destination directory again (Stat = %v)", err)
}
}
+20
View File
@@ -7,10 +7,12 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"os"
"strings" "strings"
"time" "time"
"github.com/rclone/rclone/fs" "github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/config"
"github.com/rclone/rclone/fs/config/configfile" "github.com/rclone/rclone/fs/config/configfile"
"github.com/rclone/rclone/fs/operations" "github.com/rclone/rclone/fs/operations"
@@ -33,6 +35,15 @@ type RcloneStorer struct {
// The remote parameter is the rclone remote name (as configured via `rclone config`). // The remote parameter is the rclone remote name (as configured via `rclone config`).
// The path parameter is the path within the remote. // The path parameter is the path within the remote.
func NewRcloneStorer(ctx context.Context, remote, path string) (*RcloneStorer, error) { func NewRcloneStorer(ctx context.Context, remote, path string) (*RcloneStorer, error) {
// rclone reads RCLONE_CONFIG only as the value of its --config flag,
// which vaultik does not have, so it is applied here as that flag would.
if configPath, ok := os.LookupEnv("RCLONE_CONFIG"); ok {
err := config.SetConfigPath(configPath)
if err != nil {
return nil, fmt.Errorf("using RCLONE_CONFIG %q: %w", configPath, err)
}
}
// Install the default config file handler // Install the default config file handler
configfile.Install() configfile.Install()
@@ -42,6 +53,14 @@ func NewRcloneStorer(ctx context.Context, remote, path string) (*RcloneStorer, e
rclonePath += path rclonePath += path
} }
// On S3, Swift and some other remotes rclone keeps a time of its own in
// each object's metadata, which costs one request per object to read.
// rclone's use_server_modtime setting makes ModTime the time the
// listing gives instead: when the object was stored, which is what
// prune compares with prune_grace_period.
ctx, ci := fs.AddConfig(ctx)
ci.UseServerModTime = true
// Create the rclone filesystem // Create the rclone filesystem
fsys, err := fs.NewFs(ctx, rclonePath) fsys, err := fs.NewFs(ctx, rclonePath)
if err != nil { if err != nil {
@@ -202,6 +221,7 @@ func (r *RcloneStorer) ListStream(
ch <- ObjectInfo{ ch <- ObjectInfo{
Key: key, Key: key,
Size: obj.Size(), Size: obj.Size(),
ModTime: obj.ModTime(ctx),
} }
} }
}) })
+114
View File
@@ -5,12 +5,19 @@ import (
"context" "context"
"errors" "errors"
"io" "io"
"net/http"
"net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync/atomic"
"testing" "testing"
"time"
"github.com/johannesboyne/gofakes3"
"github.com/johannesboyne/gofakes3/backend/s3mem"
"github.com/rclone/rclone/fs" "github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/config"
"github.com/rclone/rclone/fs/config/configmap" "github.com/rclone/rclone/fs/config/configmap"
"sneak.berlin/go/vaultik/internal/storage" "sneak.berlin/go/vaultik/internal/storage"
) )
@@ -390,3 +397,110 @@ func TestNewRcloneStorerUnknownRemote(t *testing.T) {
t.Errorf("NewRcloneStorer error = %v, want ErrRemoteNotFound", err) t.Errorf("NewRcloneStorer error = %v, want ErrRemoteNotFound", err)
} }
} }
// TestNewRcloneStorerReadsRcloneConfigEnv checks that a remote defined only
// in the file RCLONE_CONFIG names is found and written to, as rclone itself
// would.
func TestNewRcloneStorerReadsRcloneConfigEnv(t *testing.T) {
// rclone keeps the config file path in a process-global variable, which
// NewRcloneStorer leaves pointing at this test's file.
previous := config.GetConfigPath()
t.Cleanup(func() { _ = config.SetConfigPath(previous) })
confFile := filepath.Join(t.TempDir(), "rclone.conf")
err := os.WriteFile(confFile, []byte("[loc]\ntype = local\n"), 0o600)
if err != nil {
t.Fatalf("writing rclone config: %v", err)
}
t.Setenv("RCLONE_CONFIG", confFile)
dir := t.TempDir()
ctx := context.Background()
s, err := storage.NewRcloneStorer(ctx, "loc", dir)
if err != nil {
t.Fatalf("NewRcloneStorer: %v", err)
}
err = s.Put(ctx, testBlobKey, strings.NewReader("blob-bytes"))
if err != nil {
t.Fatalf("Put: %v", err)
}
_, err = os.Stat(filepath.Join(dir, testBlobKey))
if err != nil {
t.Errorf("object is not in the remote's directory: %v", err)
}
}
// TestRcloneListStreamOnS3MakesNoRequestPerObject checks that listing an
// rclone S3 remote takes each object's time from the listing itself. By
// default rclone reads the time it keeps in each object's metadata, one
// request per object, and every command that lists the destination store
// would slow down as it grows.
//
//nolint:paralleltest // NewRcloneStorer installs the process-global rclone config
func TestRcloneListStreamOnS3MakesNoRequestPerObject(t *testing.T) {
backend := s3mem.New()
err := backend.CreateBucket(s3TestBucket)
if err != nil {
t.Fatalf("create bucket: %v", err)
}
const body = "blob-bytes"
_, err = backend.PutObject(s3TestBucket, testBlobKey, nil,
strings.NewReader(body), int64(len(body)))
if err != nil {
t.Fatalf("seed object: %v", err)
}
var headRequests atomic.Int64
fakeS3 := gofakes3.New(backend).Server()
srv := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead {
headRequests.Add(1)
}
fakeS3.ServeHTTP(w, r)
}))
t.Cleanup(srv.Close)
remote := ":s3,provider=Other" +
",access_key_id=" + s3TestAccessKeyID +
",secret_access_key=" + s3TestSecretAccessKey +
",endpoint='" + srv.URL + "'"
ctx := context.Background()
s, err := storage.NewRcloneStorer(ctx, remote, s3TestBucket)
if err != nil {
t.Fatalf("NewRcloneStorer: %v", err)
}
headRequests.Store(0)
var listed []storage.ObjectInfo
for info := range s.ListStream(ctx, "blobs/") {
listed = append(listed, info)
}
if len(listed) != 1 || listed[0].Err != nil {
t.Fatalf("ListStream(\"blobs/\") = %v, want the one object", listed)
}
if time.Since(listed[0].ModTime).Abs() > time.Minute {
t.Errorf("ListStream ModTime = %v, want within a minute of now",
listed[0].ModTime)
}
if n := headRequests.Load(); n != 0 {
t.Errorf("ListStream made %d HEAD request(s), want none", n)
}
}
+1
View File
@@ -91,6 +91,7 @@ func (s *S3Storer) ListStream(ctx context.Context, prefix string) <-chan ObjectI
ch <- ObjectInfo{ ch <- ObjectInfo{
Key: info.Key, Key: info.Key,
Size: info.Size, Size: info.Size,
ModTime: info.ModTime,
Err: info.Err, Err: info.Err,
} }
} }
+4 -3
View File
@@ -100,8 +100,9 @@ func TestS3StorerMissingKeyMapsToErrNotFound(t *testing.T) {
// TestS3ListingCutShortIsAnError checks that List and ListStream fail on a // TestS3ListingCutShortIsAnError checks that List and ListStream fail on a
// listing page marked truncated that carries no continuation token, as a // listing page marked truncated that carries no continuation token, as a
// server answering V2 listings V1-style sends it. Taken as the last page, // server answering V2 listings V1-style sends it. Taken as the last page,
// it would hide every later key, and prune would treat the blobs of the // it would hide every later key: prune would treat the blobs of the
// snapshots listed there as unreferenced. // snapshots listed there as unreferenced, and a directory under metadata/
// whose manifest is listed there as one with no manifest.
func TestS3ListingCutShortIsAnError(t *testing.T) { func TestS3ListingCutShortIsAnError(t *testing.T) {
t.Parallel() t.Parallel()
@@ -160,7 +161,7 @@ func TestS3ListingCutShortIsAnError(t *testing.T) {
} }
// TestS3URLPrefixKeyLayout pins the bucket keys an s3:// URL reads and // TestS3URLPrefixKeyLayout pins the bucket keys an s3:// URL reads and
// writes: the README's remote storage layout, with the prefix joined to // writes: the layout in docs/REPOSTRUCTURE.md, with the prefix joined to
// each key by one "/". s3://b/p and s3://b/p/ must be the same // each key by one "/". s3://b/p and s3://b/p/ must be the same
// destination, or a host that writes the URL the other way finds no // destination, or a host that writes the URL the other way finds no
// snapshots. The listed object is put straight into the bucket, as // snapshots. The listed object is put straight into the bucket, as
+8 -2
View File
@@ -14,6 +14,7 @@ import (
"context" "context"
"errors" "errors"
"io" "io"
"time"
) )
// ErrNotFound is returned when an object does not exist. // ErrNotFound is returned when an object does not exist.
@@ -27,6 +28,9 @@ type ProgressCallback func(bytesTransferred int64) error
type ObjectInfo struct { type ObjectInfo struct {
Key string // Object key/path Key string // Object key/path
Size int64 // Size in bytes Size int64 // Size in bytes
// ModTime is when the object was written, as the store reports it.
// Only ListStream sets it; Stat leaves it zero.
ModTime time.Time
Err error // Error for streaming results (nil on success) Err error // Error for streaming results (nil on success)
} }
@@ -41,7 +45,8 @@ type Info struct {
// directory for filesystem). // directory for filesystem).
type Storer interface { type Storer interface {
// Put stores data at the specified key. // Put stores data at the specified key.
// Parent directories are created automatically for filesystem backends. // The filesystem backend fails when its destination directory does
// not exist, and creates the directories below it as needed.
Put(ctx context.Context, key string, data io.Reader) error Put(ctx context.Context, key string, data io.Reader) error
// PutWithProgress stores data with progress reporting. // PutWithProgress stores data with progress reporting.
@@ -66,7 +71,8 @@ type Storer interface {
// For large result sets, prefer ListStream. // For large result sets, prefer ListStream.
List(ctx context.Context, prefix string) ([]string, error) List(ctx context.Context, prefix string) ([]string, error)
// ListStream returns a channel of ObjectInfo for large result sets. // ListStream returns a channel of ObjectInfo, with each object's key,
// size and modification time, for large result sets.
// The channel is closed when listing completes. // The channel is closed when listing completes.
// If an error occurs during listing, the final item will have Err set. // If an error occurs during listing, the final item will have Err set.
ListStream(ctx context.Context, prefix string) <-chan ObjectInfo ListStream(ctx context.Context, prefix string) <-chan ObjectInfo
+47 -6
View File
@@ -30,6 +30,12 @@ var (
"storage URL must not carry credentials; " + "storage URL must not carry credentials; " +
"set s3.access_key_id and s3.secret_access_key in the config instead") "set s3.access_key_id and s3.secret_access_key in the config instead")
ErrURLUnknownParam = errors.New("unknown query parameter in storage URL") ErrURLUnknownParam = errors.New("unknown query parameter in storage URL")
ErrURLBadQuery = errors.New(
"storage URL query cannot be decoded; " +
"check it for a ; or a % not followed by two hex digits")
ErrRcloneRemoteParams = errors.New(
"rclone URL must not carry parameters in the remote name; " +
"set them on the remote with rclone config instead")
) )
// URL represents a parsed storage URL. // URL represents a parsed storage URL.
@@ -90,8 +96,9 @@ func parseFileURL(rawPath string) (*URL, error) {
} }
// parseS3URL parses an s3://bucket/prefix URL. It rejects credentials in // parseS3URL parses an s3://bucket/prefix URL. It rejects credentials in
// the userinfo and any query parameter other than endpoint, region and // the userinfo or in the endpoint parameter's userinfo, a query that
// ssl, so a credential-bearing URL is never stored or echoed. // cannot be decoded, and any query parameter other than endpoint, region
// and ssl, so a credential-bearing URL is never stored or echoed.
func parseS3URL(rawURL string) (*URL, error) { func parseS3URL(rawURL string) (*URL, error) {
u, err := url.Parse(rawURL) u, err := url.Parse(rawURL)
if err != nil { if err != nil {
@@ -107,13 +114,26 @@ func parseS3URL(rawURL string) (*URL, error) {
return nil, ErrMissingBucket return nil, ErrMissingBucket
} }
query := u.Query() // u.Query() would silently drop a part it cannot decode, endpoint
// included. The decode error is not wrapped: it can quote the value.
query, err := url.ParseQuery(u.RawQuery)
if err != nil {
return nil, ErrURLBadQuery
}
err = rejectUnknownParams(query, "endpoint", "region", "ssl") err = rejectUnknownParams(query, "endpoint", "region", "ssl")
if err != nil { if err != nil {
return nil, err return nil, err
} }
// A host name never contains "@", so one in an endpoint, with or
// without a scheme, can only introduce userinfo.
for _, endpoint := range query["endpoint"] {
if strings.Contains(endpoint, "@") {
return nil, ErrURLCredentials
}
}
return &URL{ return &URL{
Scheme: schemeS3, Scheme: schemeS3,
Bucket: bucket, Bucket: bucket,
@@ -125,9 +145,23 @@ func parseS3URL(rawURL string) (*URL, error) {
} }
// parseRcloneURL parses an rclone://remote/path URL. rclone:// takes no // parseRcloneURL parses an rclone://remote/path URL. rclone:// takes no
// query parameters, so credentials in the userinfo and any parameter at // query parameters, so credentials in the userinfo, parameters in the
// remote name, a query that cannot be decoded and any query parameter at
// all are rejected rather than silently ignored. // all are rejected rather than silently ignored.
func parseRcloneURL(rawURL string) (*URL, error) { func parseRcloneURL(rawURL string) (*URL, error) {
// The remote name runs up to the first "/", "?" or "#". It never
// contains a comma; one starts rclone's remote,param=value form,
// whose values can be credentials. This is checked before url.Parse,
// whose error for such a name can print part of it.
remote := strings.TrimPrefix(rawURL, "rclone://")
if end := strings.IndexAny(remote, "/?#"); end >= 0 {
remote = remote[:end]
}
if strings.Contains(remote, ",") {
return nil, ErrRcloneRemoteParams
}
u, err := url.Parse(rawURL) u, err := url.Parse(rawURL)
if err != nil { if err != nil {
return nil, wrapParseError(err) return nil, wrapParseError(err)
@@ -137,12 +171,19 @@ func parseRcloneURL(rawURL string) (*URL, error) {
return nil, ErrURLCredentials return nil, ErrURLCredentials
} }
remote := u.Host remote = u.Host
if remote == "" { if remote == "" {
return nil, ErrMissingRemote return nil, ErrMissingRemote
} }
err = rejectUnknownParams(u.Query()) // As in parseS3URL: u.Query() would drop a part it cannot decode, and
// the decode error can quote the value.
query, err := url.ParseQuery(u.RawQuery)
if err != nil {
return nil, ErrURLBadQuery
}
err = rejectUnknownParams(query)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+119
View File
@@ -150,6 +150,19 @@ func TestParseStorageURLRejectsCredentials(t *testing.T) {
wantErr: storage.ErrURLCredentials, wantErr: storage.ErrURLCredentials,
secrets: []string{key, secret, "mybucket"}, secrets: []string{key, secret, "mybucket"},
}, },
{
name: "s3 userinfo in endpoint",
raw: "s3://mybucket?endpoint=https://" +
key + ":" + secret + "@minio.example.com",
wantErr: storage.ErrURLCredentials,
secrets: []string{key, secret},
},
{
name: "s3 userinfo in endpoint without scheme",
raw: "s3://mybucket?endpoint=" + key + ":" + secret + "@minio.example.com",
wantErr: storage.ErrURLCredentials,
secrets: []string{key, secret},
},
{ {
name: "s3 unknown query param", name: "s3 unknown query param",
raw: "s3://mybucket?access_key=" + key + "&secret=" + secret, raw: "s3://mybucket?access_key=" + key + "&secret=" + secret,
@@ -203,6 +216,112 @@ func TestParseStorageURLRejectsCredentials(t *testing.T) {
} }
} }
// TestParseStorageURLRejectsRcloneRemoteParams checks that an rclone
// remote name carrying parameters (rclone's "remote,param=value" form,
// which can hold a credential) is rejected without echoing the value,
// including the URLs that url.Parse itself refuses.
func TestParseStorageURLRejectsRcloneRemoteParams(t *testing.T) {
t.Parallel()
cases := []struct {
name string
raw string
secrets []string // must not appear in the error message
}{
{
name: "configured remote",
raw: "rclone://gdrive,client_secret=topsecret/backups",
secrets: []string{"topsecret"},
},
{
name: "remote defined inline",
raw: "rclone://:s3,access_key_id=KEY,secret_access_key=SECRET/bucket",
secrets: []string{"KEY", "SECRET"},
},
{
name: "colon in a parameter value",
raw: "rclone://gdrive,client_secret=ab:cd/backups",
secrets: []string{"cd"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, err := storage.ParseStorageURL(tc.raw)
if !errors.Is(err, storage.ErrRcloneRemoteParams) {
t.Fatalf("ParseStorageURL(%q) error = %v, want %v",
tc.raw, err, storage.ErrRcloneRemoteParams)
}
for _, secret := range tc.secrets {
if strings.Contains(err.Error(), secret) {
t.Errorf("error message leaked %q: %v", secret, err.Error())
}
}
})
}
}
// TestParseStorageURLRejectsUndecodableQuery checks that an s3 or rclone
// URL whose query cannot be decoded is rejected, not read with that part
// of the query left out, and that the error does not echo it.
func TestParseStorageURLRejectsUndecodableQuery(t *testing.T) {
t.Parallel()
const (
key = "AKIAKEY"
secret = "topsecret"
)
cases := []struct {
name string
raw string
}{
{
name: "semicolon",
raw: "s3://mybucket/prefix?endpoint=https://" + key + ":" + secret +
"@minio.example.com:9000;region=us-east-1",
},
{
name: "bad percent escape",
raw: "s3://mybucket/prefix?endpoint=https://" + key + ":" + secret +
"%zz@minio.example.com:9000",
},
{
name: "rclone semicolon",
raw: "rclone://gdrive/backups?token=" + secret + ";x=1",
},
{
name: "rclone bad percent escape",
raw: "rclone://gdrive/backups?token=" + secret + "%zz",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, err := storage.ParseStorageURL(tc.raw)
if err == nil {
t.Fatalf("ParseStorageURL(%q) returned no error", tc.raw)
}
if !errors.Is(err, storage.ErrURLBadQuery) {
t.Errorf("ParseStorageURL(%q) error = %v, want %v",
tc.raw, err, storage.ErrURLBadQuery)
}
for _, s := range []string{key, secret, "minio.example.com"} {
if strings.Contains(err.Error(), s) {
t.Errorf("error message leaked %q: %v", s, err.Error())
}
}
})
}
}
// TestParseStorageURLParseFailureHidesURL checks that when url.Parse // TestParseStorageURLParseFailureHidesURL checks that when url.Parse
// itself fails, the wrapped error carries only the inner cause, not the // itself fails, the wrapped error carries only the inner cause, not the
// *url.Error whose text embeds the raw (possibly credential-bearing) URL. // *url.Error whose text embeds the raw (possibly credential-bearing) URL.
+34 -3
View File
@@ -22,6 +22,10 @@ var errBlobHashMismatch = errors.New("blob hash mismatch")
var errBlobNotFullyRead = errors.New( var errBlobNotFullyRead = errors.New(
"blob closed before fully read; hash not verified") "blob closed before fully read; hash not verified")
// errWrongPrivateKeyForBlob is returned when the configured key parses
// but is not one of the keys a blob was encrypted to.
var errWrongPrivateKeyForBlob = errors.New("cannot decrypt this blob")
// hashVerifyReader wraps a blobgen.Reader and verifies the double-SHA-256 hash // hashVerifyReader wraps a blobgen.Reader and verifies the double-SHA-256 hash
// of decrypted plaintext when Close is called. It reuses the hash that // of decrypted plaintext when Close is called. It reuses the hash that
// blobgen.Reader already computes internally via its TeeReader, avoiding // blobgen.Reader already computes internally via its TeeReader, avoiding
@@ -87,10 +91,14 @@ func (v *Vaultik) FetchAndDecryptBlob(
return nil, err return nil, err
} }
reader, err := blobgen.NewReader(rc, identities...) reader, err := v.newBlobReader(rc, identities)
if err != nil { if err != nil {
_ = rc.Close() _ = rc.Close()
if errors.Is(err, errWrongPrivateKeyForBlob) {
return nil, err
}
return nil, fmt.Errorf("creating blob reader: %w", err) return nil, fmt.Errorf("creating blob reader: %w", err)
} }
@@ -102,9 +110,32 @@ func (v *Vaultik) FetchAndDecryptBlob(
}, nil }, nil
} }
// newBlobReader opens the decrypting reader for a blob. When none of the
// identities can decrypt it, the error says in plain words that the
// configured private key is not one of the keys the blob was encrypted
// to and names where the key was configured.
func (v *Vaultik) newBlobReader(
r io.Reader, identities []age.Identity,
) (*blobgen.Reader, error) {
blobReader, err := blobgen.NewReader(r, identities...)
if _, ok := errors.AsType[*age.NoIdentityMatchError](err); ok {
return nil, fmt.Errorf(
"%w: the private key in %s is not one of the keys the blob was encrypted to",
errWrongPrivateKeyForBlob, v.Config.AgeSecretKeySourceName())
}
if err != nil {
return nil, err
}
return blobReader, nil
}
// FetchBlob downloads a blob and returns a reader for the encrypted data. // FetchBlob downloads a blob and returns a reader for the encrypted data.
// The reader is closed as soon as ctx is cancelled, which ends a read // The reader is closed as soon as ctx is cancelled, which ends a read
// blocked inside it. // blocked inside it. A storage error is returned as is: restore and deep
// verify name the blob in their own errors.
func (v *Vaultik) FetchBlob( func (v *Vaultik) FetchBlob(
ctx context.Context, blobHash string, ctx context.Context, blobHash string,
) (io.ReadCloser, error) { ) (io.ReadCloser, error) {
@@ -119,7 +150,7 @@ func (v *Vaultik) FetchBlob(
rc, err := v.Storage.Get(ctx, blobPath) rc, err := v.Storage.Get(ctx, blobPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("downloading blob %s: %w", shortHash(blobHash), err) return nil, err
} }
return newCloseOnCancelReader(ctx, rc), nil return newCloseOnCancelReader(ctx, rc), nil
+3 -3
View File
@@ -39,9 +39,9 @@ type blobDiskCacheEntry struct {
// internally and will fall back to LRU eviction if curBytes exceeds // internally and will fall back to LRU eviction if curBytes exceeds
// maxBytes. Restore passes math.MaxInt64 as maxBytes and drives // maxBytes. Restore passes math.MaxInt64 as maxBytes and drives
// eviction itself via Delete() through restoreSweeper, which deletes // eviction itself via Delete() through restoreSweeper, which deletes
// each blob the moment every file that references its chunks has been // each blob once no file still to be restored in this run needs it.
// written. LRU never fires under that configuration; it is kept as a // LRU never fires under that configuration; it is kept as a safety net
// safety net for callers that don't manage eviction themselves. // for callers that don't manage eviction themselves.
// //
// Get/ReadAt/peak-Len counters are debugging instrumentation used by // Get/ReadAt/peak-Len counters are debugging instrumentation used by
// tests to assert that the restore code path uses ReadAt rather than // tests to assert that the restore code path uses ReadAt rather than
@@ -137,7 +137,7 @@ func TestBackupAfterRemovingNewestSnapshotRestoresChangedFile(t *testing.T) {
fs := afero.NewOsFs() fs := afero.NewOsFs()
tempDir := t.TempDir() tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "src") dataDir := filepath.Join(tempDir, "src")
storeDir := filepath.Join(tempDir, "remote") storeDir := t.TempDir()
restoreDir := filepath.Join(tempDir, "restored") restoreDir := filepath.Join(tempDir, "restored")
dbPath := filepath.Join(tempDir, "index.sqlite") dbPath := filepath.Join(tempDir, "index.sqlite")
changedPath := filepath.Join(dataDir, "appended.bin") changedPath := filepath.Join(dataDir, "appended.bin")
@@ -184,7 +184,7 @@ func TestBackupAfterInterruptedRunRestoresChangedFile(t *testing.T) {
fs := afero.NewOsFs() fs := afero.NewOsFs()
tempDir := t.TempDir() tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "src") dataDir := filepath.Join(tempDir, "src")
storeDir := filepath.Join(tempDir, "remote") storeDir := t.TempDir()
restoreDir := filepath.Join(tempDir, "restored") restoreDir := filepath.Join(tempDir, "restored")
dbPath := filepath.Join(tempDir, "index.sqlite") dbPath := filepath.Join(tempDir, "index.sqlite")
changedPath := filepath.Join(dataDir, "appended.bin") changedPath := filepath.Join(dataDir, "appended.bin")
+13 -6
View File
@@ -6,6 +6,7 @@ import (
"crypto/rand" "crypto/rand"
"io" "io"
"os" "os"
"path"
"path/filepath" "path/filepath"
"testing" "testing"
@@ -34,7 +35,7 @@ func TestDeepVerifyAcceptsHealthyAndRejectsCorruptBlob(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "source") dataDir := filepath.Join(tempDir, "source")
storeDir := filepath.Join(tempDir, "remote") storeDir := t.TempDir()
dbPath := filepath.Join(tempDir, "index.sqlite") dbPath := filepath.Join(tempDir, "index.sqlite")
chunkSize := int64(64 * 1024) chunkSize := int64(64 * 1024)
@@ -93,7 +94,7 @@ func TestDeepVerifyJSONCountsBlobs(t *testing.T) {
fs := afero.NewOsFs() fs := afero.NewOsFs()
ctx := context.Background() ctx := context.Background()
storeDir := filepath.Join(t.TempDir(), "remote") storeDir := t.TempDir()
cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir) cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir)
@@ -142,14 +143,15 @@ func TestDeepVerifyJSONCountsBlobs(t *testing.T) {
// TestDeepVerifyJSONCountsWrongSizeBlob checks that under --deep --json a // TestDeepVerifyJSONCountsWrongSizeBlob checks that under --deep --json a
// blob of the wrong size is counted in mismatched and fails the run, and // blob of the wrong size is counted in mismatched and fails the run, and
// that the check goes on past it to count a missing blob after it. // that the check goes on past it to count a missing blob after it. The
// store is on disk, so the message must not call it S3.
func TestDeepVerifyJSONCountsWrongSizeBlob(t *testing.T) { func TestDeepVerifyJSONCountsWrongSizeBlob(t *testing.T) {
log.Initialize(log.Config{}) log.Initialize(log.Config{})
t.Parallel() t.Parallel()
fs := afero.NewOsFs() fs := afero.NewOsFs()
ctx := context.Background() ctx := context.Background()
storeDir := filepath.Join(t.TempDir(), "remote") storeDir := t.TempDir()
cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir) cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir)
@@ -168,17 +170,20 @@ func TestDeepVerifyJSONCountsWrongSizeBlob(t *testing.T) {
require.Equal(t, 1, got.Mismatched) require.Equal(t, 1, got.Mismatched)
require.Equal(t, 1, got.Missing) require.Equal(t, 1, got.Missing)
require.Zero(t, got.Verified) require.Zero(t, got.Verified)
require.Contains(t, got.ErrorMessage, "with the wrong size")
require.NotContains(t, got.ErrorMessage, "S3")
} }
// TestDeepVerifyStopsOnBlobThatCannotBeChecked checks that a blob that is // TestDeepVerifyStopsOnBlobThatCannotBeChecked checks that a blob that is
// present in storage but cannot be checked is not counted as missing: // present in storage but cannot be checked is not counted as missing:
// deep verify stops and fails with the storage error. // deep verify stops and fails, saying it could not check the blob, with
// the storage error.
func TestDeepVerifyStopsOnBlobThatCannotBeChecked(t *testing.T) { func TestDeepVerifyStopsOnBlobThatCannotBeChecked(t *testing.T) {
log.Initialize(log.Config{}) log.Initialize(log.Config{})
t.Parallel() t.Parallel()
ctx := context.Background() ctx := context.Background()
storeDir := filepath.Join(t.TempDir(), "remote") storeDir := t.TempDir()
cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir) cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir)
failing := statFailingStorer{Storer: storer, failKeys: []string{keys[0]}} failing := statFailingStorer{Storer: storer, failKeys: []string{keys[0]}}
@@ -187,6 +192,8 @@ func TestDeepVerifyStopsOnBlobThatCannotBeChecked(t *testing.T) {
require.Error(t, err) require.Error(t, err)
require.Equal(t, "failed", got.Status) require.Equal(t, "failed", got.Status)
require.Zero(t, got.Missing) require.Zero(t, got.Missing)
require.Contains(t, got.ErrorMessage,
"could not check blob "+path.Base(keys[0]))
require.Contains(t, got.ErrorMessage, errStatDenied.Error()) require.Contains(t, got.ErrorMessage, errStatDenied.Error())
} }
+111
View File
@@ -0,0 +1,111 @@
package vaultik
import (
"errors"
"fmt"
"strings"
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/storage"
)
// destinationMarkerKey is the object every backup writes at the root of
// the backup destination store. While the local index holds a snapshot
// stored there, a destination that answers a look-up of it with not
// found is treated as not present: an unplugged volume leaves an empty
// directory at its mount point, which would otherwise read as a store
// whose snapshots are all gone.
const destinationMarkerKey = "vaultik-destination.txt"
// destinationMarkerText is the whole content of the marker. It names no
// host, path or time.
const destinationMarkerText = "This is a vaultik backup destination store.\n"
// errDestinationNotPresent is returned when the local index holds a
// snapshot stored on the backup destination store and the store answers
// that it has no marker.
var errDestinationNotPresent = errors.New("backup destination store is not present")
// checkDestinationMarker returns errDestinationNotPresent when the local
// index holds a snapshot stored on the backup destination store and the
// store answers that it has no marker. With no snapshot in the local
// index, as on a first backup or on another machine, there is nothing to
// check; nor when the local index is bound to a different destination
// than the configured one, since its snapshots are stored there.
//
// A look-up of the marker that fails for any other reason than not
// found is logged and otherwise ignored, because the check could not be
// made: S3 refuses it to credentials that allow only PUT, which is all
// a backup needs. The check exists for a file:// volume's empty mount
// point, which always answers not found, and a destination that cannot
// be reached fails on the next request made to it, such as the backup's
// PUT of the marker.
func (v *Vaultik) checkDestinationMarker() error {
ids, err := v.localSnapshotIDs()
if err != nil {
return fmt.Errorf("listing local snapshots: %w", err)
}
if len(ids) == 0 {
return nil
}
// Config is nil only in tests, which then record no binding (see
// checkStorageBinding).
if v.Config != nil {
stored, configured, err := v.storageBinding()
if err != nil {
return err
}
if stored != "" && stored != configured {
return nil
}
}
_, err = v.Storage.Stat(v.ctx, destinationMarkerKey)
if errors.Is(err, storage.ErrNotFound) {
return fmt.Errorf("%w: %s has no %s, but the local index holds "+
"snapshots stored there; mount the volume if it is removable, "+
"or if the destination was emptied on purpose, run "+
"'vaultik database delete' and back up again",
errDestinationNotPresent, v.Storage.Info().Location,
destinationMarkerKey)
}
// Info, not a warning: --cron must stay silent on a successful
// backup from a host whose credentials allow only PUT.
if err != nil {
log.Info("Could not check the backup destination store for its marker",
"key", destinationMarkerKey, "error", err)
}
return nil
}
// markDestination refuses a local index bound to a different
// destination, checks the backup destination store for its marker,
// writes the marker, and then records the binding. A first backup marks
// its destination and a later one writes the same text again. The
// binding is recorded last, so a first backup that cannot write to the
// destination leaves the local index unbound.
func (v *Vaultik) markDestination() error {
err := v.checkStorageBinding()
if err != nil {
return err
}
err = v.checkDestinationMarker()
if err != nil {
return err
}
err = v.Storage.Put(v.ctx, destinationMarkerKey,
strings.NewReader(destinationMarkerText))
if err != nil {
return fmt.Errorf("writing %s to the backup destination store: %w",
destinationMarkerKey, err)
}
return v.EnsureStorageBinding()
}
+331
View File
@@ -0,0 +1,331 @@
package vaultik_test
import (
"bytes"
"context"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/storage"
"sneak.berlin/go/vaultik/internal/storage/faultstore"
"sneak.berlin/go/vaultik/internal/vaultik"
)
// These tests cover https://git.eeqj.de/sneak/vaultik/issues/239: a
// file:// destination at a volume's mount point leaves an empty directory
// behind when the volume is unplugged. While the local index holds
// snapshots, a destination that answers that it has no
// vaultik-destination.txt is not present, so no command may write to it
// or conclude from it that the local snapshots are gone.
// markerFile is the name of the file a backup writes at the root of the
// destination.
const markerFile = "vaultik-destination.txt"
// backUpThenUnplugMountPoint backs up to a file:// destination, then
// moves the destination directory away and leaves an empty directory in
// its place, as unplugging a volume mounted there would. It returns the
// path of that empty directory.
func backUpThenUnplugMountPoint(
ctx context.Context, t *testing.T,
) (*vaultik.Vaultik, *database.Repositories, *bytes.Buffer, string) {
t.Helper()
storeDir := filepath.Join(t.TempDir(), "mnt")
v, repos, out := backUpToFileDestination(ctx, t, storeDir)
require.NoError(t, os.Rename(storeDir, storeDir+"-unplugged"))
require.NoError(t, os.Mkdir(storeDir, 0o750))
return v, repos, out, storeDir
}
// assertUntouched checks that the local index still holds its one
// snapshot and that nothing was written to the empty mount point.
func assertUntouched(
ctx context.Context, t *testing.T,
repos *database.Repositories, storeDir string,
) {
t.Helper()
snapshots, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
require.NoError(t, err)
assert.Len(t, snapshots, 1, "no local snapshot record may be deleted")
entries, err := os.ReadDir(storeDir)
require.NoError(t, err)
assert.Empty(t, entries, "nothing may be written to the empty mount point")
}
// TestFirstBackupWritesMarker checks that a backup writes the marker
// file at the root of the destination.
func TestFirstBackupWritesMarker(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
storeDir := t.TempDir()
backUpToFileDestination(context.Background(), t, storeDir)
assert.FileExists(t, filepath.Join(storeDir, markerFile))
}
// TestBackupRefusesDestinationWithoutMarker checks that snapshot create
// --prune writes nothing and drops no local snapshot record when the
// destination has no marker.
func TestBackupRefusesDestinationWithoutMarker(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
v, repos, _, storeDir := backUpThenUnplugMountPoint(ctx, t)
err := v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
Cron: true,
Prune: true,
})
require.ErrorContains(t, err, markerFile)
assertUntouched(ctx, t, repos, storeDir)
}
// TestBackupGoesOnWhenMarkerCannotBeChecked checks that a backup goes on
// and writes the marker when looking it up fails with an error other
// than not found, as S3 answers credentials that allow only PUT
// (https://git.eeqj.de/sneak/vaultik/issues/389), and that a marker
// that is not found still stops it.
func TestBackupGoesOnWhenMarkerCannotBeChecked(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
storeDir := t.TempDir()
v, repos, _ := backUpToFileDestination(ctx, t, storeDir)
// Removed so the test sees the backup write it again: with the
// look-up failing, the backup cannot tell that it is gone.
markerPath := filepath.Join(storeDir, markerFile)
require.NoError(t, os.Remove(markerPath))
store := faultstore.New(v.Storage)
store.OnStat = func(key string) bool { return key == markerFile }
v.Storage = store
require.NoError(t, backUp(v, "second"))
localSnapshotID(ctx, t, repos, "second")
assert.FileExists(t, markerPath)
require.NoError(t, os.Remove(markerPath))
store.OnStat = nil
require.ErrorContains(t, backUp(v, "third"), markerFile)
}
// TestBackupChecksMarkerBeforeDroppingIncompleteSnapshots checks that
// snapshot create writes nothing when the local index holds only an
// incomplete snapshot and the destination has no marker. The backup drops
// incomplete snapshots before it scans; checked after that, the local
// index would hold none and nothing would be checked.
func TestBackupChecksMarkerBeforeDroppingIncompleteSnapshots(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
storeDir := t.TempDir()
v, repos := newFileDestinationVaultik(ctx, t, storeDir)
require.NoError(t, repos.Snapshots.Create(ctx, nil, &database.Snapshot{
ID: "host_first_2026-01-02T03:04:05Z",
Hostname: "host",
StartedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
}))
err := backUp(v, "first")
require.ErrorContains(t, err, markerFile)
assertUntouched(ctx, t, repos, storeDir)
}
// TestPruneRefusesDestinationWithoutMarker checks that prune fails and
// deletes no local snapshot record when the destination has no marker.
func TestPruneRefusesDestinationWithoutMarker(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
v, repos, _, storeDir := backUpThenUnplugMountPoint(ctx, t)
err := v.Prune(&vaultik.PruneOptions{Force: true})
require.ErrorContains(t, err, markerFile)
assertUntouched(ctx, t, repos, storeDir)
}
// TestPurgeRefusesDestinationWithoutMarker checks that snapshot purge
// fails and deletes no local snapshot record when the destination has no
// marker.
func TestPurgeRefusesDestinationWithoutMarker(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
v, repos, _, storeDir := backUpThenUnplugMountPoint(ctx, t)
err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
KeepLatest: true,
Force: true,
})
require.ErrorContains(t, err, markerFile)
assertUntouched(ctx, t, repos, storeDir)
}
// TestNukeRemoteRefusesDestinationWithoutMarker checks that remote nuke
// fails, deletes no local snapshot record, and neither reports the
// destination empty nor advises running nuke again when the destination
// has no marker.
func TestNukeRemoteRefusesDestinationWithoutMarker(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
v, repos, out, storeDir := backUpThenUnplugMountPoint(ctx, t)
err := v.NukeRemote(true)
require.ErrorContains(t, err, markerFile)
assert.NotContains(t, out.String(), "now empty")
assert.NotContains(t, out.String(), "vaultik remote nuke --force")
assertUntouched(ctx, t, repos, storeDir)
}
// TestRemoveSnapshotWarnsWhenDestinationHasNoMarker checks that snapshot
// remove warns that the metadata could not be removed from a destination
// without a marker, instead of reporting that it was.
func TestRemoveSnapshotWarnsWhenDestinationHasNoMarker(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
v, repos, out, _ := backUpThenUnplugMountPoint(ctx, t)
result, err := v.RemoveSnapshot(localSnapshotID(ctx, t, repos, "first"),
&vaultik.RemoveOptions{Force: true})
require.NoError(t, err)
assert.False(t, result.RemoteRemoved)
assert.Contains(t, out.String(),
"Could not remove snapshot metadata from remote")
assert.Contains(t, out.String(), markerFile)
assert.NotContains(t, out.String(),
"Removed snapshot metadata from remote storage")
}
// TestRemoveSnapshotOnlyOnDestinationFailsWithoutMarker checks that
// snapshot remove, for a snapshot that is only on the destination, fails
// saying the destination is not present when it has no marker, instead
// of saying the snapshot does not exist.
func TestRemoveSnapshotOnlyOnDestinationFailsWithoutMarker(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
storeDir := filepath.Join(t.TempDir(), "mnt")
v, repos, _ := backUpToFileDestination(ctx, t, storeDir)
require.NoError(t, backUp(v, "second"))
id := localSnapshotID(ctx, t, repos, "first")
_, err := v.RemoveSnapshot(id,
&vaultik.RemoveOptions{Force: true, LocalOnly: true})
require.NoError(t, err)
// Unplug the volume: an empty directory stays at its mount point.
require.NoError(t, os.Rename(storeDir, storeDir+"-unplugged"))
require.NoError(t, os.Mkdir(storeDir, 0o750))
_, err = v.RemoveSnapshot(id, &vaultik.RemoveOptions{Force: true})
require.ErrorContains(t, err, markerFile)
require.ErrorContains(t, err, "mount the volume")
assertUntouched(ctx, t, repos, storeDir)
}
// TestListSnapshotsWarnsWhenDestinationHasNoMarker checks that snapshot
// list warns and shows the local index alone when the destination has no
// marker, without reporting the local snapshot as missing from it.
func TestListSnapshotsWarnsWhenDestinationHasNoMarker(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
v, repos, out, _ := backUpThenUnplugMountPoint(ctx, t)
id := localSnapshotID(ctx, t, repos, "first")
require.NoError(t, v.ListSnapshots(false))
assert.Contains(t, out.String(), "Could not list backup destination store")
assert.Contains(t, out.String(), markerFile)
assert.Contains(t, out.String(), id)
assert.NotContains(t, out.String(), "not found in backup destination store")
}
// TestListSnapshotsAfterStorageURLChangeToEmptyDirectory checks that
// snapshot list, after storage_url is changed to an empty directory,
// warns only that the local index is bound to a different destination:
// the local snapshots are stored at the old one, so the new one's lack
// of a marker says nothing. With --json the local row is not on the new
// destination, so remote_present is false, not unknown.
func TestListSnapshotsAfterStorageURLChangeToEmptyDirectory(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
v, repos, out := backUpToFileDestination(ctx, t, t.TempDir())
id := localSnapshotID(ctx, t, repos, "first")
newDir := t.TempDir()
store, err := storage.NewFileStorer(newDir)
require.NoError(t, err)
v.Storage = store
v.Config.StorageURL = "file://" + newDir
require.NoError(t, v.ListSnapshots(false))
assert.Contains(t, out.String(), id)
assert.Contains(t, out.String(),
"local index is bound to a different backup destination")
assert.NotContains(t, out.String(), "Could not list backup destination store")
assert.NotContains(t, out.String(), markerFile)
out.Reset()
require.NoError(t, v.ListSnapshots(true))
rows := decodeListJSON(t, out.String())
require.Len(t, rows, 1)
require.NotNil(t, rows[0].RemotePresent)
assert.False(t, *rows[0].RemotePresent)
}
// TestBackupAfterNukeRemoteSucceeds checks that remote nuke leaves the
// destination ready for a new first backup, which writes the marker
// again.
func TestBackupAfterNukeRemoteSucceeds(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
storeDir := t.TempDir()
v, _, _ := backUpToFileDestination(context.Background(), t, storeDir)
require.NoError(t, v.NukeRemote(true))
assert.NoFileExists(t, filepath.Join(storeDir, markerFile))
require.NoError(t, backUp(v, "second"))
assert.FileExists(t, filepath.Join(storeDir, markerFile))
}
+19 -11
View File
@@ -192,7 +192,7 @@ func assertRestoreRejectsDamagedBlob(
fs := afero.NewOsFs() fs := afero.NewOsFs()
tempDir := t.TempDir() tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "src") dataDir := filepath.Join(tempDir, "src")
storeDir := filepath.Join(tempDir, "remote") storeDir := t.TempDir()
restoreDir := filepath.Join(tempDir, "restored") restoreDir := filepath.Join(tempDir, "restored")
dbPath := filepath.Join(tempDir, "index.sqlite") dbPath := filepath.Join(tempDir, "index.sqlite")
@@ -238,7 +238,7 @@ func TestDeepVerifyCatchesLyingBackend(t *testing.T) {
fs := afero.NewOsFs() fs := afero.NewOsFs()
tempDir := t.TempDir() tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "src") dataDir := filepath.Join(tempDir, "src")
storeDir := filepath.Join(tempDir, "remote") storeDir := t.TempDir()
dbPath := filepath.Join(tempDir, "index.sqlite") dbPath := filepath.Join(tempDir, "index.sqlite")
ctx := context.Background() ctx := context.Background()
@@ -290,7 +290,7 @@ func TestInterruptedBlobUploadRecordsNoUploadedBlob(t *testing.T) {
fs := afero.NewOsFs() fs := afero.NewOsFs()
tempDir := t.TempDir() tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "src") dataDir := filepath.Join(tempDir, "src")
storeDir := filepath.Join(tempDir, "remote") storeDir := t.TempDir()
dbPath := filepath.Join(tempDir, "index.sqlite") dbPath := filepath.Join(tempDir, "index.sqlite")
ctx := context.Background() ctx := context.Background()
@@ -298,10 +298,6 @@ func TestInterruptedBlobUploadRecordsNoUploadedBlob(t *testing.T) {
writeFaultSourceTree(t, fs, dataDir) writeFaultSourceTree(t, fs, dataDir)
// No upload succeeds, so nothing creates the destination directory.
// It must exist for the listing below to show that no blob survived.
require.NoError(t, os.Mkdir(storeDir, 0o750))
inner, err := storage.NewFileStorer(storeDir) inner, err := storage.NewFileStorer(storeDir)
require.NoError(t, err) require.NoError(t, err)
@@ -365,7 +361,7 @@ func TestBackupRetryAfterInterruptedUploadIsRestorable(t *testing.T) {
fs := afero.NewOsFs() fs := afero.NewOsFs()
tempDir := t.TempDir() tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "src") dataDir := filepath.Join(tempDir, "src")
storeDir := filepath.Join(tempDir, "remote") storeDir := t.TempDir()
restoreDir := filepath.Join(tempDir, "restored") restoreDir := filepath.Join(tempDir, "restored")
dbPath := filepath.Join(tempDir, "index.sqlite") dbPath := filepath.Join(tempDir, "index.sqlite")
@@ -429,7 +425,7 @@ func TestBackupSurvivesMetadataExportInterruption(t *testing.T) {
fs := afero.NewOsFs() fs := afero.NewOsFs()
tempDir := t.TempDir() tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "src") dataDir := filepath.Join(tempDir, "src")
storeDir := filepath.Join(tempDir, "remote") storeDir := t.TempDir()
dbPath := filepath.Join(tempDir, "index.sqlite") dbPath := filepath.Join(tempDir, "index.sqlite")
ctx := context.Background() ctx := context.Background()
@@ -445,6 +441,10 @@ func TestBackupSurvivesMetadataExportInterruption(t *testing.T) {
repos := database.NewRepositories(db) repos := database.NewRepositories(db)
// snapshot create writes the marker; this backup runs its steps one
// by one, so the marker is written here.
require.NoError(t, inner.Put(ctx, markerFile, strings.NewReader("")))
// Back up and complete with a working backend. // Back up and complete with a working backend.
sm := newFaultSnapshotManager(fs, inner, cfg, repos) sm := newFaultSnapshotManager(fs, inner, cfg, repos)
scanner := newFaultScanner(fs, inner, cfg, repos) scanner := newFaultScanner(fs, inner, cfg, repos)
@@ -493,8 +493,14 @@ func TestBackupSurvivesMetadataExportInterruption(t *testing.T) {
"shallow verify must report the missing manifest") "shallow verify must report the missing manifest")
// snapshot list must not crash on the partial snapshot. // snapshot list must not crash on the partial snapshot.
var listOut strings.Builder
reader.UI = ui.NewWithColor(&listOut, false)
require.NoError(t, reader.ListSnapshots(false), require.NoError(t, reader.ListSnapshots(false),
"snapshot list must tolerate a partially-exported snapshot") "snapshot list must tolerate a partially-exported snapshot")
assert.NotContains(t, listOut.String(),
"Could not list backup destination store",
"snapshot list must read the partial snapshot from the destination")
} }
// Scenario 2, repair: the process dies during the metadata export of a // Scenario 2, repair: the process dies during the metadata export of a
@@ -511,7 +517,7 @@ func TestBackupCompletesOnlyAfterMetadataExport(t *testing.T) {
fs := afero.NewOsFs() fs := afero.NewOsFs()
tempDir := t.TempDir() tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "src") dataDir := filepath.Join(tempDir, "src")
storeDir := filepath.Join(tempDir, "remote") storeDir := t.TempDir()
restoreDir := filepath.Join(tempDir, "restored") restoreDir := filepath.Join(tempDir, "restored")
dbPath := filepath.Join(tempDir, "index.sqlite") dbPath := filepath.Join(tempDir, "index.sqlite")
@@ -672,6 +678,8 @@ func faultScannerFactory(
UI: params.UI, UI: params.UI,
Exclude: params.Exclude, Exclude: params.Exclude,
SkipErrors: params.SkipErrors, SkipErrors: params.SkipErrors,
PreviousSnapshotID: params.PreviousSnapshotID,
DeviceNumber: params.DeviceNumber,
}) })
} }
} }
@@ -686,7 +694,7 @@ func TestRestoreReportsDiskFull(t *testing.T) {
osFS := afero.NewOsFs() osFS := afero.NewOsFs()
tempDir := t.TempDir() tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "src") dataDir := filepath.Join(tempDir, "src")
storeDir := filepath.Join(tempDir, "remote") storeDir := t.TempDir()
restoreDir := filepath.Join(tempDir, "restored") restoreDir := filepath.Join(tempDir, "restored")
dbPath := filepath.Join(tempDir, "index.sqlite") dbPath := filepath.Join(tempDir, "index.sqlite")
+7 -7
View File
@@ -246,7 +246,7 @@ func (v *Vaultik) RemoteInfo(jsonOutput bool) error {
v.stdoutf("Type: %s\n", storageInfo.Type) v.stdoutf("Type: %s\n", storageInfo.Type)
v.stdoutf("Location: %s\n", storageInfo.Location) v.stdoutf("Location: %s\n", storageInfo.Location)
v.printlnStdout() v.printlnStdout()
v.stdoutf("Scanning snapshot metadata...\n") v.UI.Beginf("Scanning snapshot metadata...")
} }
snapshotMetadata, snapshotIDs, skippedManifestCount, err := v.collectSnapshotMetadata() snapshotMetadata, snapshotIDs, skippedManifestCount, err := v.collectSnapshotMetadata()
@@ -265,7 +265,7 @@ func (v *Vaultik) RemoteInfo(jsonOutput bool) error {
} }
} }
v.stdoutf("Downloading %d manifest(s)...\n", manifestCount) v.UI.Beginf("Downloading %d manifest(s)...", manifestCount)
} }
referencedBlobs, unreadableManifests := v.collectReferencedBlobsFromManifests( referencedBlobs, unreadableManifests := v.collectReferencedBlobsFromManifests(
@@ -447,7 +447,7 @@ func (v *Vaultik) scanRemoteBlobStorage(
result *RemoteInfoResult, referencedBlobs map[string]int64, showText bool, result *RemoteInfoResult, referencedBlobs map[string]int64, showText bool,
) error { ) error {
if showText { if showText {
v.stdoutf("Scanning blobs...\n") v.UI.Beginf("Scanning blobs...")
} }
// Use prune's listing, so a file under blobs/ that prune would never // Use prune's listing, so a file under blobs/ that prune would never
@@ -457,9 +457,9 @@ func (v *Vaultik) scanRemoteBlobStorage(
return err return err
} }
for _, size := range allBlobs { for _, blob := range allBlobs {
result.TotalBlobCount++ result.TotalBlobCount++
result.TotalBlobSize += size result.TotalBlobSize += blob.Size
} }
// A blob named only by a manifest that could not be read, or by one // A blob named only by a manifest that could not be read, or by one
@@ -474,10 +474,10 @@ func (v *Vaultik) scanRemoteBlobStorage(
orphanedSize int64 orphanedSize int64
) )
for hash, size := range allBlobs { for hash, blob := range allBlobs {
if _, referenced := referencedBlobs[hash]; !referenced { if _, referenced := referencedBlobs[hash]; !referenced {
orphanedCount++ orphanedCount++
orphanedSize += size orphanedSize += blob.Size
} }
} }
+2 -2
View File
@@ -771,7 +771,7 @@ func TestEndToEndFileStorage(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "source") dataDir := filepath.Join(tempDir, "source")
storeDir := filepath.Join(tempDir, "remote") storeDir := t.TempDir()
restoreDir := filepath.Join(tempDir, "restored") restoreDir := filepath.Join(tempDir, "restored")
dbPath := filepath.Join(tempDir, "index.sqlite") dbPath := filepath.Join(tempDir, "index.sqlite")
@@ -964,7 +964,7 @@ func TestDedupOnlySnapshotRestores(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "source") dataDir := filepath.Join(tempDir, "source")
storeDir := filepath.Join(tempDir, "remote") storeDir := t.TempDir()
restoreDir := filepath.Join(tempDir, "restored") restoreDir := filepath.Join(tempDir, "restored")
dbPath := filepath.Join(tempDir, "index.sqlite") dbPath := filepath.Join(tempDir, "index.sqlite")
+148 -19
View File
@@ -7,6 +7,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"testing" "testing"
"github.com/spf13/afero" "github.com/spf13/afero"
@@ -24,21 +25,28 @@ import (
// that is not plugged in, cannot be listed. It is not an empty store, so // that is not plugged in, cannot be listed. It is not an empty store, so
// no command may conclude from it that the local snapshots are gone. // no command may conclude from it that the local snapshots are gone.
// backUpToFileDestination backs up the snapshot named "first" to a // newFileDestinationVaultik writes the fault source tree and returns a
// file:// destination at storeDir, which need not exist yet. Everything // Vaultik that backs it up to a file:// destination at storeDir.
// the returned Vaultik prints after the backup goes to the returned func newFileDestinationVaultik(
// buffer.
func backUpToFileDestination(
ctx context.Context, t *testing.T, storeDir string, ctx context.Context, t *testing.T, storeDir string,
) (*vaultik.Vaultik, *database.Repositories, *bytes.Buffer) { ) (*vaultik.Vaultik, *database.Repositories) {
t.Helper() t.Helper()
osFs := afero.NewOsFs()
tempDir := t.TempDir() tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "src") dataDir := filepath.Join(tempDir, "src")
dbPath := filepath.Join(tempDir, "index.sqlite") writeFaultSourceTree(t, afero.NewOsFs(), dataDir)
writeFaultSourceTree(t, osFs, dataDir) return openFileDestinationVaultik(ctx, t, storeDir, dataDir,
filepath.Join(tempDir, "index.sqlite"))
}
// openFileDestinationVaultik returns a Vaultik that backs up dataDir to a
// file:// destination at storeDir, with its local index at dbPath. The
// index is closed when the test ends.
func openFileDestinationVaultik(
ctx context.Context, t *testing.T, storeDir, dataDir, dbPath string,
) (*vaultik.Vaultik, *database.Repositories) {
t.Helper()
store, err := storage.NewFileStorer(storeDir) store, err := storage.NewFileStorer(storeDir)
require.NoError(t, err) require.NoError(t, err)
@@ -49,9 +57,85 @@ func backUpToFileDestination(
repos := database.NewRepositories(db) repos := database.NewRepositories(db)
cfg := changedFileConfig(dataDir, dbPath) cfg := changedFileConfig(dataDir, dbPath)
v := newBackupVaultik(ctx, cfg, store, repos, db, osFs)
return newBackupVaultik(ctx, cfg, store, repos, db, afero.NewOsFs()), repos
}
// firstBackup is one backup of the snapshot named "first" to a file://
// destination. dir holds its source tree in src, its store in store and
// its closed local index in index/index.sqlite. The first test that
// needs it makes it, and TestMain removes it. Every test that copies it
// backs up the same source tree, so no test may change that tree.
//
//nolint:gochecknoglobals // made once and shared by the tests that copy it
var firstBackup struct {
once sync.Once
dir string
ready bool
}
// TestMain removes firstBackup once every test has run.
func TestMain(m *testing.M) {
code := m.Run()
_ = os.RemoveAll(firstBackup.dir)
os.Exit(code)
}
// makeFirstBackup writes the fault source tree and backs it up as the
// snapshot named "first", all in a new directory that it records in
// firstBackup.
func makeFirstBackup(ctx context.Context, t *testing.T) {
t.Helper()
dir, err := os.MkdirTemp("", "vaultik-first-backup-")
require.NoError(t, err)
firstBackup.dir = dir
storeDir := filepath.Join(dir, "store")
dataDir := filepath.Join(dir, "src")
indexDir := filepath.Join(dir, "index")
require.NoError(t, os.Mkdir(storeDir, 0o750))
require.NoError(t, os.Mkdir(indexDir, 0o750))
writeFaultSourceTree(t, afero.NewOsFs(), dataDir)
v, _ := openFileDestinationVaultik(ctx, t, storeDir, dataDir,
filepath.Join(indexDir, "index.sqlite"))
require.NoError(t, backUp(v, "first")) require.NoError(t, backUp(v, "first"))
require.NoError(t, v.DB.Close())
firstBackup.ready = true
}
// backUpToFileDestination creates the directory storeDir, as a user must
// before the first backup, and returns a Vaultik whose file://
// destination there holds a backup of the snapshot named "first". The
// store and the local index are copies of firstBackup's, which is faster
// than backing up in every test. The returned Vaultik backs up a source
// tree shared with every other caller, so no test may change that tree.
// Everything the returned Vaultik prints after the backup goes to the
// returned buffer.
func backUpToFileDestination(
ctx context.Context, t *testing.T, storeDir string,
) (*vaultik.Vaultik, *database.Repositories, *bytes.Buffer) {
t.Helper()
firstBackup.once.Do(func() { makeFirstBackup(ctx, t) })
require.True(t, firstBackup.ready, "another test failed to make firstBackup")
require.NoError(t, os.MkdirAll(storeDir, 0o750))
require.NoError(t, os.CopyFS(storeDir,
os.DirFS(filepath.Join(firstBackup.dir, "store"))))
indexDir := t.TempDir()
require.NoError(t, os.CopyFS(indexDir,
os.DirFS(filepath.Join(firstBackup.dir, "index"))))
v, repos := openFileDestinationVaultik(ctx, t, storeDir,
filepath.Join(firstBackup.dir, "src"),
filepath.Join(indexDir, "index.sqlite"))
out := &bytes.Buffer{} out := &bytes.Buffer{}
v.Stdout = out v.Stdout = out
@@ -75,21 +159,28 @@ func backUpThenUnplug(
return v, repos, out return v, repos, out
} }
// TestFirstBackupCreatesDestinationDirectory checks that a first backup // TestBackupFailsWhenDestinationDirectoryMissing checks that a backup to
// to a destination directory that does not exist yet creates it, and // a file:// destination whose directory does not exist fails before it
// that the destination can be listed afterwards. // records a snapshot, and does not create the directory.
func TestFirstBackupCreatesDestinationDirectory(t *testing.T) { func TestBackupFailsWhenDestinationDirectoryMissing(t *testing.T) {
log.Initialize(log.Config{}) log.Initialize(log.Config{})
t.Parallel() t.Parallel()
ctx := context.Background() ctx := context.Background()
storeDir := filepath.Join(t.TempDir(), "volume", "backup") volume := filepath.Join(t.TempDir(), "volume")
v, _, out := backUpToFileDestination(ctx, t, storeDir) v, repos := newFileDestinationVaultik(ctx, t, filepath.Join(volume, "backup"))
require.NoError(t, v.ListSnapshots(false)) err := backUp(v, "first")
require.ErrorIs(t, err, fs.ErrNotExist)
require.ErrorContains(t, err, "create it, or mount the volume it is on")
assert.NotContains(t, out.String(), "Could not list backup destination store") _, err = os.Stat(volume)
assert.NotContains(t, out.String(), "not found in backup destination store") require.ErrorIs(t, err, fs.ErrNotExist,
"the backup must not create the destination directory")
snapshots, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
require.NoError(t, err)
assert.Empty(t, snapshots)
} }
// TestListSnapshotsWarnsWhenDestinationMissing checks that snapshot list // TestListSnapshotsWarnsWhenDestinationMissing checks that snapshot list
@@ -169,3 +260,41 @@ func TestPurgeSaysListingFailedOnceWhenDestinationMissing(t *testing.T) {
assert.Equal(t, 1, strings.Count(err.Error(), "listing remote snapshots"), assert.Equal(t, 1, strings.Count(err.Error(), "listing remote snapshots"),
err.Error()) err.Error())
} }
// TestNukeRemoteKeepsLocalIndexWhenDestinationMissing checks that remote
// nuke fails on a destination it cannot list, leaves the local index
// unchanged, and neither reports the destination empty nor advises
// running nuke again.
func TestNukeRemoteKeepsLocalIndexWhenDestinationMissing(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
v, repos, out := backUpThenUnplug(ctx, t)
filesBefore, err := repos.Files.ListAll(ctx)
require.NoError(t, err)
require.NotEmpty(t, filesBefore)
blobsBefore, err := repos.Blobs.GetAll(ctx)
require.NoError(t, err)
require.NotEmpty(t, blobsBefore)
err = v.NukeRemote(true)
require.ErrorIs(t, err, fs.ErrNotExist)
require.ErrorContains(t, err, "listing remote snapshots")
assert.NotContains(t, out.String(), "now empty")
assert.NotContains(t, out.String(), "vaultik remote nuke --force")
snapshots, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
require.NoError(t, err)
assert.Len(t, snapshots, 1, "nuke must delete no local snapshot record")
files, err := repos.Files.ListAll(ctx)
require.NoError(t, err)
assert.Len(t, files, len(filesBefore), "nuke must delete no local file record")
blobs, err := repos.Blobs.GetAll(ctx)
require.NoError(t, err)
assert.Len(t, blobs, len(blobsBefore), "nuke must delete no local blob record")
}
+1 -1
View File
@@ -39,7 +39,7 @@ func TestNewChunkSizeOfChangedFileStaysFixed(t *testing.T) {
appendRandomBytes(t, fs, files, changedPath, appendedFileSize) appendRandomBytes(t, fs, files, changedPath, appendedFileSize)
store, err := storage.NewFileStorer(filepath.Join(tempDir, "remote")) store, err := storage.NewFileStorer(t.TempDir())
require.NoError(t, err) require.NoError(t, err)
db, err := database.New(ctx, dbPath) db, err := database.New(ctx, dbPath)
+28 -3
View File
@@ -76,7 +76,7 @@ func TestNukeRemoteLeavesNoFiles(t *testing.T) {
// TestNukeRemoteFailsWhenADeleteFails checks that remote nuke returns an // TestNukeRemoteFailsWhenADeleteFails checks that remote nuke returns an
// error when a snapshot's metadata or a blob cannot be deleted, does not // error when a snapshot's metadata or a blob cannot be deleted, does not
// report the store empty, and advises running nuke again: prune deletes // report the store empty, and advises running nuke again: prune deletes
// no metadata. // no snapshot metadata that has its manifest.
func TestNukeRemoteFailsWhenADeleteFails(t *testing.T) { func TestNukeRemoteFailsWhenADeleteFails(t *testing.T) {
log.Initialize(log.Config{}) log.Initialize(log.Config{})
t.Parallel() t.Parallel()
@@ -104,6 +104,32 @@ func TestNukeRemoteFailsWhenADeleteFails(t *testing.T) {
} }
} }
// TestNukeRemoteKeepsLocalSnapshotWhoseMetadataIsNotDeleted checks that
// remote nuke keeps a snapshot in the local index while its metadata
// cannot be deleted from the destination store: dropped from the local
// index, it would be left there as a remote-only snapshot without its
// name.
func TestNukeRemoteKeepsLocalSnapshotWhoseMetadataIsNotDeleted(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
storeDir := filepath.Join(t.TempDir(), "store")
v, repos, _ := backUpToFileDestination(ctx, t, storeDir)
store := faultstore.New(v.Storage)
store.OnDelete = func(key string) bool {
return strings.HasPrefix(key, "metadata/")
}
v.Storage = store
require.Error(t, v.NukeRemote(true))
snapshots, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
require.NoError(t, err)
assert.Len(t, snapshots, 1)
}
// TestNukeRemoteNamesWhatIsLeft checks that remote nuke, when an object // TestNukeRemoteNamesWhatIsLeft checks that remote nuke, when an object
// it does not recognise cannot be deleted, does not report the store // it does not recognise cannot be deleted, does not report the store
// empty but names that object and fails. // empty but names that object and fails.
@@ -154,8 +180,7 @@ func TestNukeRemoteRefusesAnotherDestinationWithoutRetryAdvice(t *testing.T) {
// TestNukeRemoteFailsWhenALocalSnapshotCannotBeRemoved checks that a // TestNukeRemoteFailsWhenALocalSnapshotCannotBeRemoved checks that a
// snapshot remote nuke cannot remove from the local index fails the // snapshot remote nuke cannot remove from the local index fails the
// run: nuke then leaves that snapshot's metadata on the destination // run, and that nuke then does not report the destination store empty.
// store.
func TestNukeRemoteFailsWhenALocalSnapshotCannotBeRemoved(t *testing.T) { func TestNukeRemoteFailsWhenALocalSnapshotCannotBeRemoved(t *testing.T) {
log.Initialize(log.Config{}) log.Initialize(log.Config{})
t.Parallel() t.Parallel()
+324 -78
View File
@@ -4,9 +4,13 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"sort"
"strings" "strings"
"time"
"sneak.berlin/go/vaultik/internal/log" "sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/snapshot"
"sneak.berlin/go/vaultik/internal/storage"
) )
// PruneOptions contains options for the prune command // PruneOptions contains options for the prune command
@@ -22,6 +26,11 @@ var errNukeRequiresForce = errors.New(
// errBlobDeleteFailed reports unreferenced blobs prune could not delete. // errBlobDeleteFailed reports unreferenced blobs prune could not delete.
var errBlobDeleteFailed = errors.New("blob(s) could not be deleted") var errBlobDeleteFailed = errors.New("blob(s) could not be deleted")
// errMetadataDirDeleteFailed reports directories under metadata/ with no
// manifest that prune could not delete.
var errMetadataDirDeleteFailed = errors.New(
"metadata director(ies) with no manifest could not be deleted")
// errObjectsLeft reports objects remote nuke still finds on the backup // errObjectsLeft reports objects remote nuke still finds on the backup
// destination store after deleting. // destination store after deleting.
var errObjectsLeft = errors.New( var errObjectsLeft = errors.New(
@@ -29,7 +38,8 @@ var errObjectsLeft = errors.New(
// nukeCommandHint is the command suggested when a remote nuke could not // nukeCommandHint is the command suggested when a remote nuke could not
// delete everything: running it again deletes what is left, which // delete everything: running it again deletes what is left, which
// `vaultik prune` does not do for snapshot metadata. // `vaultik prune` does not do for snapshot metadata that has its
// manifest.
const nukeCommandHint = "vaultik remote nuke --force" const nukeCommandHint = "vaultik remote nuke --force"
// metadataDirName is the top-level remote directory holding snapshot // metadataDirName is the top-level remote directory holding snapshot
@@ -42,9 +52,10 @@ const blobKeyParts = 4
// NukeRemote deletes every object under metadata/ and blobs/ in remote // NukeRemote deletes every object under metadata/ and blobs/ in remote
// storage, whatever its name, along with any object an upload cut off // storage, whatever its name, along with any object an upload cut off
// part-way left under a temporary `.partial` name. It returns // part-way left under a temporary `.partial` name, and last the
// successfully only when a listing afterwards finds nothing left there, // destination marker. It returns successfully only when a listing
// and the next backup then starts from scratch. // afterwards finds nothing left under metadata/ and blobs/, and the next
// backup then starts from scratch.
// //
// Refuses to run unless force is true. The caller is responsible for // Refuses to run unless force is true. The caller is responsible for
// confirming with the user. // confirming with the user.
@@ -54,8 +65,15 @@ func (v *Vaultik) NukeRemote(force bool) error {
} }
// Checked before anything is deleted: running nuke again would fail // Checked before anything is deleted: running nuke again would fail
// the same way, so the advice below must not follow this error. // the same way, so the advice below must not follow these errors.
err := v.EnsureStorageBinding() // The listing also fails on a destination that answers that it has
// no marker while the local index holds snapshots.
err := v.checkStorageBinding()
if err != nil {
return err
}
_, err = v.listAllRemoteSnapshotKeys()
if err != nil { if err != nil {
return err return err
} }
@@ -105,6 +123,13 @@ func (v *Vaultik) emptyDestinationStore() error {
return fmt.Errorf("%d %w", len(left), errObjectsLeft) return fmt.Errorf("%d %w", len(left), errObjectsLeft)
} }
// Last, once the local snapshot records are gone: the next backup
// then has none and writes the marker again.
err = v.Storage.Delete(v.ctx, destinationMarkerKey)
if err != nil {
return fmt.Errorf("deleting %s: %w", destinationMarkerKey, err)
}
return nil return nil
} }
@@ -148,16 +173,26 @@ type PruneBlobsResult struct {
BlobsDeleted int `json:"blobs_deleted"` BlobsDeleted int `json:"blobs_deleted"`
BlobsFailed int `json:"blobs_failed,omitempty"` BlobsFailed int `json:"blobs_failed,omitempty"`
BytesFreed int64 `json:"bytes_freed"` BytesFreed int64 `json:"bytes_freed"`
// BlobsTooNew counts the unreferenced blobs kept because they are
// newer than the prune grace period, or of unknown age. BlobsFound
// does not include them.
BlobsTooNew int `json:"blobs_too_new"`
// MetadataDirsDeleted counts the directories under metadata/ with no
// manifest that prune deleted, and MetadataDirsFailed those it could
// not delete.
MetadataDirsDeleted int `json:"metadata_dirs_deleted"`
MetadataDirsFailed int `json:"metadata_dirs_failed,omitempty"`
} }
// Prune removes orphaned data from the local index database AND // Prune removes orphaned data from the local index database AND, from the
// unreferenced blobs from the backup destination store. This is the // backup destination store, unreferenced blobs and the directories under
// single user-facing prune entry point — the split between local and // metadata/ with no manifest. This is the single user-facing prune entry
// remote cleanup is an implementation detail. Calling code should // point — the split between local and remote cleanup is an
// prefer this method over PruneDatabase or PruneBlobs individually // implementation detail. Calling code should prefer this method over
// unless it specifically wants one half. // PruneDatabase or PruneBlobs individually unless it specifically wants
// one half.
func (v *Vaultik) Prune(opts *PruneOptions) error { func (v *Vaultik) Prune(opts *PruneOptions) error {
err := v.EnsureStorageBinding() err := v.checkStorageBinding()
if err != nil { if err != nil {
return err return err
} }
@@ -179,11 +214,17 @@ func (v *Vaultik) Prune(opts *PruneOptions) error {
return v.PruneBlobs(opts) return v.PruneBlobs(opts)
} }
// PruneBlobs removes unreferenced blobs from storage // PruneBlobs removes unreferenced blobs from storage, and each directory
// under metadata/ that a backup left with no manifest.
func (v *Vaultik) PruneBlobs(opts *PruneOptions) error { func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
log.Info("Starting prune operation") log.Info("Starting prune operation")
allBlobsReferenced, err := v.collectReferencedBlobs() metadataObjects, err := v.listMetadataObjects()
if err != nil {
return err
}
allBlobsReferenced, err := v.collectReferencedBlobs(metadataObjects)
if err != nil { if err != nil {
return err return err
} }
@@ -193,50 +234,44 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
return err return err
} }
unreferencedBlobs, totalSize := v.findUnreferencedBlobs(allBlobs, allBlobsReferenced) unreferencedBlobs, totalSize, tooNew := v.findUnreferencedBlobs(
allBlobs, allBlobsReferenced)
result := &PruneBlobsResult{BlobsFound: len(unreferencedBlobs)} dirsWithoutManifest, err := v.findMetadataDirsWithoutManifest(metadataObjects)
if err != nil {
return err
}
if len(unreferencedBlobs) == 0 { result := &PruneBlobsResult{
BlobsFound: len(unreferencedBlobs),
BlobsTooNew: tooNew,
}
v.reportBlobsTooNew(tooNew, opts)
if len(unreferencedBlobs) == 0 && len(dirsWithoutManifest) == 0 {
log.Info("No unreferenced blobs found") log.Info("No unreferenced blobs found")
if opts.JSON { if opts.JSON {
return v.outputPruneBlobsJSON(result) return v.outputPruneBlobsJSON(result)
} }
v.printlnStdout("No unreferenced blobs to remove.") v.UI.Infof("No unreferenced blobs to remove.")
return nil return nil
} }
log.Info("Found unreferenced blobs", log.Info("Found unreferenced blobs",
"count", len(unreferencedBlobs), "total_size", ubytes(totalSize)) "count", len(unreferencedBlobs), "total_size", ubytes(totalSize),
"metadata_dirs_without_manifest", len(dirsWithoutManifest))
if !opts.JSON { if !v.confirmPrune(opts, len(unreferencedBlobs), totalSize,
v.stdoutf("Found %d unreferenced blob(s) totaling %s\n", dirsWithoutManifest) {
len(unreferencedBlobs), ubytes(totalSize)) return ErrNotConfirmed
}
if !opts.Force && !opts.JSON {
v.stdoutf("\nDelete %d unreferenced blob(s)? [y/N] ", len(unreferencedBlobs))
var confirm string
_, err = v.scanStdin(&confirm)
if err != nil {
v.printlnStdout("Cancelled")
return nil //nolint:nilerr // read failure means no confirmation
}
if strings.ToLower(confirm) != "y" {
v.printlnStdout("Cancelled")
return nil
}
} }
v.deleteUnreferencedBlobs(unreferencedBlobs, allBlobs, result) v.deleteUnreferencedBlobs(unreferencedBlobs, allBlobs, result)
v.deleteMetadataDirs(dirsWithoutManifest, result)
if opts.JSON { if opts.JSON {
err = v.outputPruneBlobsJSON(result) err = v.outputPruneBlobsJSON(result)
@@ -244,33 +279,142 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
return err return err
} }
} else { } else {
v.stdoutf("\nDeleted %d blob(s) totaling %s\n", v.reportPruneDeletions(result, len(dirsWithoutManifest))
}
var failures []error
if result.BlobsFailed > 0 {
failures = append(failures,
fmt.Errorf("%d %w", result.BlobsFailed, errBlobDeleteFailed))
}
if result.MetadataDirsFailed > 0 {
failures = append(failures, fmt.Errorf("%d %w",
result.MetadataDirsFailed, errMetadataDirDeleteFailed))
}
return errors.Join(failures...)
}
// confirmPrune states what prune found to delete, unreferenced blobs and
// directories under metadata/ with no manifest, naming each directory by
// its remote key, and, unless --force is given, asks the operator to
// confirm. Under --json it prints nothing and does not ask. It reports
// whether to go ahead.
func (v *Vaultik) confirmPrune(
opts *PruneOptions, blobCount int, blobsSize int64, dirs []string,
) bool {
if opts.JSON {
return true
}
dirCount := len(dirs)
blobsFound := fmt.Sprintf("Found %d unreferenced blob(s) totaling %s.",
blobCount, ubytes(blobsSize))
dirsFound := fmt.Sprintf("Found %d metadata director(ies) left by "+
"backups that stopped before their manifest:", dirCount)
if opts.Force {
if blobCount > 0 {
v.UI.Infof("%s", blobsFound)
}
if dirCount > 0 {
v.UI.Infof("%s", dirsFound)
for _, remoteKey := range dirs {
v.UI.Detailf("metadata/%s/", remoteKey)
}
}
return true
}
// The counts, the names, the prompt and its answer bypass the UI:
// they are an interactive exchange the operator must see, so --quiet
// does not silence them.
if blobCount > 0 {
v.stdoutf("%s\n", blobsFound)
}
if dirCount > 0 {
v.stdoutf("%s\n", dirsFound)
for _, remoteKey := range dirs {
v.stdoutf(" metadata/%s/\n", remoteKey)
}
}
switch {
case dirCount == 0:
v.stdoutf("\nDelete %d unreferenced blob(s)? [y/N] ", blobCount)
case blobCount == 0:
v.stdoutf("\nDelete %d metadata director(ies)? [y/N] ", dirCount)
default:
v.stdoutf("\nDelete %d unreferenced blob(s) and %d metadata "+
"director(ies)? [y/N] ", blobCount, dirCount)
}
var confirm string
// A failure to read the answer means no confirmation.
_, err := v.scanStdin(&confirm)
if err != nil || strings.ToLower(confirm) != "y" {
v.printlnStdout("Cancelled")
return false
}
return true
}
// reportPruneDeletions says how many blobs and metadata directories
// prune deleted, naming each kind only when it found some to delete.
func (v *Vaultik) reportPruneDeletions(result *PruneBlobsResult, dirCount int) {
if result.BlobsFound > 0 {
v.UI.Infof("Deleted %d blob(s) totaling %s.",
result.BlobsDeleted, ubytes(result.BytesFreed)) result.BlobsDeleted, ubytes(result.BytesFreed))
} }
if result.BlobsFailed > 0 { if dirCount > 0 {
return fmt.Errorf("%d %w", result.BlobsFailed, errBlobDeleteFailed) v.UI.Infof("Deleted %d metadata director(ies) with no manifest.",
result.MetadataDirsDeleted)
}
} }
return nil // listMetadataObjects lists every object under metadata/ on the
// destination store.
func (v *Vaultik) listMetadataObjects() ([]storage.ObjectInfo, error) {
log.Info("Listing remote snapshots")
var objects []storage.ObjectInfo
for object := range v.Storage.ListStream(v.ctx, "metadata/") {
if object.Err != nil {
return nil, fmt.Errorf("listing metadata objects: %w", object.Err)
} }
// collectReferencedBlobs downloads all manifests and returns the set of objects = append(objects, object)
// referenced blob hashes. }
return objects, nil
}
// collectReferencedBlobs downloads every manifest among the listed
// metadata objects and returns the set of referenced blob hashes.
// //
// Every manifest must be read successfully. A manifest that cannot be // Every manifest must be read successfully. A manifest that cannot be
// downloaded or decoded means its snapshot's blobs are unknown, so // downloaded or decoded means its snapshot's blobs are unknown, so
// treating them as unreferenced would let prune delete data a snapshot // treating them as unreferenced would let prune delete data a snapshot
// still needs. Rather than risk that silent loss, any failure returns an // still needs. Rather than risk that silent loss, any failure returns an
// error naming the remote key and prune deletes nothing. // error naming the remote key and prune deletes nothing.
func (v *Vaultik) collectReferencedBlobs() (map[string]bool, error) { func (v *Vaultik) collectReferencedBlobs(
log.Info("Listing remote snapshots") metadataObjects []storage.ObjectInfo,
// IDs returned by listUniqueSnapshotIDs are remote keys (hashed ) (map[string]bool, error) {
// IDs returned by uniqueSnapshotIDs are remote keys (hashed
// subdirectories under metadata/), not human snapshot IDs. // subdirectories under metadata/), not human snapshot IDs.
remoteKeys, err := v.listUniqueSnapshotIDs() remoteKeys := uniqueSnapshotIDs(metadataObjects)
if err != nil {
return nil, fmt.Errorf("listing snapshot keys: %w", err)
}
log.Info("Found manifests in remote storage", "count", len(remoteKeys)) log.Info("Found manifests in remote storage", "count", len(remoteKeys))
@@ -295,18 +439,14 @@ func (v *Vaultik) collectReferencedBlobs() (map[string]bool, error) {
return allBlobsReferenced, nil return allBlobsReferenced, nil
} }
// listUniqueSnapshotIDs returns deduplicated snapshot IDs from remote metadata // uniqueSnapshotIDs returns deduplicated snapshot IDs from the listed
func (v *Vaultik) listUniqueSnapshotIDs() ([]string, error) { // metadata objects.
objectCh := v.Storage.ListStream(v.ctx, "metadata/") func uniqueSnapshotIDs(metadataObjects []storage.ObjectInfo) []string {
seen := make(map[string]bool) seen := make(map[string]bool)
var snapshotIDs []string var snapshotIDs []string
for object := range objectCh { for _, object := range metadataObjects {
if object.Err != nil {
return nil, fmt.Errorf("listing metadata objects: %w", object.Err)
}
parts := strings.Split(object.Key, "/") parts := strings.Split(object.Key, "/")
if len(parts) >= minSnapshotIDParts && if len(parts) >= minSnapshotIDParts &&
parts[0] == metadataDirName && parts[1] != "" { parts[0] == metadataDirName && parts[1] != "" {
@@ -321,14 +461,15 @@ func (v *Vaultik) listUniqueSnapshotIDs() ([]string, error) {
} }
} }
return snapshotIDs, nil return snapshotIDs
} }
// listAllRemoteBlobs returns a map of all blob hashes to their sizes in remote storage // listAllRemoteBlobs returns a map of all blob hashes in remote storage
func (v *Vaultik) listAllRemoteBlobs() (map[string]int64, error) { // to their listing entries.
func (v *Vaultik) listAllRemoteBlobs() (map[string]storage.ObjectInfo, error) {
log.Info("Listing all blobs in storage") log.Info("Listing all blobs in storage")
allBlobs := make(map[string]int64) allBlobs := make(map[string]storage.ObjectInfo)
blobObjectCh := v.Storage.ListStream(v.ctx, "blobs/") blobObjectCh := v.Storage.ListStream(v.ctx, "blobs/")
for object := range blobObjectCh { for object := range blobObjectCh {
@@ -352,7 +493,7 @@ func (v *Vaultik) listAllRemoteBlobs() (map[string]int64, error) {
continue continue
} }
allBlobs[parts[3]] = object.Size allBlobs[parts[3]] = object
} }
log.Info("Found blobs in storage", "count", len(allBlobs)) log.Info("Found blobs in storage", "count", len(allBlobs))
@@ -360,30 +501,135 @@ func (v *Vaultik) listAllRemoteBlobs() (map[string]int64, error) {
return allBlobs, nil return allBlobs, nil
} }
// findUnreferencedBlobs returns blob hashes not referenced by any // findUnreferencedBlobs returns the hashes of the blobs no manifest
// manifest and their total size. // references that are older than the prune grace period, and their total
// size. It also returns how many unreferenced blobs it left out for
// being newer than that, or of unknown age because the listing gave no
// time for them.
func (v *Vaultik) findUnreferencedBlobs( func (v *Vaultik) findUnreferencedBlobs(
allBlobs map[string]int64, referenced map[string]bool, allBlobs map[string]storage.ObjectInfo, referenced map[string]bool,
) ([]string, int64) { ) ([]string, int64, int) {
var ( var (
unreferenced []string unreferenced []string
totalSize int64 totalSize int64
tooNew int
) )
for hash, size := range allBlobs { cutoff := time.Now().UTC().Add(-v.Config.PruneGracePeriod)
if !referenced[hash] {
for hash, blob := range allBlobs {
if referenced[hash] {
continue
}
if blob.ModTime.IsZero() || blob.ModTime.After(cutoff) {
tooNew++
continue
}
unreferenced = append(unreferenced, hash) unreferenced = append(unreferenced, hash)
totalSize += size totalSize += blob.Size
}
return unreferenced, totalSize, tooNew
}
// findMetadataDirsWithoutManifest returns the remote key of each
// directory under metadata/ that has no manifest.json.zst, such as one a
// backup leaves when its manifest upload fails after db.zst.age was
// uploaded. It leaves out a directory a local snapshot record claims,
// and one holding an object newer than the prune grace period, or of
// unknown age because the listing gave no time for it: a backup still
// running may be writing there. A name that is not a remote key is
// never returned.
func (v *Vaultik) findMetadataDirsWithoutManifest(
metadataObjects []storage.ObjectInfo,
) ([]string, error) {
localIDs, err := v.localSnapshotIDs()
if err != nil {
return nil, fmt.Errorf("listing local snapshots: %w", err)
}
keep := make(map[string]bool, len(localIDs))
for _, id := range localIDs {
keep[snapshot.RemoteSnapshotKey(id)] = true
}
cutoff := time.Now().UTC().Add(-v.Config.PruneGracePeriod)
listed := make(map[string]bool)
for _, object := range metadataObjects {
parts := strings.Split(object.Key, "/")
if len(parts) < metadataKeyParts || !isBlobHash(parts[1]) {
continue
}
remoteKey := parts[1]
listed[remoteKey] = true
if parts[2] == "manifest.json.zst" ||
object.ModTime.IsZero() || object.ModTime.After(cutoff) {
keep[remoteKey] = true
} }
} }
return unreferenced, totalSize var remoteKeys []string
for remoteKey := range listed {
if !keep[remoteKey] {
remoteKeys = append(remoteKeys, remoteKey)
}
}
sort.Strings(remoteKeys)
return remoteKeys, nil
}
// deleteMetadataDirs deletes everything in the given directories under
// metadata/ and counts in the result the directories deleted and those
// it could not delete.
func (v *Vaultik) deleteMetadataDirs(
remoteKeys []string, result *PruneBlobsResult,
) {
for _, remoteKey := range remoteKeys {
_, err := v.deleteRemoteSnapshotByKey(remoteKey)
if err != nil {
log.Error("Failed to delete metadata directory with no manifest",
"remote_key", remoteKey, "error", err)
result.MetadataDirsFailed++
continue
}
log.Info("Deleted metadata directory with no manifest",
"remote_key", remoteKey)
result.MetadataDirsDeleted++
}
}
// reportBlobsTooNew says how many unreferenced blobs prune kept for being
// newer than the grace period or of unknown age. Under --json the
// document carries the count instead.
func (v *Vaultik) reportBlobsTooNew(tooNew int, opts *PruneOptions) {
if tooNew == 0 || opts.JSON {
return
}
v.UI.Infof("Kept %d unreferenced blob(s) newer than "+
"prune_grace_period (%s), or of unknown age: a backup still "+
"running may need them.",
tooNew, v.Config.PruneGracePeriod)
} }
// deleteUnreferencedBlobs deletes the given blobs from storage and // deleteUnreferencedBlobs deletes the given blobs from storage and
// populates the result. // populates the result.
func (v *Vaultik) deleteUnreferencedBlobs( func (v *Vaultik) deleteUnreferencedBlobs(
unreferencedBlobs []string, allBlobs map[string]int64, result *PruneBlobsResult, unreferencedBlobs []string, allBlobs map[string]storage.ObjectInfo,
result *PruneBlobsResult,
) { ) {
log.Info("Deleting unreferenced blobs") log.Info("Deleting unreferenced blobs")
@@ -398,7 +644,7 @@ func (v *Vaultik) deleteUnreferencedBlobs(
} }
result.BlobsDeleted++ result.BlobsDeleted++
result.BytesFreed += allBlobs[hash] result.BytesFreed += allBlobs[hash].Size
if (i+1)%progressLogEvery == 0 || i == len(unreferencedBlobs)-1 { if (i+1)%progressLogEvery == 0 || i == len(unreferencedBlobs)-1 {
log.Info("Deletion progress", log.Info("Deletion progress",
+60
View File
@@ -1,13 +1,17 @@
package vaultik //nolint:testpackage // exercises unexported count helpers package vaultik //nolint:testpackage // exercises unexported count helpers
import ( import (
"bytes"
"context" "context"
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/database" "sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/log" "sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/snapshot"
"sneak.berlin/go/vaultik/internal/ui"
) )
// TestTableCountForReportSurfacesReadFailure is the regression guard for // TestTableCountForReportSurfacesReadFailure is the regression guard for
@@ -76,3 +80,59 @@ func TestCountDiffUnknownWhenEitherSideUnknown(t *testing.T) {
assert.Nil(t, countDiff(&before, nil), "unknown after yields unknown delta") assert.Nil(t, countDiff(&before, nil), "unknown after yields unknown delta")
assert.Nil(t, countDiff(nil, nil)) assert.Nil(t, countDiff(nil, nil))
} }
// TestPruneDatabaseSummaryLabelsTotals removes the only incomplete
// snapshot from an index that also holds a complete one. The figure in
// brackets is every snapshot left in the local index, so the line must
// say so rather than read as incomplete snapshots that remain.
func TestPruneDatabaseSummaryLabelsTotals(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
db, err := database.New(ctx, ":memory:")
require.NoError(t, err)
t.Cleanup(func() { _ = db.Close() })
repos := database.NewRepositories(db)
startedAt := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
completedAt := startedAt.Add(time.Minute)
require.NoError(t, repos.Snapshots.Create(ctx, nil, &database.Snapshot{
ID: "host_complete_2026-01-02T03:04:05Z",
Hostname: "host",
StartedAt: startedAt,
CompletedAt: &completedAt,
}))
require.NoError(t, repos.Snapshots.Create(ctx, nil, &database.Snapshot{
ID: "host_incomplete_2026-01-02T03:04:05Z",
Hostname: "host",
StartedAt: startedAt,
}))
var out bytes.Buffer
v := &Vaultik{
DB: db,
Repositories: repos,
SnapshotManager: snapshot.NewSnapshotManager(
snapshot.SnapshotManagerParams{Repos: repos}),
UI: ui.NewWithColor(&out, false),
}
v.SetContext(ctx)
result, err := v.PruneDatabase()
require.NoError(t, err)
require.Equal(t, int64(1), result.SnapshotsDeleted)
assert.Contains(t, out.String(), "Incomplete snapshots: 1 removed "+
"(total snapshots left in the local index: 1).")
assert.Contains(t, out.String(), "Orphaned files: 0 removed "+
"(total files left in the local index: 0).")
assert.Contains(t, out.String(), "Orphaned chunks: 0 removed "+
"(total chunks left in the local index: 0).")
assert.Contains(t, out.String(), "Orphaned blobs: 0 removed "+
"(total blobs left in the local index: 0).")
}
+168
View File
@@ -0,0 +1,168 @@
package vaultik_test
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/config"
"sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/storage"
"sneak.berlin/go/vaultik/internal/storage/faultstore"
"sneak.berlin/go/vaultik/internal/ui"
"sneak.berlin/go/vaultik/internal/vaultik"
)
// TestPruneDuringBackupKeepsItsBlobs runs a prune from another host
// sharing the destination store at the moment a backup has uploaded its
// blobs and its db.zst.age but not yet its manifest, so no manifest lists
// those blobs. The prune must leave the blobs and the db.zst.age, and the
// backup must then restore.
func TestPruneDuringBackupKeepsItsBlobs(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
fs := afero.NewOsFs()
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "src")
storeDir := t.TempDir()
restoreDir := filepath.Join(tempDir, "restored")
dbPath := filepath.Join(tempDir, "index.sqlite")
ctx := context.Background()
cfg := faultTestConfig()
testFiles := writeFaultSourceTree(t, fs, dataDir)
inner, err := storage.NewFileStorer(storeDir)
require.NoError(t, err)
otherHostCfg := faultTestConfig()
otherHostCfg.PruneGracePeriod = time.Hour
otherHost := newReaderVaultik(ctx, otherHostCfg, inner, nil, fs)
var (
pruned bool
pruneErr error
)
store := faultstore.New(inner)
store.OnPut = func(key string) faultstore.PutAction {
if strings.HasSuffix(key, "/manifest.json.zst") {
pruned = true
pruneErr = otherHost.PruneBlobs(&vaultik.PruneOptions{Force: true})
}
return faultstore.PutNormal
}
db, err := database.New(ctx, dbPath)
require.NoError(t, err)
repos := database.NewRepositories(db)
id := fullFaultBackup(ctx, t, fs, store, cfg, repos, dataDir, dbPath, "shared")
require.NoError(t, db.Close())
require.True(t, pruned, "the prune must run before the manifest upload")
require.NoError(t, pruneErr)
v := newReaderVaultik(ctx, cfg, inner, nil, fs)
require.NoError(t, v.Restore(&vaultik.RestoreOptions{
SnapshotID: id,
TargetDir: restoreDir,
Verify: true,
}), "a prune during the backup must not delete the backup's blobs "+
"or its db.zst.age")
assertRestoredTree(t, fs, restoreDir, testFiles)
}
// TestPruneKeepsUnreferencedBlobWithNoTime checks that a blob no
// manifest lists, whose listing gives no modification time (as an S3
// listing entry without LastModified does), is kept and counted with
// the blobs too new to delete: its age is unknown.
func TestPruneKeepsUnreferencedBlobWithNoTime(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
env := newListEnv(t)
// Written into the mock store's map directly rather than through
// Put, so its listing gives no time.
blobKey := "blobs/" + testBlobHashA[:2] + "/" + testBlobHashA[2:4] +
"/" + testBlobHashA
env.store.data[blobKey] = []byte("blob")
require.NoError(t, env.v.PruneBlobs(&vaultik.PruneOptions{JSON: true}))
assert.True(t, env.store.hasKey(blobKey),
"a blob of unknown age must be kept")
var result vaultik.PruneBlobsResult
require.NoError(t, json.Unmarshal(env.stdout.Bytes(), &result))
assert.Equal(t, 0, result.BlobsDeleted)
assert.Equal(t, 1, result.BlobsTooNew)
}
// TestPruneDeletesUnreferencedBlobOlderThanGracePeriod checks that the
// grace period only delays deletion: a blob no manifest lists is deleted
// once it is older than the grace period, and kept and counted until
// then.
func TestPruneDeletesUnreferencedBlobOlderThanGracePeriod(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
storeDir := t.TempDir()
store, err := storage.NewFileStorer(storeDir)
require.NoError(t, err)
oldKey := "blobs/" + testBlobHashA[:2] + "/" + testBlobHashA[2:4] +
"/" + testBlobHashA
newKey := "blobs/" + testBlobHashB[:2] + "/" + testBlobHashB[2:4] +
"/" + testBlobHashB
for _, key := range []string{oldKey, newKey} {
require.NoError(t, store.Put(ctx, key, strings.NewReader("blob")))
}
gracePeriod := time.Hour
beforeGracePeriod := time.Now().Add(-gracePeriod - time.Minute)
require.NoError(t, os.Chtimes(filepath.Join(storeDir, oldKey),
beforeGracePeriod, beforeGracePeriod))
stdout := &bytes.Buffer{}
v := &vaultik.Vaultik{
Config: &config.Config{PruneGracePeriod: gracePeriod},
Storage: store,
Stdout: stdout,
UI: ui.NewWithColor(stdout, false),
}
v.SetContext(ctx)
require.NoError(t, v.PruneBlobs(&vaultik.PruneOptions{JSON: true}))
_, err = store.Stat(ctx, oldKey)
require.ErrorIs(t, err, storage.ErrNotFound,
"a blob older than the grace period must be deleted")
_, err = store.Stat(ctx, newKey)
require.NoError(t, err, "a blob newer than the grace period must be kept")
var result vaultik.PruneBlobsResult
require.NoError(t, json.Unmarshal(stdout.Bytes(), &result))
assert.Equal(t, 1, result.BlobsDeleted)
assert.Equal(t, 1, result.BlobsTooNew)
}
+226
View File
@@ -0,0 +1,226 @@
package vaultik_test
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/config"
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/snapshot"
"sneak.berlin/go/vaultik/internal/storage"
"sneak.berlin/go/vaultik/internal/storage/faultstore"
"sneak.berlin/go/vaultik/internal/ui"
"sneak.berlin/go/vaultik/internal/vaultik"
)
// metadataObjectKey returns the key of the named file in the metadata
// directory of the snapshot with the given human ID.
func metadataObjectKey(snapshotID, name string) string {
return "metadata/" + snapshot.RemoteSnapshotKey(snapshotID) + "/" + name
}
// TestPruneDeletesMetadataDirWithoutManifestOlderThanGracePeriod checks
// that prune deletes a directory under metadata/ holding only
// db.zst.age, as a backup leaves when its manifest upload fails, once it
// is older than the grace period, and keeps one newer than that. A
// directory with a manifest is never deleted, however old.
func TestPruneDeletesMetadataDirWithoutManifestOlderThanGracePeriod(
t *testing.T,
) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
storeDir := t.TempDir()
store, err := storage.NewFileStorer(storeDir)
require.NoError(t, err)
const (
oldID = "testhost_home_2026-01-01T00:00:00Z"
newID = "testhost_home_2026-01-02T00:00:00Z"
completeID = "testhost_home_2026-01-03T00:00:00Z"
)
oldDB := metadataObjectKey(oldID, "db.zst.age")
newDB := metadataObjectKey(newID, "db.zst.age")
completeDB := metadataObjectKey(completeID, "db.zst.age")
completeManifest := metadataObjectKey(completeID, "manifest.json.zst")
manifest, err := snapshot.EncodeManifest(&snapshot.Manifest{
SnapshotID: snapshot.RemoteSnapshotKey(completeID),
}, 3)
require.NoError(t, err)
for _, key := range []string{oldDB, newDB, completeDB} {
require.NoError(t, store.Put(ctx, key, strings.NewReader("db")))
}
require.NoError(t, store.Put(ctx, completeManifest,
bytes.NewReader(manifest)))
gracePeriod := time.Hour
beforeGracePeriod := time.Now().Add(-gracePeriod - time.Minute)
for _, key := range []string{oldDB, completeDB, completeManifest} {
require.NoError(t, os.Chtimes(filepath.Join(storeDir, key),
beforeGracePeriod, beforeGracePeriod))
}
stdout := &bytes.Buffer{}
v := &vaultik.Vaultik{
Config: &config.Config{PruneGracePeriod: gracePeriod},
Storage: store,
Stdout: stdout,
UI: ui.NewWithColor(stdout, false),
}
v.SetContext(ctx)
require.NoError(t, v.PruneBlobs(&vaultik.PruneOptions{JSON: true}))
_, err = store.Stat(ctx, oldDB)
require.ErrorIs(t, err, storage.ErrNotFound,
"a directory with no manifest older than the grace period "+
"must be deleted")
for _, key := range []string{newDB, completeDB, completeManifest} {
_, err = store.Stat(ctx, key)
require.NoError(t, err, "%s must be kept", key)
}
var result vaultik.PruneBlobsResult
require.NoError(t, json.Unmarshal(stdout.Bytes(), &result))
assert.Equal(t, 1, result.MetadataDirsDeleted)
}
// TestPruneKeepsMetadataDirClaimedUnknownAgeOrNotRemoteKey checks that
// prune --force keeps a directory under metadata/ with no manifest when a
// local snapshot record claims it, when the listing gives no time for an
// object in it, or when its name is not a remote key. A fourth such
// directory is named, deleted and reported, although there are no
// unreferenced blobs to delete.
func TestPruneKeepsMetadataDirClaimedUnknownAgeOrNotRemoteKey(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
env := newListEnv(t)
const (
claimedID = "testhost_home_2026-01-01T00:00:00Z"
leftID = "testhost_home_2026-01-03T00:00:00Z"
)
env.addLocal(t, claimedID, time.Now().Add(-time.Hour))
claimedDB := metadataObjectKey(claimedID, "db.zst.age")
unknownAgeDB := metadataObjectKey("testhost_home_2026-01-02T00:00:00Z",
"db.zst.age")
leftDB := metadataObjectKey(leftID, "db.zst.age")
notRemoteKeyDB := "metadata/not-a-remote-key/db.zst.age"
for _, key := range []string{claimedDB, leftDB, notRemoteKeyDB} {
require.NoError(t, env.store.Put(ctx, key, strings.NewReader("db")))
}
// Written into the mock store's map directly rather than through
// Put, so its listing gives no time.
env.store.data[unknownAgeDB] = []byte("db")
require.NoError(t, env.v.PruneBlobs(&vaultik.PruneOptions{Force: true}))
assert.True(t, env.store.hasKey(claimedDB),
"a directory a local snapshot record claims must be kept")
assert.True(t, env.store.hasKey(unknownAgeDB),
"a directory holding an object of unknown age must be kept")
assert.True(t, env.store.hasKey(notRemoteKeyDB),
"a directory whose name is not a remote key must be kept")
assert.False(t, env.store.hasKey(leftDB),
"a directory with no manifest, unclaimed and old, must be deleted")
output := env.stdout.String()
assert.Contains(t, output, "Found 1 metadata director(ies) left by "+
"backups that stopped before their manifest:")
assert.Contains(t, output,
"metadata/"+snapshot.RemoteSnapshotKey(leftID)+"/")
assert.NotContains(t, output, "not-a-remote-key")
assert.Contains(t, output,
"Deleted 1 metadata director(ies) with no manifest.")
assert.NotContains(t, output, "No unreferenced blobs to remove.")
}
// TestPruneDeclinedKeepsMetadataDirWithoutManifest checks that the prompt
// names a directory under metadata/ with no manifest that prune is about
// to delete, and that answering no keeps it.
func TestPruneDeclinedKeepsMetadataDirWithoutManifest(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
env := newListEnv(t)
const leftID = "testhost_home_2026-01-01T00:00:00Z"
leftDB := metadataObjectKey(leftID, "db.zst.age")
require.NoError(t, env.store.Put(context.Background(), leftDB,
strings.NewReader("db")))
env.v.Stdin = strings.NewReader("n\n")
require.ErrorIs(t, env.v.PruneBlobs(&vaultik.PruneOptions{}),
vaultik.ErrNotConfirmed)
assert.True(t, env.store.hasKey(leftDB),
"a declined prune must keep the directory")
output := env.stdout.String()
assert.Contains(t, output,
" metadata/"+snapshot.RemoteSnapshotKey(leftID)+"/\n")
assert.Contains(t, output, "Delete 1 metadata director(ies)? [y/N] ")
assert.Contains(t, output, "Cancelled")
assert.NotContains(t, output, "Deleted")
}
// TestPruneFailedMetadataDirDeleteReturnsError checks that prune --json
// counts a directory under metadata/ with no manifest that it cannot
// delete, and then returns an error, so the command exits non-zero.
func TestPruneFailedMetadataDirDeleteReturnsError(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
env := newListEnv(t)
leftDB := metadataObjectKey("testhost_home_2026-01-01T00:00:00Z",
"db.zst.age")
require.NoError(t, env.store.Put(context.Background(), leftDB,
strings.NewReader("db")))
store := faultstore.New(env.store)
store.OnDelete = func(key string) bool {
return strings.HasPrefix(key, "metadata/")
}
env.v.Storage = store
err := env.v.PruneBlobs(&vaultik.PruneOptions{JSON: true})
require.ErrorContains(t, err,
"1 metadata director(ies) with no manifest could not be deleted")
assert.True(t, env.store.hasKey(leftDB))
var result vaultik.PruneBlobsResult
require.NoError(t, json.Unmarshal(env.stdout.Bytes(), &result),
"stdout must hold the JSON document, got:\n%s", env.stdout.String())
assert.Equal(t, 1, result.MetadataDirsFailed)
assert.Equal(t, 0, result.MetadataDirsDeleted)
}
@@ -14,6 +14,7 @@ import (
"sneak.berlin/go/vaultik/internal/log" "sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/snapshot" "sneak.berlin/go/vaultik/internal/snapshot"
"sneak.berlin/go/vaultik/internal/types" "sneak.berlin/go/vaultik/internal/types"
"sneak.berlin/go/vaultik/internal/ui"
"sneak.berlin/go/vaultik/internal/vaultik" "sneak.berlin/go/vaultik/internal/vaultik"
) )
@@ -34,6 +35,9 @@ func setupConsistencyTest(
repos := database.NewRepositories(db) repos := database.NewRepositories(db)
mockStorage := NewMockStorer() mockStorage := NewMockStorer()
// A backup wrote to this destination, so it carries the marker.
require.NoError(t, mockStorage.Put(ctx, markerFile, strings.NewReader("")))
for _, id := range snapshotIDs { for _, id := range snapshotIDs {
parts := strings.Split(id, "_") parts := strings.Split(id, "_")
startedAt, err := time.Parse(time.RFC3339, parts[len(parts)-1]) startedAt, err := time.Parse(time.RFC3339, parts[len(parts)-1])
@@ -58,13 +62,16 @@ func setupConsistencyTest(
require.NoError(t, err) require.NoError(t, err)
} }
stdout := &bytes.Buffer{}
v := &vaultik.Vaultik{ v := &vaultik.Vaultik{
Storage: mockStorage, Storage: mockStorage,
Repositories: repos, Repositories: repos,
DB: db, DB: db,
Stdout: &bytes.Buffer{}, Stdout: stdout,
Stderr: &bytes.Buffer{}, Stderr: &bytes.Buffer{},
Stdin: &bytes.Buffer{}, Stdin: &bytes.Buffer{},
UI: ui.NewWithColor(stdout, false),
} }
v.SetContext(ctx) v.SetContext(ctx)
+7 -1
View File
@@ -14,6 +14,7 @@ import (
"sneak.berlin/go/vaultik/internal/log" "sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/snapshot" "sneak.berlin/go/vaultik/internal/snapshot"
"sneak.berlin/go/vaultik/internal/types" "sneak.berlin/go/vaultik/internal/types"
"sneak.berlin/go/vaultik/internal/ui"
"sneak.berlin/go/vaultik/internal/vaultik" "sneak.berlin/go/vaultik/internal/vaultik"
) )
@@ -44,6 +45,9 @@ func setupPurgeTest(
repos := database.NewRepositories(db) repos := database.NewRepositories(db)
mockStorage := NewMockStorer() mockStorage := NewMockStorer()
// A backup wrote to this destination, so it carries the marker.
require.NoError(t, mockStorage.Put(ctx, markerFile, strings.NewReader("")))
// Insert each snapshot into the DB and create remote metadata stubs. // Insert each snapshot into the DB and create remote metadata stubs.
// Use timestamps parsed from snapshot IDs for realistic ordering. // Use timestamps parsed from snapshot IDs for realistic ordering.
for _, id := range snapshotIDs { for _, id := range snapshotIDs {
@@ -86,6 +90,7 @@ func setupPurgeTest(
Stdout: stdout, Stdout: stdout,
Stderr: stderr, Stderr: stderr,
Stdin: stdin, Stdin: stdin,
UI: ui.NewWithColor(stdout, false),
} }
v.SetContext(ctx) v.SetContext(ctx)
@@ -279,7 +284,8 @@ func TestPurgeOlderThan_TooLargeDeletesNothing(t *testing.T) {
OlderThan: "1000y", OlderThan: "1000y",
Force: true, Force: true,
}) })
require.Error(t, err) require.EqualError(t, err,
`invalid duration: "1000y" is longer than about 292 years`)
err = v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{ err = v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
OlderThan: "292y", OlderThan: "292y",
+22
View File
@@ -12,6 +12,7 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/log" "sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/snapshot" "sneak.berlin/go/vaultik/internal/snapshot"
"sneak.berlin/go/vaultik/internal/ui"
) )
// testBlobHashB is a blob that the manifest written by addRemote does // testBlobHashB is a blob that the manifest written by addRemote does
@@ -259,3 +260,24 @@ func TestRemoteInfo_DirectoryWithoutManifestLeavesOrphansKnown(t *testing.T) {
assert.NotContains(t, doc, "unreadable_manifests") assert.NotContains(t, doc, "unreadable_manifests")
assert.NotContains(t, doc, "skipped_manifest_count") assert.NotContains(t, doc, "skipped_manifest_count")
} }
// TestRemoteInfo_StatusLineUsesUIMarker checks that the remote info
// status lines go through internal/ui: marker-prefixed, and silenced by
// --quiet.
func TestRemoteInfo_StatusLineUsesUIMarker(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
env := newListEnv(t)
env.addRemote(t, listRemoteID, time.Date(2026, 3, 2, 0, 0, 0, 0, time.UTC))
const statusLine = ui.Marker + " Scanning snapshot metadata...\n"
require.NoError(t, env.v.RemoteInfo(false))
assert.Contains(t, env.stdout.String(), statusLine)
env.stdout.Reset()
env.v.UI.SetQuiet(true)
require.NoError(t, env.v.RemoteInfo(false))
assert.NotContains(t, env.stdout.String(), "Scanning snapshot metadata")
}
+154
View File
@@ -0,0 +1,154 @@
package vaultik_test
import (
"bytes"
"context"
"encoding/json"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/ui"
"sneak.berlin/go/vaultik/internal/vaultik"
)
// TestPurgeSnapshots_QuietSuppressesReport checks that `snapshot purge
// --keep-latest --force` under --quiet deletes the older snapshot and
// prints nothing.
func TestPurgeSnapshots_QuietSuppressesReport(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
v := setupPurgeTest(t, snapHostname, []string{snapHomeT0, snapHomeT1})
var out bytes.Buffer
v.Stdout = &out
v.UI = ui.NewWithColor(&out, false)
v.UI.SetQuiet(true)
require.NoError(t, v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
KeepLatest: true,
Force: true,
}))
assert.Equal(t, []string{snapHomeT1}, listRemainingSnapshots(t, v))
assert.Empty(t, out.String())
}
// TestPurgeSnapshots_QuietPromptListsSnapshots checks that `snapshot
// purge --keep-latest` without --force still lists the snapshots its
// prompt asks to delete under --quiet.
func TestPurgeSnapshots_QuietPromptListsSnapshots(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
v := setupPurgeTest(t, snapHostname, []string{snapHomeT0, snapHomeT1})
var out bytes.Buffer
v.Stdout = &out
v.UI = ui.NewWithColor(&out, false)
v.UI.SetQuiet(true)
// Stdin is empty, so the prompt reads no answer and cancels.
err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
KeepLatest: true,
})
require.ErrorIs(t, err, vaultik.ErrNotConfirmed)
assert.Contains(t, out.String(), snapHomeT0)
assert.Contains(t, out.String(), "Delete 1 snapshot(s)? [y/N]")
assert.Len(t, listRemainingSnapshots(t, v), 2)
}
// TestPruneBlobs_QuietPromptShowsBlobTotal checks that `prune` without
// --force still states the count and total size of the blobs its prompt
// asks to delete under --quiet.
func TestPruneBlobs_QuietPromptShowsBlobTotal(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
env := newListEnv(t)
env.v.UI.SetQuiet(true)
// The store holds no manifest, so nothing references this blob.
addBlob(t, env.store.testStorer, testBlobHashA)
// Stdin is empty, so the prompt reads no answer and cancels.
require.ErrorIs(t, env.v.PruneBlobs(&vaultik.PruneOptions{}),
vaultik.ErrNotConfirmed)
assert.Contains(t, env.stdout.String(),
"Found 1 unreferenced blob(s) totaling")
assert.Contains(t, env.stdout.String(),
"Delete 1 unreferenced blob(s)? [y/N]")
}
// TestRemoveSnapshot_QuietSuppressesReport checks that `snapshot remove`
// under --quiet prints nothing with --dry-run or with --force, and that
// the --json document still prints.
func TestRemoveSnapshot_QuietSuppressesReport(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
newQuietRemover := func() (*vaultik.TestVaultik, *testStorer) {
store := newTestStorer()
addManifest(t, store, "snapshot-001", nil)
// A backup wrote to this destination, so it carries the marker.
require.NoError(t, store.Put(context.Background(), markerFile,
bytes.NewReader(nil)))
tv := vaultik.NewForTesting(store)
tv.Repositories = newLocalIndex(t, "snapshot-001")
tv.UI.SetQuiet(true)
return tv, store
}
tv, store := newQuietRemover()
_, err := tv.RemoveSnapshot("snapshot-001",
&vaultik.RemoveOptions{DryRun: true})
require.NoError(t, err)
assert.Empty(t, tv.Stdout.String(),
"--dry-run must print nothing under --quiet")
_, err = tv.RemoveSnapshot("snapshot-001",
&vaultik.RemoveOptions{Force: true})
require.NoError(t, err)
assert.False(t, store.hasKey(remoteKeyPath("snapshot-001")))
assert.Empty(t, tv.Stdout.String(),
"--force must print nothing under --quiet")
tv, _ = newQuietRemover()
_, err = tv.RemoveSnapshot("snapshot-001",
&vaultik.RemoveOptions{JSON: true})
require.NoError(t, err)
var result vaultik.RemoveResult
require.NoError(t, json.Unmarshal(tv.Stdout.Bytes(), &result),
"the --json document must still print under --quiet")
assert.True(t, result.RemoteRemoved)
}
// TestNukeRemote_QuietSuppressesReport checks that `remote nuke --force`
// under --quiet prints nothing while it removes a snapshot's metadata
// and its blobs.
func TestNukeRemote_QuietSuppressesReport(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
storeDir := filepath.Join(t.TempDir(), "store")
v, _, out := backUpToFileDestination(ctx, t, storeDir)
v.UI.SetQuiet(true)
require.NoError(t, v.NukeRemote(true))
assert.Empty(t, out.String())
}
+78 -3
View File
@@ -17,6 +17,7 @@ import (
"sneak.berlin/go/vaultik/internal/log" "sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/snapshot" "sneak.berlin/go/vaultik/internal/snapshot"
"sneak.berlin/go/vaultik/internal/storage" "sneak.berlin/go/vaultik/internal/storage"
"sneak.berlin/go/vaultik/internal/storage/faultstore"
"sneak.berlin/go/vaultik/internal/types" "sneak.berlin/go/vaultik/internal/types"
"sneak.berlin/go/vaultik/internal/vaultik" "sneak.berlin/go/vaultik/internal/vaultik"
) )
@@ -25,11 +26,14 @@ import (
type testStorer struct { type testStorer struct {
mu sync.Mutex mu sync.Mutex
data map[string][]byte data map[string][]byte
// putTimes holds when Put stored each object; ListStream reports it.
putTimes map[string]time.Time
} }
func newTestStorer() *testStorer { func newTestStorer() *testStorer {
return &testStorer{ return &testStorer{
data: make(map[string][]byte), data: make(map[string][]byte),
putTimes: make(map[string]time.Time),
} }
} }
@@ -43,6 +47,7 @@ func (s *testStorer) Put(_ context.Context, key string, reader io.Reader) error
} }
s.data[key] = data s.data[key] = data
s.putTimes[key] = time.Now().UTC()
return nil return nil
} }
@@ -86,6 +91,7 @@ func (s *testStorer) Delete(_ context.Context, key string) error {
defer s.mu.Unlock() defer s.mu.Unlock()
delete(s.data, key) delete(s.data, key)
delete(s.putTimes, key)
return nil return nil
} }
@@ -121,6 +127,7 @@ func (s *testStorer) ListStream(
ch <- storage.ObjectInfo{ ch <- storage.ObjectInfo{
Key: key, Key: key,
Size: int64(len(data)), Size: int64(len(data)),
ModTime: s.putTimes[key],
} }
} }
} }
@@ -303,6 +310,10 @@ func TestRemoveSnapshot_DefaultRemovesMetadataNotBlobs(t *testing.T) {
addBlob(t, store, blobUnique) addBlob(t, store, blobUnique)
addBlob(t, store, blobShared) addBlob(t, store, blobShared)
// A backup wrote to this destination, so it carries the marker.
require.NoError(t, store.Put(context.Background(), markerFile,
bytes.NewReader(nil)))
tv := vaultik.NewForTesting(store) tv := vaultik.NewForTesting(store)
tv.Repositories = newLocalIndex(t, "snapshot-001", "snapshot-002") tv.Repositories = newLocalIndex(t, "snapshot-001", "snapshot-002")
@@ -326,6 +337,27 @@ func TestRemoveSnapshot_DefaultRemovesMetadataNotBlobs(t *testing.T) {
assert.Contains(t, out, "vaultik prune") assert.Contains(t, out, "vaultik prune")
} }
// TestRemoveSnapshot_DeclinedIsError checks that answering no to the
// prompt keeps the snapshot and returns ErrNotConfirmed, so the command
// exits non-zero.
func TestRemoveSnapshot_DeclinedIsError(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
store := newTestStorer()
addManifest(t, store, "snapshot-001", nil)
tv := vaultik.NewForTesting(store)
tv.Repositories = newLocalIndex(t, "snapshot-001")
tv.Stdin.WriteString("n\n")
_, err := tv.RemoveSnapshot("snapshot-001", &vaultik.RemoveOptions{})
require.ErrorIs(t, err, vaultik.ErrNotConfirmed)
assert.True(t, store.hasKey(remoteKeyPath("snapshot-001")))
assert.Contains(t, tv.Stdout.String(), "Cancelled")
}
func TestRemoveSnapshot_DryRun(t *testing.T) { func TestRemoveSnapshot_DryRun(t *testing.T) {
log.Initialize(log.Config{}) log.Initialize(log.Config{})
t.Parallel() t.Parallel()
@@ -350,7 +382,7 @@ func TestRemoveSnapshot_DryRun(t *testing.T) {
assert.True(t, store.hasKey("blobs/aa/aa/"+blobA)) assert.True(t, store.hasKey("blobs/aa/aa/"+blobA))
assert.True(t, store.hasKey(remoteKeyPath("snapshot-001"))) assert.True(t, store.hasKey(remoteKeyPath("snapshot-001")))
assert.Contains(t, tv.Stdout.String(), "[Dry run - no changes made]") assert.Contains(t, tv.Stdout.String(), "Dry run: no changes made.")
} }
// TestRemoveSnapshot_UnknownIDIsError checks that an ID naming no // TestRemoveSnapshot_UnknownIDIsError checks that an ID naming no
@@ -372,6 +404,10 @@ func TestRemoveSnapshot_UnknownIDIsError(t *testing.T) {
store := newTestStorer() store := newTestStorer()
addManifest(t, store, "snapshot-001", nil) addManifest(t, store, "snapshot-001", nil)
// A backup wrote to this destination, so it carries the marker.
require.NoError(t, store.Put(context.Background(), markerFile,
bytes.NewReader(nil)))
tv := vaultik.NewForTesting(store) tv := vaultik.NewForTesting(store)
tv.Repositories = newLocalIndex(t, "snapshot-001") tv.Repositories = newLocalIndex(t, "snapshot-001")
@@ -392,7 +428,12 @@ func TestRemoveSnapshot_NoMetadataOnDestination(t *testing.T) {
log.Initialize(log.Config{}) log.Initialize(log.Config{})
t.Parallel() t.Parallel()
tv := vaultik.NewForTesting(newTestStorer()) // A backup wrote to this destination, so it carries the marker.
store := newTestStorer()
require.NoError(t, store.Put(context.Background(), markerFile,
bytes.NewReader(nil)))
tv := vaultik.NewForTesting(store)
tv.Repositories = newLocalIndex(t, "snapshot-001") tv.Repositories = newLocalIndex(t, "snapshot-001")
result, err := tv.RemoveSnapshot("snapshot-001", result, err := tv.RemoveSnapshot("snapshot-001",
@@ -431,6 +472,40 @@ func TestRemoveSnapshot_NotInLocalIndex(t *testing.T) {
assert.Contains(t, out, "Removed snapshot metadata from remote storage") assert.Contains(t, out, "Removed snapshot metadata from remote storage")
} }
// TestRemoveSnapshot_NotInLocalIndexDeleteFails checks that removing a
// snapshot only the destination store holds fails when its metadata
// cannot be deleted there, under --force and under --json: nothing was
// removed, so the command must not succeed.
func TestRemoveSnapshot_NotInLocalIndexDeleteFails(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
for name, opts := range map[string]vaultik.RemoveOptions{
"force": {Force: true},
"json": {JSON: true},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
store := newTestStorer()
addManifest(t, store, "snapshot-001", nil)
faulty := faultstore.New(store)
faulty.OnDelete = func(string) bool { return true }
tv := vaultik.NewForTesting(faulty)
tv.Repositories = newLocalIndex(t)
result, err := tv.RemoveSnapshot("snapshot-001", &opts)
require.ErrorIs(t, err, faultstore.ErrInjectedDelete)
assert.False(t, result.RemoteRemoved)
assert.True(t, store.hasKey(remoteKeyPath("snapshot-001")))
assert.Empty(t, tv.Stdout.String())
})
}
}
func TestRemoveAllSnapshots_RequiresForce(t *testing.T) { func TestRemoveAllSnapshots_RequiresForce(t *testing.T) {
log.Initialize(log.Config{}) log.Initialize(log.Config{})
t.Parallel() t.Parallel()
@@ -504,7 +579,7 @@ func TestRemoveAllSnapshots_DryRun(t *testing.T) {
assert.Equal(t, initialCount, store.keyCount()) assert.Equal(t, initialCount, store.keyCount())
assert.Contains(t, tv.Stdout.String(), "[Dry run - no changes made]") assert.Contains(t, tv.Stdout.String(), "Dry run: no changes made.")
} }
func TestRemoveAllSnapshots_NoSnapshots(t *testing.T) { func TestRemoveAllSnapshots_NoSnapshots(t *testing.T) {
+149 -43
View File
@@ -12,6 +12,7 @@ import (
"path/filepath" "path/filepath"
"slices" "slices"
"strings" "strings"
"syscall"
"time" "time"
"filippo.io/age" "filippo.io/age"
@@ -39,6 +40,9 @@ var (
// which is secret, so the message is safe to print and log. // which is secret, so the message is safe to print and log.
errInvalidAgeSecretKey = errors.New( errInvalidAgeSecretKey = errors.New(
"configured age secret key holds no usable age identity") "configured age secret key holds no usable age identity")
// errWrongPrivateKey is returned when the configured key parses but
// is not one of the keys the snapshot was encrypted to.
errWrongPrivateKey = errors.New("cannot decrypt this snapshot")
errBlobMissingFromIndex = errors.New("blob hash missing from blob index") errBlobMissingFromIndex = errors.New("blob hash missing from blob index")
errChunkNotInAnyBlob = errors.New("chunk not found in any blob") errChunkNotInAnyBlob = errors.New("chunk not found in any blob")
errBlobIDNotInHashIndex = errors.New("blob id missing from hash index") errBlobIDNotInHashIndex = errors.New("blob id missing from hash index")
@@ -105,7 +109,12 @@ type RestoreResult struct {
BytesDownloaded int64 BytesDownloaded int64
Duration time.Duration Duration time.Duration
FilesFailed int FilesFailed int
FailedFiles []string // Paths of files that failed to restore FailedFiles []string // Paths of files that failed to restore for their own reason
// Blobs that could not be downloaded, and the paths of the files
// skipped because they need one of them. Those files are counted in
// FilesFailed but not listed in the summary.
BlobsFailed int
FilesNeedingFailedBlob []string
// Verification results (only populated if Verify option is set) // Verification results (only populated if Verify option is set)
FilesVerified int FilesVerified int
BytesVerified int64 BytesVerified int64
@@ -127,10 +136,19 @@ func (v *Vaultik) Restore(opts *RestoreOptions) error {
"paths", opts.Paths, "paths", opts.Paths,
) )
remoteKey, err := v.resolveSnapshotRemoteKey(opts.SnapshotID)
if err != nil {
return err
}
// Step 1: Download and decrypt the snapshot metadata database // Step 1: Download and decrypt the snapshot metadata database
log.Info("Downloading snapshot metadata...") log.Info("Downloading snapshot metadata...")
tempDB, tempDir, err := v.downloadSnapshotDB(opts.SnapshotID, identities) tempDB, tempDir, err := v.downloadSnapshotDB(opts.SnapshotID, remoteKey, identities)
if errors.Is(err, errWrongPrivateKey) {
return err
}
if err != nil { if err != nil {
return fmt.Errorf("downloading snapshot database: %w", err) return fmt.Errorf("downloading snapshot database: %w", err)
} }
@@ -219,14 +237,17 @@ func (v *Vaultik) finishRestore(
) )
if os.Geteuid() != 0 { if os.Geteuid() != 0 {
v.UI.Warningf("Restore did not preserve file ownership: chown(2) " + v.warnIfOwnershipNotPreserved(files, opts.TargetDir)
"requires root. Re-run as root (e.g. with sudo) if you need " +
"original UID/GID preserved.")
} }
if result.FilesFailed > 0 { if result.FilesFailed > 0 {
v.UI.Warningf("%d file(s) failed to restore:", result.FilesFailed) v.UI.Warningf("%d file(s) failed to restore:", result.FilesFailed)
if result.BlobsFailed > 0 {
v.UI.Detailf("%d of them need a blob that could not be read (%d blob(s) in all).",
len(result.FilesNeedingFailedBlob), result.BlobsFailed)
}
for _, path := range result.FailedFiles { for _, path := range result.FailedFiles {
v.UI.Detailf("%s", v.UI.Path(path)) v.UI.Detailf("%s", v.UI.Path(path))
} }
@@ -247,6 +268,43 @@ func (v *Vaultik) finishRestore(
return unmatchedErr return unmatchedErr
} }
// warnIfOwnershipNotPreserved warns once if some entry restored under
// targetDir has another owner or group on disk than the snapshot
// records. It is meant for a restore by a normal user, which changes no
// owner or group: a new entry is owned by that user, and its group is
// that user's, or the group of the directory it is created in on macOS
// and in a setgid directory on Linux. An entry that is not on disk, or
// whose filesystem reports no owner, is skipped.
func (v *Vaultik) warnIfOwnershipNotPreserved(
files []*database.File, targetDir string,
) {
for _, file := range files {
targetPath, err := containedRestorePath(
v.Fs, targetDir, file.Path.String())
if err != nil {
continue
}
info, err := lstatIfPossible(v.Fs, targetPath)
if err != nil {
continue
}
owner, ok := info.Sys().(*syscall.Stat_t)
if !ok {
continue
}
if owner.Uid != file.UID || owner.Gid != file.GID {
v.UI.Warningf("Restore did not preserve file ownership: chown(2) " +
"requires root. Re-run as root (e.g. with sudo) if you need " +
"original UID/GID preserved.")
return
}
}
}
// reportUnmatchedPaths warns, by name, about each requested path that // reportUnmatchedPaths warns, by name, about each requested path that
// matched nothing in the snapshot, and returns an error counting them, // matched nothing in the snapshot, and returns an error counting them,
// or nil when there are none. // or nil when there are none.
@@ -287,6 +345,28 @@ func (v *Vaultik) restoreIdentities() ([]age.Identity, error) {
return identities, nil return identities, nil
} }
// newSnapshotDBReader opens the decrypting reader for a snapshot
// database. When none of the identities can decrypt it, the error says
// in plain words that the configured private key is the wrong one and
// names where it was configured.
func (v *Vaultik) newSnapshotDBReader(
r io.Reader, identities []age.Identity,
) (*blobgen.Reader, error) {
blobReader, err := blobgen.NewReader(r, identities...)
if _, ok := errors.AsType[*age.NoIdentityMatchError](err); ok {
return nil, fmt.Errorf(
"%w: the private key in %s is not one of the keys the backup was encrypted to",
errWrongPrivateKey, v.Config.AgeSecretKeySourceName())
}
if err != nil {
return nil, err
}
return blobReader, nil
}
// restoreAllFiles processes files in blob-locality order: drain every // restoreAllFiles processes files in blob-locality order: drain every
// file whose blob set is on disk, download the missing blobs for the // file whose blob set is on disk, download the missing blobs for the
// pending file with the smallest uncached count, repeat. This keeps // pending file with the smallest uncached count, repeat. This keeps
@@ -323,12 +403,6 @@ func (v *Vaultik) restoreAllFiles(
_ = blobCache.Close() _ = blobCache.Close()
}() }()
// Per-restore sweep state: every blob_size_limit/100 bytes written,
// scan the cache and delete any blob whose remaining file references
// are all already restored.
sweeper := newRestoreSweeper(v.ctx, repos, blobCache,
v.Config.BlobSizeLimit.Int64()/sweepIntervalDivisor)
blobByHash, blobIDToHash, err := v.buildBlobIndexes(repos) blobByHash, blobIDToHash, err := v.buildBlobIndexes(repos)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -340,6 +414,12 @@ func (v *Vaultik) restoreAllFiles(
return nil, fmt.Errorf("building restore plan: %w", err) return nil, fmt.Errorf("building restore plan: %w", err)
} }
// Per-restore sweep state: every blob_size_limit/100 bytes written,
// scan the cache and delete any blob that no pending file in the
// plan references.
sweeper := newRestoreSweeper(plan, blobCache,
v.Config.BlobSizeLimit.Int64()/sweepIntervalDivisor)
// Ranging over files rather than the map reports them in a stable order. // Ranging over files rather than the map reports them in a stable order.
for _, file := range files { for _, file := range files {
if planErr, ok := unplanned[file.ID]; ok { if planErr, ok := unplanned[file.ID]; ok {
@@ -427,10 +507,8 @@ func (v *Vaultik) runRestoreLoop(
continue continue
} }
// Record the file as restored so the sweeper can free blobs // Drop the file from the plan's indexes so future picks ignore
// once all referencing files are done, and drop it from the // it and the sweeper can free blobs no pending file needs.
// plan's indexes so future picks ignore it.
session.sweeper.fileRestored(fileID.String())
plan.finishFile(fileID) plan.finishFile(fileID)
processed++ processed++
@@ -460,8 +538,8 @@ func (v *Vaultik) runRestoreLoop(
// Returns false when nothing is pending download (the caller stops). // Returns false when nothing is pending download (the caller stops).
// //
// A blob that cannot be downloaded is reported through // A blob that cannot be downloaded is reported through
// handleRestoreFileError for every pending file that references it, so // handleRestoreBlobError, so it aborts the restore unless --skip-errors
// it aborts the restore unless --skip-errors is set. // is set.
func (s *restoreSession) downloadNextBlobSet( func (s *restoreSession) downloadNextBlobSet(
plan *restorePlan, filesByID map[types.FileID]*database.File, plan *restorePlan, filesByID map[types.FileID]*database.File,
) (bool, error) { ) (bool, error) {
@@ -494,12 +572,11 @@ func (s *restoreSession) downloadNextBlobSet(
return false, err return false, err
} }
for _, fileID := range plan.filesReferencingBlob(hash) { blobErr := s.v.handleRestoreBlobError(
fileErr := s.v.handleRestoreFileError( plan, s.opts, s.result, filesByID,
plan, s.opts, s.result, filesByID[fileID], fileID, err) plan.filesReferencingBlob(hash), err)
if fileErr != nil { if blobErr != nil {
return false, fileErr return false, blobErr
}
} }
// next is among the failed files, so the rest of its blob set // next is among the failed files, so the rest of its blob set
@@ -590,19 +667,19 @@ func (v *Vaultik) restoreProgressTick(
// handleRestoreFileError records a per-file restore failure: fatal unless // handleRestoreFileError records a per-file restore failure: fatal unless
// --skip-errors is set, in which case the file is counted as failed and // --skip-errors is set, in which case the file is counted as failed and
// dropped from the plan. // dropped from the plan. A fatal failure is not logged here: the returned
// error is printed as the command's ERROR: line.
func (v *Vaultik) handleRestoreFileError( func (v *Vaultik) handleRestoreFileError(
plan *restorePlan, opts *RestoreOptions, result *RestoreResult, plan *restorePlan, opts *RestoreOptions, result *RestoreResult,
file *database.File, fileID types.FileID, err error, file *database.File, fileID types.FileID, err error,
) error { ) error {
log.Error("Failed to restore file", "path", file.Path, "error", err)
if !opts.SkipErrors { if !opts.SkipErrors {
return fmt.Errorf( return fmt.Errorf(
"restoring %s: %w (pass --skip-errors to continue past "+ "restoring %s: %w (pass --skip-errors to continue past "+
"restore failures)", file.Path, err) "restore failures)", file.Path, err)
} }
log.Error("Failed to restore file", "path", file.Path, "error", err)
v.UI.Errorf("Failed to restore %s: %v. Skipping (--skip-errors).", v.UI.Errorf("Failed to restore %s: %v. Skipping (--skip-errors).",
v.UI.Path(file.Path.String()), err) v.UI.Path(file.Path.String()), err)
@@ -614,6 +691,41 @@ func (v *Vaultik) handleRestoreFileError(
return nil return nil
} }
// handleRestoreBlobError records a blob that could not be downloaded.
// fileIDs are the pending files that need it, never empty. Without
// --skip-errors the first of them fails the restore. With it, the blob is
// reported once, and its files are counted as failed and dropped from
// the plan without being reported one by one.
func (v *Vaultik) handleRestoreBlobError(
plan *restorePlan, opts *RestoreOptions, result *RestoreResult,
filesByID map[types.FileID]*database.File, fileIDs []types.FileID,
err error,
) error {
if !opts.SkipErrors {
return v.handleRestoreFileError(
plan, opts, result, filesByID[fileIDs[0]], fileIDs[0], err)
}
log.Error("Failed to restore files", "files", len(fileIDs), "error", err)
v.UI.Errorf("Failed to restore %d file(s): %v. Skipping them (--skip-errors).",
len(fileIDs), err)
result.BlobsFailed++
for _, fileID := range fileIDs {
path := filesByID[fileID].Path.String()
log.Debug("Skipped file that needs a failed blob", "path", path)
result.FilesFailed++
result.FilesNeedingFailedBlob = append(result.FilesNeedingFailedBlob, path)
plan.finishFile(fileID)
}
return nil
}
// printRestoreProgress emits a periodic restore-phase status line via // printRestoreProgress emits a periodic restore-phase status line via
// the UI writer, mirroring scanner.printProcessingProgress so the two // the UI writer, mirroring scanner.printProcessingProgress so the two
// long-running commands have the same on-screen rhythm. // long-running commands have the same on-screen rhythm.
@@ -685,8 +797,8 @@ func (v *Vaultik) handleRestoreVerification(
) error { ) error {
// A file that failed to restore was not written and is already // A file that failed to restore was not written and is already
// reported as such, so it is not verified. // reported as such, so it is not verified.
failedToRestore := make(map[string]bool, len(result.FailedFiles)) failedToRestore := make(map[string]bool, result.FilesFailed)
for _, path := range result.FailedFiles { for _, path := range slices.Concat(result.FailedFiles, result.FilesNeedingFailedBlob) {
failedToRestore[path] = true failedToRestore[path] = true
} }
@@ -720,19 +832,12 @@ func (v *Vaultik) handleRestoreVerification(
return nil return nil
} }
// downloadSnapshotDB downloads and decrypts the snapshot metadata // downloadSnapshotDB downloads and decrypts the metadata database of the
// database. The identifier is resolved to the snapshot's remote key: a // snapshot at remoteKey, which resolveSnapshotRemoteKey returned for
// human ID is hashed, and a remote key (or its abbreviation, as printed // snapshotID.
// for a remote-only snapshot) is used as-is, so a host with no local
// index can restore the snapshots it can only see on the store.
func (v *Vaultik) downloadSnapshotDB( func (v *Vaultik) downloadSnapshotDB(
snapshotID string, identities []age.Identity, snapshotID, remoteKey string, identities []age.Identity,
) (*database.DB, string, error) { ) (*database.DB, string, error) {
remoteKey, err := v.resolveSnapshotRemoteKey(snapshotID)
if err != nil {
return nil, "", err
}
// Download encrypted database from storage // Download encrypted database from storage
dbKey := fmt.Sprintf("metadata/%s/db.zst.age", remoteKey) dbKey := fmt.Sprintf("metadata/%s/db.zst.age", remoteKey)
@@ -749,9 +854,9 @@ func (v *Vaultik) downloadSnapshotDB(
// the plaintext to a temp file. Neither the encrypted bytes nor the // the plaintext to a temp file. Neither the encrypted bytes nor the
// decrypted database is ever held whole in memory; a snapshot database // decrypted database is ever held whole in memory; a snapshot database
// can be large. // can be large.
blobReader, err := blobgen.NewReader(reader, identities...) blobReader, err := v.newSnapshotDBReader(reader, identities)
if err != nil { if err != nil {
return nil, "", fmt.Errorf("creating decryption reader: %w", err) return nil, "", err
} }
defer func() { _ = blobReader.Close() }() defer func() { _ = blobReader.Close() }()
@@ -967,8 +1072,9 @@ type restoreSession struct {
// root can chown a file to an arbitrary UID/GID — non-root chown // root can chown a file to an arbitrary UID/GID — non-root chown
// always fails with EPERM. Attempting it anyway produces N // always fails with EPERM. Attempting it anyway produces N
// guaranteed-failed syscalls + N noisy debug lines, so we skip // guaranteed-failed syscalls + N noisy debug lines, so we skip
// the call entirely as non-root and emit one warning at the end // the call entirely as non-root; warnIfOwnershipNotPreserved warns
// of the restore explaining that ownership was not preserved. // once at the end of the restore if that left any owner or group
// other than recorded.
runningAsRoot bool runningAsRoot bool
// directories holds every restored directory, for // directories holds every restored directory, for
// applyDirectoryMetadata to finish after the restore loop. // applyDirectoryMetadata to finish after the restore loop.
@@ -42,7 +42,7 @@ func TestRestoreOnAnotherMachine(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "source") dataDir := filepath.Join(tempDir, "source")
storeDir := filepath.Join(tempDir, "remote") storeDir := t.TempDir()
restoreDir := filepath.Join(tempDir, "restored") restoreDir := filepath.Join(tempDir, "restored")
dbPath := filepath.Join(tempDir, "index.sqlite") dbPath := filepath.Join(tempDir, "index.sqlite")
+3 -2
View File
@@ -65,7 +65,7 @@ func TestRestoreCleansTempDirOnInterrupt(t *testing.T) {
root := t.TempDir() root := t.TempDir()
dataDir := filepath.Join(root, "source") dataDir := filepath.Join(root, "source")
storeDir := filepath.Join(root, "remote") storeDir := t.TempDir()
restoreDir := filepath.Join(root, "restored") restoreDir := filepath.Join(root, "restored")
dbPath := filepath.Join(root, "index.sqlite") dbPath := filepath.Join(root, "index.sqlite")
@@ -74,7 +74,8 @@ func TestRestoreCleansTempDirOnInterrupt(t *testing.T) {
buildLocalityFixture(t, fs, dataDir) buildLocalityFixture(t, fs, dataDir)
cfg, storer, snapshotID := setupLocalityBackup( cfg, storer, snapshotID := setupLocalityBackup(
context.Background(), t, fs, dataDir, storeDir, dbPath) context.Background(), t, fs, dataDir, storeDir, dbPath,
localitySourceBytes)
// Point the "" temp paths (the blob cache directory and the // Point the "" temp paths (the blob cache directory and the
// snapshot-database directory) at a private directory so the test can // snapshot-database directory) at a private directory so the test can
+22 -18
View File
@@ -46,11 +46,11 @@ import (
// slice out a few KB — currently the dominant cost in restore. // slice out a few KB — currently the dominant cost in restore.
// //
// The test deliberately constructs an adversarial scenario: three // The test deliberately constructs an adversarial scenario: three
// blobs A/B/C of ~6 MB each, nine files distributed across them, and // blobs A/B/C of five 64 KiB chunks each, nine files distributed across
// path-ordered names that interleave the blobs (a1, b1, c1, a2, b2, // them, and path-ordered names that interleave the blobs (a1, b1, c1,
// c2, …) so naive path-order processing would touch every blob before // a2, b2, c2, …) so naive path-order processing would touch every blob
// finishing any of them. // before finishing any of them.
// localitySource is one 1 MiB fixture file used by the locality test. // localitySource is one 64 KiB fixture file used by the locality test.
type localitySource struct { type localitySource struct {
path string path string
data []byte data []byte
@@ -63,8 +63,12 @@ type localityCopy struct {
data []byte data []byte
} }
// localitySourceBytes is the size of each source file of the locality
// fixture.
const localitySourceBytes = 64 * 1024
// buildLocalityFixture writes the adversarial source layout described // buildLocalityFixture writes the adversarial source layout described
// in TestRestoreLocalityAndReadAt: 15 sources of 1 MiB each (which the // in TestRestoreLocalityAndReadAt: 15 sources of 64 KiB each (which the
// backup packs into 3 blobs of 5 chunks) plus 9 interleaved-name copies // backup packs into 3 blobs of 5 chunks) plus 9 interleaved-name copies
// (3 per blob group). // (3 per blob group).
func buildLocalityFixture( func buildLocalityFixture(
@@ -73,7 +77,6 @@ func buildLocalityFixture(
t.Helper() t.Helper()
const ( const (
srcBytes = 1024 * 1024
srcCount = 15 srcCount = 15
blobsCount = 3 blobsCount = 3
perBlob = srcCount / blobsCount perBlob = srcCount / blobsCount
@@ -83,7 +86,7 @@ func buildLocalityFixture(
for i := range srcCount { for i := range srcCount {
s := &localitySource{ s := &localitySource{
path: fmt.Sprintf("src-%03d.bin", i+1), path: fmt.Sprintf("src-%03d.bin", i+1),
data: randomBytes(t, srcBytes), data: randomBytes(t, localitySourceBytes),
} }
sources[i] = s sources[i] = s
require.NoError(t, afero.WriteFile( require.NoError(t, afero.WriteFile(
@@ -115,19 +118,20 @@ func buildLocalityFixture(
// setupLocalityBackup runs the backup half of the locality test: a // setupLocalityBackup runs the backup half of the locality test: a
// snapshot of dataDir into a file storer with sizes tuned so 15 one-chunk // snapshot of dataDir into a file storer with sizes tuned so 15 one-chunk
// files pack into 3 blobs. The index database is closed before returning // files of srcBytes pack into 3 blobs. The index database is closed
// so the restore half runs from remote bytes only. // before returning so the restore half runs from remote bytes only.
func setupLocalityBackup( func setupLocalityBackup(
ctx context.Context, t *testing.T, fs afero.Fs, ctx context.Context, t *testing.T, fs afero.Fs,
dataDir, storeDir, dbPath string, dataDir, storeDir, dbPath string, srcBytes int64,
) (*config.Config, *storage.FileStorer, string) { ) (*config.Config, *storage.FileStorer, string) {
t.Helper() t.Helper()
// chunkSize avg = 4 MiB makes minSize = 1 MiB, so a 1 MiB file // An average chunk size of four times srcBytes makes the minimum
// becomes one chunk. maxBlobSize = 5 MiB packs exactly 5 chunks // chunk srcBytes, so each source file becomes one chunk. A blob limit
// per blob, yielding 3 blobs from 15 source files. // of five times srcBytes packs exactly 5 chunks per blob, yielding 3
chunkSize := int64(4 * 1024 * 1024) // blobs from 15 source files.
maxBlobSize := int64(5 * 1024 * 1024) chunkSize := 4 * srcBytes
maxBlobSize := 5 * srcBytes
storer, err := storage.NewFileStorer(storeDir) storer, err := storage.NewFileStorer(storeDir)
require.NoError(t, err) require.NoError(t, err)
@@ -197,7 +201,7 @@ func TestRestoreLocalityAndReadAt(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "source") dataDir := filepath.Join(tempDir, "source")
storeDir := filepath.Join(tempDir, "remote") storeDir := t.TempDir()
restoreDir := filepath.Join(tempDir, "restored") restoreDir := filepath.Join(tempDir, "restored")
dbPath := filepath.Join(tempDir, "index.sqlite") dbPath := filepath.Join(tempDir, "index.sqlite")
@@ -208,7 +212,7 @@ func TestRestoreLocalityAndReadAt(t *testing.T) {
ctx := context.Background() ctx := context.Background()
cfg, storer, snapshotID := setupLocalityBackup( cfg, storer, snapshotID := setupLocalityBackup(
ctx, t, fs, dataDir, storeDir, dbPath) ctx, t, fs, dataDir, storeDir, dbPath, localitySourceBytes)
// Wrap the storer so we can count downloads per blob key. // Wrap the storer so we can count downloads per blob key.
counter := newCountingStorer(storer) counter := newCountingStorer(storer)
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff. Show more