Author SHA1 Message Date
sneak c9f3667757 Name the setting and value when a size does not parse (closes #429)
check / check (push) Successful in 12m50s
A size setting such as chunk_size: banana failed with "invalid size
format: strconv.ParseFloat: parsing "": invalid syntax", naming neither
the setting nor the value. The size decoder now quotes the value, and
Parse checks every size setting after ${...} references are filled in,
so the error reads chunk_size: "banana" is not a size; use a number of
bytes or a unit such as 10MB. This covers chunk_size, blob_size_limit
and s3.part_size, and config set. A value too large for a size, such as
100EB or 99999999999999999999, fails as too large, naming the setting.

Model: opus-5-5
2026-11-11 05:13:27 +00:00
23 changed files with 2099 additions and 3126 deletions

No files matched your search

+970 -1404
View File
File diff suppressed because it is too large. Load diff
+18 -55
View File
@@ -1,32 +1,16 @@
# vaultik configuration file example # vaultik configuration file example
# This file shows every configuration option. The values are examples, not # This file shows all available configuration options with their default values
# defaults; where an option has a default, its comment gives it. # Copy this file and uncomment/modify the values you need
# Copy this file and change the values you need
# Age recipient public keys for encryption (age: https://age-encryption.org/) # Age recipient public keys for encryption
# 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 a key pair, and show its public key, with: # Generate with: age-keygen | grep "public key"
# 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:
@@ -269,18 +253,11 @@ 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. When it is not set, the endpoint, bucket and prefix in the s3 # Storage URL - use either this OR the s3 section below
# 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
@@ -306,30 +283,26 @@ 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
# # An object too large for 10,000 parts of this size gets larger parts # # A blob 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 # It must be an absolute path; ~ is expanded
# 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 size of a chunk, a piece of a file's content. Chunk boundaries # Average chunk size for content-defined chunking
# depend on the file's content, not on fixed offsets # Smaller chunks = better deduplication but more metadata
# 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
@@ -339,11 +312,9 @@ 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 parameters of the FastCDC chunking algorithm # Chunking uses no secret (the FastCDC parameters are fixed and public). At a
# are fixed and public). A full blob at a large limit holds hundreds of # large limit a blob holds hundreds of chunks, so individual chunk lengths are
# chunks, so its size does not show any one chunk's length. The last blob of # not visible in its size; lowering the limit toward chunk_size exposes them.
# 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
@@ -359,14 +330,12 @@ storage_url: "rclone://myremote/path/to/backups"
# Default: 3 # Default: 3
compression_level: 5 compression_level: 5
# How old a blob that no snapshot lists must be before vaultik prune deletes # How old a blob that no snapshot lists must be before prune deletes it, and
# it. Each snapshot has a directory under metadata/ at the destination, and # how old every object in a directory under metadata/ with no manifest must
# its manifest there lists the blobs it uses. This is also how old every # be before prune deletes that directory
# object in a directory with no manifest must be before vaultik prune deletes # A backup uploads its blobs and its db.zst.age before its manifest, so on a
# that directory # destination shared between hosts or users this must be longer than the
# A backup uploads its blobs and its encrypted database (db.zst.age) before # longest backup run
# 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 # 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 # before its manifest: that blob keeps the time of its first upload, so a
# prune can delete it # prune can delete it
@@ -377,9 +346,3 @@ compression_level: 5
# 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
+435 -367
View File
@@ -1,418 +1,486 @@
# vaultik architecture # Vaultik Architecture
This document describes how the `vaultik` code is organised. It covers three This document describes the internal architecture of Vaultik, focusing on the
things: data model, type instantiation, and the relationships between core modules.
- the packages ## Overview
- how they are wired together
- what happens during a backup, a restore, a verify and a prune
Two other documents cover the stored data: 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).
- [`DATAMODEL.md`](DATAMODEL.md) describes the local index. ## Data Flow
- [`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 Source Files
│ │
▼ ▼
Scanner walks the paths and finds the files that changed ┌─────────────────┐
│ │ Scanner │ Walks directories, detects changed files
▼ └────────┬────────┘
Chunker cuts each changed file into chunks │
│ ▼
▼ ┌─────────────────┐
Packer packs new chunks into a blob, compressed and encrypted │ Chunker │ Splits files into variable-size chunks (FastCDC)
│ └────────┬────────┘
▼ │
Storer uploads the blob to the destination ▼
┌─────────────────┐
│ Packer │ Accumulates chunks, compresses (zstd), encrypts (age)
└────────┬────────┘
│
▼
┌─────────────────┐
│ S3 Client │ Uploads blobs to remote storage
└─────────────────┘
``` ```
## Packages ## Data Model
- `cmd/vaultik`: the `main` function. It starts optional profiling and calls ### Core Entities
`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 database tracks five primary entities and their relationships:
The application is assembled with [uber-go/fx](https://github.com/uber-go/fx), a ```
dependency injection library. `cli.NewApp` lists the providers: ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 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](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 ```go
fx.Supply(config.Path(opts.ConfigPath)), // cli/app.go: NewApp()
fx.Supply(opts.LogOptions), fx.New(
fx.Provide(globals.New), fx.Supply(config.Path(opts.ConfigPath)), // 1. Config path
fx.Provide(log.New), fx.Supply(opts.LogOptions), // 2. Log options
config.Module, fx.Provide(globals.New), // 3. Globals
database.Module, fx.Provide(log.New), // 4. Logger config
log.Module, config.Module, // 5. Config
storage.Module, database.Module, // 6. Database + Repositories
snapshot.Module, log.Module, // 7. Logger initialization
fx.Provide(vaultik.New), storage.Module, // 8. Storage backend (S3/file/rclone)
fx.Invoke(setupGlobals), snapshot.Module, // 9. SnapshotManager + ScannerFactory
fx.NopLogger, fx.Provide(vaultik.New), // 10. Vaultik orchestrator
)
``` ```
Each module provides these: ### 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`)
- `config.Module` provides `*config.Config`, from `config.Load`. - **Created by**: `database.Module` via `database.New()`
- `database.Module` provides `*database.DB` and `*database.Repositories`. It - **When**: Application startup (fx DI)
creates the index file with mode `0600` when it does not exist. - **Contains**: SQLite connection, path reference
- `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 #### 3. Repositories (`database.Repositories`)
- `database delete`
- `completion` - **Created by**: `database.Module` via `database.NewRepositories()`
- `version` - **When**: Application startup (fx DI)
- **Contains**: All repository interfaces (Files, Chunks, Blobs, Snapshots,
etc.)
## Backup #### 4. Vaultik (`vaultik.Vaultik`)
`Vaultik.CreateSnapshot` runs a backup. It does these steps in order: - **Created by**: `vaultik.New(VaultikParams)`
- **When**: Application startup (fx DI)
- **Contains**: All dependencies for backup operations
- `markDestination` checks that the local index is not bound to another ```go
destination. It then checks the marker file, writes it, and binds the local type Vaultik struct {
index to the configured destination. Globals *globals.Globals
- `PruneDatabase` removes unfinished snapshots and unused records from the local Config *config.Config
index. DB *database.DB
- `createNamedSnapshot` runs once for each snapshot name. Repositories *database.Repositories
- With `--prune`, `runPostBackupPrune` removes old snapshots and unused data. Storage storage.Storer
ScannerFactory snapshot.ScannerFactory
SnapshotManager *snapshot.SnapshotManager
Shutdowner fx.Shutdowner
Fs afero.Fs
ctx context.Context
cancel context.CancelFunc
}
```
`CreateSnapshot` calls `skipPostBackupPrune` instead of `runPostBackupPrune` in #### 5. SnapshotManager (`snapshot.SnapshotManager`)
these cases:
- `--skip-errors` skipped a file or directory that could not be read. - **Created by**: `snapshot.Module` via `snapshot.NewSnapshotManager()`
- A configured path held nothing. - **When**: Application startup (fx DI)
- An earlier snapshot of the same name found a mount point that this backup did - **Responsibility**: Creates/completes snapshots, exports metadata to S3
not find.
`PruneDatabase` must succeed before anything is scanned. An unfinished snapshot #### 6. Scanner (`snapshot.Scanner`)
left in the index would make the backup skip files that were never fully stored.
### One snapshot - **Created by**: `ScannerFactory(ScannerParams)`
- **When**: Each `CreateSnapshot()` call
`createNamedSnapshot` does these steps: - **Contains**: Chunker, Packer, progress reporter
- `resolveSnapshotPaths` makes each configured path absolute and resolves ```go
symlinks. // vaultik/snapshot.go: CreateSnapshot()
- `SnapshotManager.CreateSnapshotWithName` creates the snapshot record. The scanner := v.ScannerFactory(snapshot.ScannerParams{
record is not yet marked complete. EnableProgress: !opts.Cron,
- `Scanner.Scan` backs up each path in turn. Fs: v.Fs,
- `finalizeSnapshotMetadata` uploads the snapshot's metadata and marks the })
snapshot complete. ```
A new `Scanner` is created for each snapshot, from `ScannerFactory`. It owns a #### 7. Chunker (`chunker.Chunker`)
`Chunker` and a `Packer`.
### Scanning one path - **Created by**: `chunker.NewChunker(avgChunkSize)`
- **When**: Inside `snapshot.NewScanner()`
- **Configuration**:
- `avgChunkSize`: From config (default 10MB)
- `minChunkSize`: avgChunkSize / 4
- `maxChunkSize`: avgChunkSize \* 4
`Scanner.Scan` works in these steps: #### 8. Packer (`blob.Packer`)
- `repairInterruptedBlobs` deletes the records of blobs whose upload never - **Created by**: `blob.NewPacker(PackerConfig)`
finished. Their data is then stored again. - **When**: Inside `snapshot.NewScanner()`
- `loadDatabaseState` loads the known files and chunks into memory. - **Configuration**:
- `scanPhase` walks the path. It sorts each regular file and symlink into - `MaxBlobSize`: Maximum blob size before finalization (typically 10GB)
changed or unchanged. - `CompressionLevel`: zstd level (1-19)
- `detectDeletedFiles` counts the files that the previous snapshot of this name - `Recipients`: age public keys for encryption
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 ```go
// snapshot/scanner.go: NewScanner()
Data is stored once at two levels. packerCfg := blob.PackerConfig{
MaxBlobSize: cfg.MaxBlobSize,
- **Files.** A file is not read again when its record in the index still matches CompressionLevel: cfg.CompressionLevel,
it. Recipients: cfg.AgeRecipients,
- **Chunks.** A chunk is named by the SHA-256 hash of its content. A chunk that Repositories: cfg.Repositories,
an uploaded blob already holds, according to the index, is not stored again. Fs: cfg.FS,
}
A file's record matches when all of these are unchanged: packer, err := blob.NewPacker(packerCfg)
```
- 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 ## Module Responsibilities
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: ### `internal/cli`
- It looks the blob up at the destination. A blob that is already there is not Entry point for fx application. Combines all modules and handles signal
uploaded again. interrupts.
- 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 Key functions:
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 - `NewApp(AppOptions)` → Creates fx.App with all modules
therefore marks a backup that did not finish. - `RunApp(ctx, app)` → Starts app, handles graceful shutdown
- `RunWithApp(ctx, opts)` → Convenience wrapper
## Restore
`Vaultik.Restore` does these steps: ### `internal/vaultik`
- `restoreIdentities` parses the age private key. A missing or malformed key Main orchestrator containing all dependencies and command implementations.
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 Key methods:
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 - `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
Decryption shows that a database is readable. It does not show that the database ### `internal/chunker`
is honest. The restore therefore checks it:
- `database.OpenReadOnly` opens the file read-only. It refuses a file that has a Content-defined chunking using FastCDC algorithm.
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. Key types:
- 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: - `Chunk` → Hash, Data, Offset, Size
- `Chunker` → avgChunkSize, minChunkSize, maxChunkSize
- 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. Key methods:
- `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`: - `NewChunker(avgChunkSize)` → Constructor
- `ChunkReaderStreaming(reader, callback)` → Stream chunks with callback
- `s3://` gives the S3 implementation. (preferred)
- `file://` gives the local directory implementation. - `ChunkReader(reader)` → Return all chunks at once (memory-intensive)
- `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 ### `internal/blob`
These checks keep the local index and the destination in step. Blob packing: accumulates chunks, compresses, encrypts, tracks metadata.
- `checkDestinationMarker` looks for `vaultik-destination.txt`. It fails when Key types:
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. - `Packer` → Thread-safe blob accumulator
- `ChunkRef` → Hash + Data for adding to packer
## Concurrency - `FinishedBlob` → Completed blob ready for upload
- `BlobWithReader` → FinishedBlob + io.Reader for streaming upload
- 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 Key methods:
therefore read it while a backup writes to it.
- `Packer` guards its state with a mutex. - `NewPacker(PackerConfig)` → Constructor
- `Scanner` holds `packerMu` while a chunk is added to the packer and while a - `AddChunk(ChunkRef)` → Add chunk to current blob
blob is closed. - `FinalizeBlob()` → Compress, encrypt, hash current blob
- `Repositories.WithTx` runs a function in one transaction. It commits when the - `Flush()` → Finalize any in-progress blob
function succeeds and rolls back when it fails. - `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 1**: Walk directory, identify files needing processing
2. **Phase 1b**: Count as deleted the files that the previous snapshot of the
same name lists, the walk did not find, and are gone from disk; a file that
is excluded, or inside an excluded directory, is not counted
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)
│
├─► markDestination() // Check the marker (see Storage Layout in S3), then write it
│
├─► PruneDatabase() // Critical: avoid dedup errors
│
├─► SnapshotManager.CreateSnapshot() // Create DB record
│
├─► For each source directory:
│ │
│ ├─► scanner.Scan(ctx, path, snapshotID)
│ │ │
│ │ ├─► Phase 1: scanPhase()
│ │ │ Walk directory
│ │ │ Check file metadata changes
│ │ │ Build list of files to process
│ │ │
│ │ ├─► Phase 1b: detectDeletedFiles()
│ │ │
│ │ └─► 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 and symlinks unchanged since last backup are skipped
(metadata comparison: size, mtime, mode, uid, gid, and a symlink's target),
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/
├── vaultik-destination.txt # Marker written by every backup
│
├── 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)
```
A destination that answers a look-up of `vaultik-destination.txt` with not found
is treated as not present while the local index holds snapshots stored there
(`checkDestinationMarker` in `internal/vaultik/destination_marker.go`):
`snapshot create` refuses to write to it, `prune`, `snapshot purge` and
`remote nuke` fail, and `snapshot list` and `snapshot remove` behave as for a
destination that cannot be listed. This keeps those commands from taking an
unplugged volume's empty mount point for a destination whose snapshots are all
gone. When the look-up fails for any reason other than not found, for example
when S3 refuses it to credentials that allow only PUT, the check is skipped, so
a host with such credentials does not notice an emptied destination; see
[docs/REPOSTRUCTURE.md](REPOSTRUCTURE.md#marker-file-vaultik-destinationtxt).
The `file://` backend never creates its destination directory, so a write to a
missing one fails.
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](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.
+301 -362
View File
@@ -1,450 +1,389 @@
# vaultik data model # Vaultik Data Model
`vaultik` keeps a SQLite database on the backed-up machine, called the local ## Overview
index. This document describes its tables, and how a backup, a restore and
`prune` use them.
The local index has two jobs: 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.
- It records which files, chunks and blobs are already stored. The next backup **Important Notes:**
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.
A restore reads no file, chunk or blob rows from the local index. It reads the - **Version Compatibility**: In rare cases, you may need to use the same version
copy that was uploaded with the snapshot. of Vaultik to restore a backup as was used to create it. This ensures
compatibility with the metadata format stored in S3.
## Terms ## Schema Migrations
- **Snapshot**: one backup of the paths listed under one name in the config. Vaultik supports schema migrations. They are the numbered SQL files in
- **Snapshot ID**: the name of a snapshot, in the form `internal/database/schema/`, embedded in the binary: `000.sql` creates the
`<hostname>_<snapshot-name>_<timestamp>`. `schema_migrations` table, which records each migration that has run, and
- **Chunk**: a piece of a file's content, named by the SHA-256 hash of that `001.sql` creates the application tables. `database.New` opens a database and
content. applies, in order, every migration that database has not yet recorded.
- **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.
## Where the index is **Before 1.0** no migrations are added, because nothing is installed anywhere
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.
The index is the file `index.sqlite` in the platform data directory. By default **After 1.0** each schema change is a new numbered file, so an existing local
that is `~/.local/share/vaultik/` on Linux and database is migrated the first time an updated vaultik opens it. A file that has
`~/Library/Application Support/vaultik/` on macOS. The `index_path` setting and shipped in a release is never edited.
the `VAULTIK_INDEX_PATH` environment variable change the path. So does
`XDG_DATA_HOME`.
The file is readable only by its owner, because it lists every backed-up path. ## Database Tables
`vaultik` sets that mode each time it opens the index.
## Schema migrations ### 1. `files`
`vaultik` supports schema migrations. They are the numbered SQL files in Stores metadata about files in the filesystem being backed up.
`internal/database/schema/`, which are built into the binary.
- `000.sql` creates the `schema_migrations` table. That table records each **Columns:**
migration that has run.
- `001.sql` creates every other table.
`database.New` opens the index. It then applies, in order, every migration that - `id` (TEXT PRIMARY KEY) - UUID for the file record
the index has not recorded. - `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)
Before version 1.0, no migration is added, because nothing is installed anywhere **Indexes:**
yet. A schema change edits `001.sql` directly, with the code that uses the
changed tables.
This has one consequence. An index created before such a change has already - `idx_files_path` on `path` for efficient lookups
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.
After version 1.0, each schema change is a new numbered file. An existing index **Purpose:** Tracks file metadata to detect changes between backup runs. Used
is then migrated the first time an updated `vaultik` opens it. A file that was for incremental backup decisions. The UUID primary key provides stable
part of a release is never edited. references that don't change if files are moved.
Migrations apply to the local index only. A snapshot's database at the ### 2. `chunks`
destination is read as it was written. Each snapshot records the `vaultik`
version that wrote it.
## Tables Stores information about content-defined chunks created from files.
Times are whole seconds since the Unix epoch, in UTC. The two exceptions are **Columns:**
noted at their columns.
### `files` - `chunk_hash` (TEXT PRIMARY KEY) - SHA256 hash of chunk content
- `size` (INTEGER NOT NULL) - Chunk size in bytes
One row for each backed-up path: a regular file, a symlink or a directory. **Purpose:** Enables deduplication by tracking unique chunks across all files.
- `id`: a UUID. Primary key. ### 3. `file_chunks`
- `path`: the absolute path. Unique.
- `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.
`idx_files_path` indexes `path`. Maps files to their constituent chunks in order.
There is one row for each path, and it is updated when the file changes. The row **Columns:**
is shared by every snapshot in the local index that lists the path. An older
snapshot's own view of the file is in that snapshot's database at the
destination.
### `chunks` - `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`)
One row for each distinct chunk. **Purpose:** Allows reconstruction of files from chunks during restore.
- `chunk_hash`: the SHA-256 hash of the chunk's content, in hex. Primary key. ### 4. `chunk_files`
- `size`: the size in bytes.
### `file_chunks` Reverse mapping showing which files contain each chunk.
The chunks that make up each file, in order. **Columns:**
- `file_id`: the file. References `files.id`. - `chunk_hash` (TEXT) - Chunk hash (FK to chunks.chunk_hash)
- `idx`: the position of the chunk within the file, counted from 0. - `file_id` (TEXT) - File ID (FK to files.id)
- `chunk_hash`: the chunk. References `chunks.chunk_hash`. - `file_offset` (INTEGER) - Byte offset of chunk within file
- `length` (INTEGER) - Length of chunk in bytes
- PRIMARY KEY (`chunk_hash`, `file_id`)
The primary key is (`file_id`, `idx`). `idx_file_chunks_chunk_hash` indexes **Purpose:** Supports efficient queries for chunk usage and deduplication
`chunk_hash`. statistics.
### `chunk_files` ### 5. `blobs`
The reverse of `file_chunks`: the files that contain each chunk. Stores information about packed, compressed, and encrypted blob files.
- `chunk_hash`: the chunk. References `chunks.chunk_hash`. **Columns:**
- `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 - `id` (TEXT PRIMARY KEY) - UUID assigned when blob creation starts
`file_id`. - `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)
- `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)
### `blobs` **Purpose:** Tracks blob lifecycle from creation through upload. The UUID
primary key allows immediate association of chunks with blobs.
One row for each blob. ### 6. `blob_chunks`
- `id`: a UUID, given when packing of the blob starts. Primary key. Maps chunks to the blobs that contain them.
- `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 **Columns:**
[`REPOSTRUCTURE.md`](REPOSTRUCTURE.md#blobs).
Once a blob is uploaded, `compressed_size` is the size of the object at the - `blob_id` (TEXT) - Blob ID (FK to blobs.id)
destination. A blob that was already there can have a different size from the - `chunk_hash` (TEXT) - Chunk hash (FK to chunks.chunk_hash)
local copy. That happens when it was written with another compression level or - `offset` (INTEGER) - Byte offset of chunk within blob (before compression)
other recipients. - `length` (INTEGER) - Length of chunk in bytes
- PRIMARY KEY (`blob_id`, `chunk_hash`)
### `blob_chunks` **Purpose:** Enables chunk retrieval from blobs during restore operations.
The chunks that each blob holds. ### 7. `snapshots`
- `blob_id`: the blob. References `blobs.id`. Tracks backup snapshots.
- `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` **Columns:**
indexes `chunk_hash`.
A restore finds the blob for each chunk through this table. - `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
### `snapshots` **Purpose:** Provides snapshot metadata and statistics including version
tracking for compatibility.
One row for each snapshot. ### 8. `snapshot_files`
- `id`: the snapshot ID. Primary key. Maps snapshots to the files they contain.
- `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 **Columns:**
example is `server1_home_2025-06-01T12:00:00Z`. It has three parts:
- the hostname, up to its first `.` - `snapshot_id` (TEXT) - Snapshot ID (FK to snapshots.id)
- the snapshot name - `file_id` (TEXT) - File ID (FK to files.id)
- the start time, in UTC - PRIMARY KEY (`snapshot_id`, `file_id`)
The `snapshot list` table shows `uncompressed_chunk_size` as `UNCOMPRESSED SIZE` **Purpose:** Records which files are included in each snapshot.
and `new_chunk_size` as `NEW CHUNK SIZE`.
A snapshot with `completed_at` set to `NULL` is unfinished. `snapshot list` ### 9. `snapshot_blobs`
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` Maps snapshots to the blobs they reference.
The files in each snapshot. **Columns:**
- `snapshot_id`: the snapshot. References `snapshots.id`. - `snapshot_id` (TEXT) - Snapshot ID (FK to snapshots.id)
- `file_id`: the file. References `files.id`. - `blob_id` (TEXT) - Blob ID (FK to blobs.id)
- `blob_hash` (TEXT) - Denormalized blob hash for manifest generation
- PRIMARY KEY (`snapshot_id`, `blob_id`)
The primary key is (`snapshot_id`, `file_id`). `idx_snapshot_files_file_id` **Purpose:** Tracks blob dependencies for snapshots and enables manifest
indexes `file_id`. generation.
### `snapshot_blobs` ### 10. `snapshot_mount_points`
The blobs that each snapshot uses. The directories at or below a configured path, the configured path itself
included, that a snapshot's backup found an entry in and, once it had listed
them, found on a different filesystem from their parent directory (a different
device number), as a mount point is. An automount point whose volume cannot be
mounted is not recorded, because the backup finds no entry in it.
**Columns:**
- `snapshot_id` (TEXT) - Snapshot ID (FK to snapshots.id)
- `path` (TEXT) - Absolute path of the directory
- PRIMARY KEY (`snapshot_id`, `path`)
- `snapshot_id`: the snapshot. References `snapshots.id`. **Purpose:** Lets `snapshot create --prune` skip the prune when a backup does
- `blob_id`: the blob. References `blobs.id`. not find a mount point that an earlier snapshot of the same name found, as when
- `blob_hash`: a copy of the blob's hash. the volume is not mounted.
The primary key is (`snapshot_id`, `blob_id`). `idx_snapshot_blobs_blob_id` ### 11. `uploads`
indexes `blob_id`.
The manifest is built from this table. Tracks blob upload metrics.
### `snapshot_mount_points` **Columns:**
The mount points that a snapshot's backup found. - `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
- `snapshot_id`: the snapshot. References `snapshots.id`. **Purpose:** Performance monitoring and tracking which blobs were newly created
- `path`: the absolute path of the directory. (uploaded) during each snapshot.
The primary key is (`snapshot_id`, `path`). ## Data Flow and Operations
A directory is recorded here when all of these hold: ### 1. Backup Process
- It is at or below a configured path. 1. **File Scanning**
- The backup found an entry in it that is not excluded. - `INSERT OR REPLACE INTO files` - Update file metadata
- It is on a different filesystem from its parent directory. - `SELECT * FROM files WHERE path = ?` - Check if file has changed
- `INSERT INTO snapshot_files` - Add file to current snapshot
- `INSERT OR IGNORE INTO snapshot_mount_points` - Record each directory on a
different filesystem from its parent that the walk found an entry in
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
The device number is read after the directory has been listed. An automount 4. **Upload**
point is a directory whose volume is mounted when it is first listed, so the - `UPDATE blobs SET uploaded_ts = ?, compressed_size = ?` - Mark blob as
number is that of the volume. An automount point whose volume cannot be mounted uploaded, with the size of the object in storage (the upload is skipped
is not recorded, because the backup finds no entry in it. when the blob is already stored)
- `INSERT INTO uploads` - Record upload metrics with snapshot_id
- `INSERT INTO snapshot_blobs` - Associate blob with snapshot
`snapshot create --prune` reads this table. It skips the prune when an earlier 5. **Snapshot Completion**
snapshot of the same name recorded a mount point that the new backup did not - `UPDATE snapshots SET completed_at = ?, stats...` - Finalize snapshot
find. That happens when the volume is not mounted. - Generate and upload blob manifest from `snapshot_blobs`
### `uploads` ### 2. Incremental Backup
One row for each blob that a backup uploaded. 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`
- `blob_hash`: the blob. References `blobs.blob_hash`. Primary key. 2. **Chunk Reuse**
- `snapshot_id`: the snapshot whose backup uploaded it. References - `SELECT * FROM blob_chunks WHERE chunk_hash = ?` - Find existing chunks
`snapshots.id`. - `INSERT INTO snapshot_blobs` - Reference existing blobs for unchanged
- `uploaded_at`: when the upload started. It is written as a date and time, not files
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`. ### 3. Snapshot Metadata Export
A blob that was already at the destination gets no row here. After a snapshot is completed:
### `local_meta`
Settings that belong to this copy of the index.
- `key`: the name of the setting. Primary key. 1. Copy database to temporary file
- `value`: its value. 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 only key is `storage_url`. A backup sets it once it has written the marker The `{remote-key}` directory name is a one-way hash of the human snapshot ID, so
file at the destination. No other command sets it. These commands refuse to run the ID is never written to the store in plaintext. The hash uses no secret, so a
when the config names a different destination: 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).
- `snapshot create` ### 4. Restore Process
- `snapshot purge`
- `snapshot remove`
- `prune`
- `remote nuke`
### `schema_migrations` The restore process doesn't use the local database. Instead:
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
The migrations that have run. ### 5. Pruning
1. **Identify Unreferenced Blobs**
- Query blobs not referenced by any remaining snapshot
- Delete from S3, once older than `prune_grace_period`, and from the local
database
2. **Delete Metadata Directories With No Manifest**
- Delete each directory under `metadata/` that has no `manifest.json.zst`
and no local snapshot record, once every object in it is older than
`prune_grace_period`
- `version`: the number of the migration. Primary key. ### 6. Incomplete Snapshot Cleanup
- `applied_at`: when it ran. It is written as a date and time, not as seconds.
Before each backup:
## How commands use the tables
1. Query incomplete snapshots (where `completed_at IS NULL`)
### Backup 2. Delete each one and all its associations, without checking S3 for its
metadata
A backup works on the tables in this order: 3. Clean up orphaned files, chunks, and blobs
- It saves the destination in `local_meta` when the index has none yet. It does ## Repository Pattern
so once it has written the marker file at the destination.
- It deletes unfinished snapshots, and the rows that no snapshot uses. Vaultik uses a repository pattern for database access:
- It creates a row in `snapshots`, with `completed_at` set to `NULL`.
- It deletes the `blobs` rows that were never uploaded. - `FileRepository` - CRUD operations for files and file metadata
- It walks each path. It compares each regular file and symlink with its row in - `ChunkRepository` - CRUD operations for content chunks
`files`. - `FileChunkRepository` - Manage file-to-chunk mappings
- It links each unchanged file to the snapshot in `snapshot_files`. - `ChunkFileRepository` - Manage chunk-to-file reverse mappings
- It writes a `files` row and a `snapshot_files` row for each directory. - `BlobRepository` - Manage blob lifecycle (creation, finalization, upload)
- It records each mount point in `snapshot_mount_points`. - `BlobChunkRepository` - Manage blob-to-chunk associations
- It reads each changed regular file and stores it. See below. A changed symlink - `SnapshotRepository` - Manage snapshots and their relationships
is recorded without being read. - `UploadRepository` - Track blob upload metrics
- It fills `snapshot_blobs` with every blob the snapshot uses.
- It stores the snapshot's totals in `snapshots`. Each repository provides methods like:
- It uploads the snapshot's metadata.
- It sets `completed_at`. - `Create()` - Insert new record
- `GetByID()` / `GetByPath()` / `GetByHash()` - Retrieve records
A file counts as changed when its row in `files` differs from the file on disk - `Update()` - Update existing records
in any of these: - `Delete()` - Remove records
- Specialized queries for each entity type (e.g., `DeleteOrphaned()`,
- size `GetIncompleteByHostname()`)
- modification time
- mode ## Transaction Management
- owner or group
- link target All database operations that modify multiple tables are wrapped in transactions:
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 {
// several repository calls that use tx // Multiple repository operations using tx
}) })
``` ```
The transaction commits when the function returns no error. Otherwise it is This ensures consistency, especially important for operations like:
rolled back.
## SQLite settings - Creating file-chunk mappings
- Associating chunks with blobs
- Updating snapshot statistics
- The index runs in WAL mode. A command that only reads, such as ## Performance Considerations
`snapshot list`, can read the index while a backup writes to it.
- A connection waits up to 10 seconds for a lock before it fails. 1. **Indexes**:
- Foreign keys are enforced. - Primary keys are automatically indexed
- Each open database uses one connection, so its queries run one at a time. - `idx_files_path` on `files(path)` for efficient file lookups
- Queries pass their values as parameters.
2. **Prepared Statements**: All queries use prepared statements for performance
and security
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
+289 -382
View File
@@ -1,181 +1,158 @@
# vaultik destination layout # Vaultik S3 Repository Structure
This document describes what `vaultik` stores at the destination. It covers the This document describes the structure and organization of data stored in the S3
layout, what the layout protects, and the risks it accepts. bucket by Vaultik.
The destination is the place the backups are stored. It is an S3 bucket and ## Overview
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.
## Terms Vaultik stores all backup data in an S3-compatible object store. The repository
consists of two main components, plus a marker file:
- **Snapshot**: one backup of the paths listed under one name in the config. 1. **Blobs** - The actual backup data (content-addressed, encrypted)
- **Snapshot ID**: the name of a snapshot, in the form 2. **Metadata** - Snapshot information and manifests (partially encrypted)
`<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 ## Directory Structure
``` ```
<destination>/ <bucket>/<prefix>/
├── vaultik-destination.txt ├── vaultik-destination.txt
├── blobs/ ├── blobs/
│ └── <hash[0:2]>/ │ └── <hash[0:2]>/
│ └── <hash[2:4]>/ │ └── <hash[2:4]>/
│ └── <hash> │ └── <full-hash>
└── metadata/ └── metadata/
└── <remote-key>/ └── <remote-key>/
├── db.zst.age ├── db.zst.age
└── manifest.json.zst └── manifest.json.zst
``` ```
The destination holds three things: 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).
- `vaultik-destination.txt`, the marker file ## Marker File (`vaultik-destination.txt`)
- `blobs/`, which holds the file contents
- `metadata/`, which holds one directory for each snapshot
## The marker file
Every backup writes `vaultik-destination.txt` before it uploads anything else.
The file holds one fixed line of text. It names no host, path or time.
The marker tells an emptied or missing destination apart from a new one. An
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.
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:
- `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.
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.
Every backup writes this file at the root before it uploads anything. It holds
one fixed line of plain text and names no host, path or time. While the local
index holds snapshots stored there, a destination that answers a look-up of the
marker with not found is treated as not present: `snapshot create` refuses to
write to it, and `prune`, `snapshot purge`, `snapshot remove`, `snapshot list`
and `remote nuke` behave as for a destination that cannot be listed. An
unplugged volume leaves an empty directory at its mount point, and without the
marker that directory would read as a destination whose snapshots are all gone.
`remote nuke` deletes the marker last. `remote nuke` deletes the marker last.
## Blobs When the look-up of the marker fails for any reason other than not found, the
check is skipped and the command goes on. S3 refuses it on every run to
credentials that allow only PUT, so a host with such credentials does not notice
a destination emptied since its last backup, and its next backup would leave out
the data its local index records as already stored. After the destination is
emptied, run `vaultik database delete` on such a host before it backs up again.
A blob holds the content of many chunks, one after another. ## Blobs Directory (`blobs/`)
- **Path**: `blobs/<first 2 characters>/<next 2 characters>/<full hash>`. ### Structure
- **Path format**: `blobs/<first-2-chars>/<next-2-chars>/<full-hash>`
- **Example**: - **Example**:
`blobs/ca/fe/cafebabe1234567890abcdef1234567890abcdef1234567890abcdef12345678`. `blobs/ca/fe/cafebabe1234567890abcdef1234567890abcdef1234567890abcdef12345678`
- **Format**: the chunk data, compressed with - **Sharding**: The two-level directory structure (using the first 4 characters
[zstd](https://github.com/facebook/zstd) and then encrypted with age. of the hash) prevents any single directory from containing too many objects
- **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. ### Content
### Blob names - **What it contains**: Packed collections of content-defined chunks from files
- **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.
A blob's name is `hex(SHA256(SHA256(uncompressed contents)))`. The contents are ### Why Encrypted
the chunk data, joined in order, before compression and encryption. The name is
not a hash of the stored bytes.
This has three consequences: Blobs contain the actual file data from backups and must be encrypted for
security. The content-addressing ensures deduplication while the encryption
ensures privacy.
- Checking a blob against its name needs the private key. The blob must be ## Metadata Directory (`metadata/`)
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. 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)).
### Uploads ### Snapshot ID Format
A backup looks a blob up by name before it uploads it. It does not upload a blob The human-readable snapshot ID is used in CLI arguments, log lines, and the
that is already there. local database. It is not written to the destination store.
On S3, when the destination refuses the look-up, the backup uploads the blob. An - **Format**: `<hostname>_<snapshot-name>_<RFC3339>` (or `<hostname>_<RFC3339>`
object with that name is then replaced by one that holds the same chunk data. 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
## Metadata 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.
Each snapshot has one directory under `metadata/`. The directory is named with ### Remote Key Derivation
the snapshot's remote key, not with its snapshot ID.
### Snapshot IDs 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`.
A snapshot ID has the form `<hostname>_<snapshot-name>_<timestamp>`. An example Worked 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:
- 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: - Directory:
`metadata/17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa/` `metadata/17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa/`
A listing of the destination therefore shows no hostname and no snapshot name. 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.
The hash cannot be reversed, but it uses no secret. Someone who guesses a ### Files in Each Snapshot Directory
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` #### `db.zst.age` - Encrypted Database
The snapshot's database. A restore needs it. - **What it contains**: Pruned binary SQLite database for this snapshot
- **Format**: Binary SQLite → Zstandard compressed → Age encrypted
- **Encryption**: Encrypted with Age
- **Purpose**: Contains full file metadata, chunk mappings, and all
relationships
- **Why encrypted**: Contains sensitive metadata like file paths, permissions,
and ownership
- **Contents**: a SQLite database. It is a copy of the local index without the #### `manifest.json.zst` - Unencrypted Blob Manifest
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. - **What it contains**: JSON list of all blob hashes referenced by this snapshot
[`DATAMODEL.md`](DATAMODEL.md) describes its tables. - **Format**: JSON → Zstandard compressed (NOT encrypted)
- **Encryption**: NOT encrypted
### `manifest.json.zst` - **Purpose**: Enables pruning operations without requiring decryption keys
- **Structure**:
The list of the blobs the snapshot uses.
- **Format**: JSON, compressed with zstd. It is not encrypted.
```json ```json
{ {
"snapshot_id": "17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa", "snapshot_id": "17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa",
"timestamp": "2025-06-01T12:04:31Z", "timestamp": "2025-06-01T12:00:00Z",
"blob_count": 2, "blob_count": 42,
"total_compressed_size": 57344, "total_compressed_size": 1048576,
"blobs": [ "blobs": [
{ {
"hash": "cafebabe1234567890abcdef1234567890abcdef1234567890abcdef12345678", "hash": "cafebabe1234567890abcdef1234567890abcdef1234567890abcdef12345678",
@@ -189,259 +166,189 @@ The list of the blobs the snapshot uses.
} }
``` ```
- `snapshot_id`: the remote key, not the snapshot ID. `snapshot_id` is the remote key (a hash), not the human ID; `timestamp` is
- `timestamp`: when the manifest was written, at the end of the backup. written in the clear.
- `blob_count`: the number of blobs.
- `total_compressed_size`: the total size of those blobs, in bytes. ### Why Manifest is Unencrypted
- `blobs`: the hash and the size of each blob.
The manifest must be readable without the private key to enable:
The manifest holds no file name and no path.
1. **Pruning operations** - Identifying unreferenced blobs for deletion
The manifest is not encrypted so that these commands work without the private 2. **Storage analysis** - Understanding space usage without decryption
key: 3. **Verification** - Checking blob existence without decryption
4. **Cross-snapshot deduplication analysis** - Finding shared blobs between
- `prune`, which must know which blobs are still in use snapshots
- `snapshot verify`, which checks that each blob is present
- `snapshot list` and `remote info`, which report what the destination holds 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
## What is protected file names, paths, or other decrypted metadata.
These are encrypted: ## Security Considerations
- file contents, in the blobs ### What's Encrypted
- in `db.zst.age`:
- file names and paths - **All file content** (in blobs)
- permissions and owners - **All file metadata** (paths, permissions, timestamps, ownership in
- modification times db.zst.age)
- which chunks make up which file - **File-to-chunk mappings** (in db.zst.age)
These are not encrypted: ### What's Not Encrypted
- the remote key of each snapshot - **The remote key** — directory names and the manifest `snapshot_id`, a one-way
- the time each backup finished, in the manifest hash of the snapshot ID (see [Remote Key Derivation](#remote-key-derivation))
- the hash and the compressed size of each blob, in the manifest - **The backup timestamp** (in manifest.json.zst)
- the number of blobs and their total size for each snapshot, in the manifest - **Blob hashes and their compressed sizes** (in manifest.json.zst)
- **Blob count and total compressed size per snapshot** (in manifest.json.zst)
Someone who can read the destination therefore learns these:
### Privacy Implications
- when each backup finished
- how many blobs each snapshot uses, and how large they are From the unencrypted data, an observer of the destination store can determine:
- which blobs two snapshots share
- whether a blob holds data they already have in full - **When each backup was taken** — not from the directory name, which is a
- whether a snapshot ID they guess exists one-way hash, but from the plaintext `timestamp` field in manifest.json.zst,
which is published in the clear
They do not learn these: - How many blobs each snapshot references, and the total compressed size
- The compressed size of each blob, and which blobs are shared between snapshots
- any hostname or snapshot name, unless they guess it (deduplication patterns)
- file names or paths - **Whether a guessed hostname and snapshot name are present** — the remote key
- file contents is an unkeyed hash, so an observer holding candidate names can hash each one
- permissions or owners and match it against the directory listing. The human ID is never published,
- the directory structure so it cannot be read off directly, but it can be confirmed by guessing.
- which chunks belong to which file
Together these give an observer a timing-and-size profile of every snapshot.
The times and sizes could not be hidden by encrypting the manifest. The This is an accepted, documented property of the format, not a defect: the
destination itself shows when each object was written and how large it is. 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
## Accepted risks times and per-object sizes stay visible at the storage layer on both `s3://` and
`file://` destinations regardless.
These are known properties of the format. They are written down so that you can
weigh them. An observer cannot determine:
### No proof of who wrote the data - 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
A restore and `snapshot verify --deep` prove two things. The data decrypts with ID, so the text is never published (though a guessed name can be confirmed, as
your private key, and it matches its hashes. They do not prove who wrote it. above)
- File names or paths
The hashes use no secret. Someone who knows a recipient's public key and can - File contents
replace objects at the destination can put a snapshot of their own there. The - File permissions or ownership
public key is not stored at the destination, but a backed-up machine that is - Directory structure
broken into has it. - Which chunks belong to which files
The defences are on the destination's side: ### Accepted Risks
- object versioning or object lock on the bucket These are known, deliberate properties of the format and the tooling, recorded
- credentials for the backed-up machine that cannot delete or overwrite so an operator can weigh them rather than discover them.
- running `prune` from a trusted machine
1. **No proof of authorship.** Restore and `verify --deep` prove that data
An S3 `PutObject` replaces an existing object. Permission to write alone decrypts with the age private key and matches its unkeyed content hashes.
therefore does not stop overwrites. 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.
### Sizes are visible The recipient string is not stored at the destination, but a compromised
backed-up host has it. Defences live on the destination side — bucket
An object's size follows exactly from the size of its compressed contents. 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
All the new chunks in one blob share one zstd stream. The stream looks back 8 `PutObject` overwrites an existing key, so PUT permission alone is not
MiB, or 4 MiB at compression levels 1 and 2. A blob is closed at the append-only.
`blob_size_limit` setting and at the end of each configured path. A chunk that 2. **Compression reveals sizes.** Blobs and `db.zst.age` are zstd-compressed
the local index records as uploaded is never packed again. 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
Data that an outsider writes compresses smaller when it matches a secret near it packed into one blob share a single zstd stream (8 MiB window, 4 MiB at
in the same blob, so the blob gets smaller. The blob's size therefore shows the compression levels 1-2), and a blob is closed at `blob_size_limit` and at the
outsider whether their data matched the secret. 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
This works only when all of these hold: 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
- The outsider can write into a backed-up file. directory as its own snapshot.
- The outsider can see the blob sizes at the destination. 3. **Chunking uses no secret.** The FastCDC parameters are fixed and public. The
- The outsider's data and the secret are in the same blob, no further apart than default 10 MB average yields chunks between 2.5 MB and 40 MB, and any file of
the stream looks back. 2.5 MB or less is a single chunk. At the default 10 GB `blob_size_limit` a
- The file keeps changing. 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
Back up a directory that outsiders can write to as a snapshot of its own. them.
4. **Decrypted data on local disk.** Several commands stage plaintext under
### Chunk boundaries use no secret `$TMPDIR`: `snapshot restore` writes decrypted blobs under
`vaultik-blobcache-*/` (no size cap) and the decrypted metadata database at
`vaultik` finds chunk boundaries with the FastCDC algorithm. Its parameters are `vaultik-restore-*/snapshot.db`; `verify --deep` writes that database at
fixed and public. `chunk_size` sets the average chunk size. No chunk is larger `vaultik-verify-*/snapshot.db`; `snapshot create` keeps a plaintext copy of
than four times this size. Only a file's last chunk can be smaller than a the index at `vaultik-snapshot-*/snapshot.db`. These files are created `0600`
quarter of it. With the default of 10 MB, those limits are 40 MB and 2.5 MB. A and removed on success, but a `kill -9` or a power loss leaves them behind —
file of 2.5 MB or less is one chunk. An empty file has no chunk. delete any leftover `vaultik-*` directory under `$TMPDIR` by hand. `$TMPDIR`
should be trusted to the same degree as the restore target.
At the default `blob_size_limit` of 10 GB, a full blob holds hundreds of chunks. 5. **Store permissions differ per command.** The backed-up host needs only PUT
The size of the blob then does not show the size of any one chunk. A limit close to run `snapshot create`: it writes blobs and metadata and neither reads nor
to `chunk_size` begins to show chunk sizes. deletes them. Other commands need more — `snapshot verify`,
`snapshot restore`, and `prune` list and read; `prune`, `snapshot purge`,
### Decrypted data on the local disk `snapshot remove`, and `remote nuke` also delete. The recommended cron line
uses `--prune`, which runs `prune` on the backed-up host, so granting that
These commands write unencrypted data to the temporary directory: 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.
- `snapshot restore` writes decrypted blobs under `vaultik-blobcache-*/`. There 6. **Changing recipients does not re-encrypt existing data.** Deduplicated
is no limit on their total size. chunks and same-named blobs already on the destination stay encrypted to the
- `snapshot restore` writes the decrypted database to recipients in force when they were written. A new snapshot that reuses them
`vaultik-restore-*/snapshot.db`. cannot be restored with a newly added recipient's key alone, because those
- `snapshot verify --deep` writes the decrypted database to reused objects were never encrypted to it. To make everything readable by a
`vaultik-verify-*/snapshot.db`. new key, run `vaultik database delete` and take a full backup to a fresh
- `snapshot create` writes a copy of the local index to destination or prefix.
`vaultik-snapshot-*/snapshot.db`. 7. **X25519 recipients only.** vaultik rejects age ssh and plugin recipients.
Long-lived ciphertext held by a third party (the destination operator) has no
These files are readable only by their owner, and are removed when the command fallback if X25519 is ever broken: there is no second recipient type and no
ends. A `kill -9` or a power loss leaves them behind. Delete any leftover post-quantum option.
`vaultik-*` entry in the temporary directory yourself.
## Consistency Guarantees
Trust the temporary directory as much as the directory you restore into.
1. **Blobs are immutable** - Once written, a blob is never modified
### Commands need different permissions 2. **Blobs are written before metadata** - A snapshot's metadata is only written
after all its blobs are successfully uploaded
`snapshot create` without `--prune` writes objects. On S3 it needs no other 3. **Metadata is written atomically** - Both db.zst.age and manifest.json.zst
permission. Other commands need more: are written as complete files
4. **A snapshot is marked complete in the local DB only after its metadata is
- These commands list and read: uploaded** - `finalizeSnapshotMetadata` runs `ExportSnapshotMetadata` first
- `snapshot list` and records completion (`MarkSnapshotComplete`) only once the export succeeds
- `snapshot verify` (see the backup data flow in [ARCHITECTURE.md](ARCHITECTURE.md)). A crash
- `snapshot restore` during the export therefore leaves the snapshot incomplete, so the next
- `remote info` backup's `PruneDatabase` drops it and re-backs-up its data, rather than
- These commands list and delete: leaving a completed-looking row in the local index with no matching metadata
- `snapshot purge` on the destination store. (A crash in the brief moment after the export
- `snapshot remove` succeeds but before completion is recorded leaves a fully-restorable snapshot
- `remote nuke` on the destination that the local index drops as incomplete on the next run;
- `prune` lists, reads and deletes. `snapshot list` then reports it honestly as remote-only, which is the safe
direction: the destination copy stays restorable.)
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 ## Pruning Safety
the look-up is refused.
The prune operation is safe because:
`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. 1. It keeps every blob listed in any snapshot's manifest and deletes only blobs
`snapshot remove --local-only` does not reach the destination. that no manifest references. Under `metadata/` it deletes only a directory
`snapshot remove --dry-run` deletes nothing. that has no `manifest.json.zst`, whose name is a remote key, and that no
local snapshot record claims
`snapshot create --prune` runs a prune on the backed-up machine. That machine 2. Manifests are unencrypted and can be read without keys
then holds credentials that can delete its own backups. To keep it to write-only 3. If any manifest cannot be downloaded or decoded, prune deletes nothing and
credentials, run `prune` from a separate, trusted machine. `prune` there deletes exits with an error, rather than treating that snapshot's blobs as
only data that no snapshot uses. It does not remove old snapshots. unreferenced
4. It deletes an unreferenced blob only once the blob is older than
### New recipients cannot read old data `prune_grace_period` (default `24h`), and a directory under `metadata/` with
no manifest only once every object in it is. A backup uploads its blobs and
Changing `age_recipients` does not re-encrypt what is already stored. A chunk its `db.zst.age` before its manifest, so a prune running meanwhile, on this
that the local index records as uploaded is not written again, so it stays host or another sharing the destination, leaves them as long as no backup run
encrypted to the earlier recipients. takes longer than the grace period. The grace period does not protect a blob
a backup reuses from an earlier run that stopped before its manifest: the
A new snapshot that uses such chunks cannot be restored with a new recipient's backup skips the upload of a blob already on the destination, so that blob
key alone. To make everything readable by a new key, run keeps the time of its first upload, and a prune before the backup's manifest
`vaultik database delete` and take a full backup to a new destination. is written can delete it; the new snapshot then cannot be restored.
`remote nuke` has no such check
### X25519 recipients only
## Restoration Requirements
`vaultik` accepts age X25519 public keys only. It rejects age SSH and plugin
recipients. To restore from a backup, you need:
The encrypted data is held by a third party for a long time. There is no second 1. **The Age private key** - To decrypt blobs and database
kind of recipient to fall back on if X25519 is broken, and no kind that resists 2. **The snapshot metadata** - Both files from the snapshot's metadata directory
a quantum computer. 3. **All referenced blobs** - As listed in the manifest
## Order of writes The restoration process:
A backup writes in a fixed order: 1. Download and decrypt the database dump to understand file structure
2. Download and decrypt the required blobs
- the marker file 3. Reconstruct files from their chunks
- the blobs 4. Restore file metadata (permissions, timestamps, etc.)
- `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.
+1 -59
View File
@@ -19,62 +19,4 @@ into `main` is the owner's.
# Future Steps # Future Steps
Work planned after 1.0, loosely ordered by priority. Work planned after 1.0 is listed in the README [roadmap](../README.md#roadmap).
## 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.
+1 -1
View File
@@ -337,7 +337,7 @@ func runVaultikApp(
}, },
Mode: mode, Mode: mode,
}, op, func(err error) { }, op, func(err error) {
log.Info(failMsg, "error", err) log.Error(failMsg, "error", err)
ReportErrorf("%s: %v", failMsg, err) ReportErrorf("%s: %v", failMsg, err)
}) })
} }
+9 -32
View File
@@ -49,14 +49,8 @@ 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")
) )
// storageURLKey is the config key that selects the storage backend.
const storageURLKey = "storage_url"
// macOS is the value of runtime.GOOS on macOS. // macOS is the value of runtime.GOOS on macOS.
const macOS = "darwin" const macOS = "darwin"
@@ -318,14 +312,12 @@ 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 the settings it lists. If a config file already exists at the target for every setting. If a config file already exists at the target path,
path, the command refuses to overwrite it. The file is created with mode the command refuses to overwrite it.
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
config.yml in the platform default config directory the platform default config directory (e.g. ~/Library/Application Support/
(~/Library/Application Support/vaultik/ on macOS, ~/.config/vaultik/ on on macOS, ~/.config/ on Linux, /etc/vaultik/ as root).`,
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()
@@ -364,7 +356,7 @@ 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 (vi when it is not set)", Short: "Open the config file in $EDITOR",
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()
@@ -446,9 +438,9 @@ 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. Comments in the file are kept. Blank lines can be the file back, preserving comments and formatting. Missing keys along
removed. Missing keys along the path are created: as a list when the the path are created: as a list when the next path element is a
next path element is a number, otherwise as a map. 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
@@ -521,7 +513,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 wouldNotLoadError(key, err) return fmt.Errorf("%w: %w", errWouldNotLoad, err)
} }
err = os.WriteFile(path, data, configFileMode) err = os.WriteFile(path, data, configFileMode)
@@ -545,21 +537,6 @@ 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
+2 -96
View File
@@ -2,7 +2,6 @@ package cli //nolint:testpackage // exercises unexported yamlPathGet/yamlPathSet
import ( import (
"bytes" "bytes"
"errors"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
@@ -96,8 +95,8 @@ func TestDefaultConfigOnLinux(t *testing.T) {
// testRecipient is a valid age public key. // testRecipient is a valid age public key.
const testRecipient = "age1278m9q7dp3chsh2dcy82qk27v047zywyvtxwnj4cvt0z65jw6a7q5dqhfj" const testRecipient = "age1278m9q7dp3chsh2dcy82qk27v047zywyvtxwnj4cvt0z65jw6a7q5dqhfj"
// TestConfigSetRecipientOnFreshConfig follows the README's getting started // TestConfigSetRecipientOnFreshConfig follows the README quickstart: on the
// steps: on the file `config init` writes, `config set age_recipients.0` and // 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.
@@ -153,21 +152,6 @@ 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:
@@ -274,14 +258,6 @@ func TestConfigSetKeepsFileThatWouldNotLoad(t *testing.T) {
"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"},
@@ -367,76 +343,6 @@ 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 // TestConfigSetWritesEnvSection checks that config set writes a key under
// the top-level env section, whose keys are not settings, on a config that // the top-level env section, whose keys are not settings, on a config that
// loads. // loads.
-4
View File
@@ -85,10 +85,6 @@ 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...)
} }
-205
View File
@@ -1,205 +0,0 @@
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()
}
@@ -61,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 once, // that cannot be decoded, and checks that the failure is reported as the
// as the prune's and not as a failed snapshot creation. // 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) {
@@ -83,9 +83,6 @@ 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,
@@ -96,8 +93,7 @@ func TestEntrySnapshotCreateReportsPostBackupPruneFailure(t *testing.T) {
// empties the source directory, as a volume that is not mounted leaves // empties the source directory, as a volume that is not mounted leaves
// its mount point, and runs `snapshot create --prune`. The command exits // its mount point, and runs `snapshot create --prune`. The command exits
// non-zero and reports the backup as completed, not as a failed snapshot // non-zero and reports the backup as completed, not as a failed snapshot
// creation. The error is printed once. See // creation. See https://git.eeqj.de/sneak/vaultik/issues/388.
// https://git.eeqj.de/sneak/vaultik/issues/388.
// //
//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 TestEntrySnapshotCreatePruneExitsNonZeroWhenSourceIsEmpty(t *testing.T) { func TestEntrySnapshotCreatePruneExitsNonZeroWhenSourceIsEmpty(t *testing.T) {
@@ -118,9 +114,6 @@ func TestEntrySnapshotCreatePruneExitsNonZeroWhenSourceIsEmpty(t *testing.T) {
"Backup completed, but 1 source path(s) were empty") "Backup completed, but 1 source path(s) were empty")
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, "the older snapshot is kept") assert.Len(t, snapshots, 2, "the older snapshot is kept")
@@ -131,8 +124,7 @@ func TestEntrySnapshotCreatePruneExitsNonZeroWhenSourceIsEmpty(t *testing.T) {
// directory as a mount point, and runs `snapshot create --prune`, which // directory as a mount point, and runs `snapshot create --prune`, which
// does not find it as one. The command exits non-zero, reports the // 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 // backup as completed, not as a failed snapshot creation, and keeps the
// older snapshot. The error is printed once. See // older snapshot. See https://git.eeqj.de/sneak/vaultik/issues/395.
// https://git.eeqj.de/sneak/vaultik/issues/395.
// //
//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 TestEntrySnapshotCreatePruneExitsNonZeroWhenMountPointIsMissing( func TestEntrySnapshotCreatePruneExitsNonZeroWhenMountPointIsMissing(
@@ -167,9 +159,6 @@ func TestEntrySnapshotCreatePruneExitsNonZeroWhenMountPointIsMissing(
"an earlier snapshot recorded were not found") "an earlier snapshot recorded were not found")
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, "the older snapshot is kept") assert.Len(t, snapshots, 2, "the older snapshot is kept")
+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.Info("Failed to show info", "error", err) log.Error("Failed to show info", "error", err)
ReportErrorf("Failed to show info: %v", err) ReportErrorf("Failed to show info: %v", err)
}) })
}, },
+2 -6
View File
@@ -17,11 +17,7 @@ func NewPruneCommand() *cobra.Command {
unreferenced blobs and directories under metadata/ with no manifest unreferenced blobs and directories under metadata/ with no manifest
from the backup destination store. from the backup destination store.
Needs permission to list, read and delete objects on the destination Local cleanup drops incomplete snapshots and any files, chunks, or
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
@@ -64,7 +60,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.Info("Prune operation failed", "error", err) log.Error("Prune operation failed", "error", err)
ReportErrorf("Prune failed: %v", err) ReportErrorf("Prune failed: %v", err)
}) })
}, },
+6 -11
View File
@@ -40,13 +40,11 @@ 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, every blob and the destination Long: `Removes every snapshot's metadata and every blob from remote
marker from the backup destination store, and each removed snapshot from storage. After this command completes successfully the bucket prefix is
the local index. After this command completes successfully the backup empty and the next backup starts from scratch.
destination store is empty and the next backup starts from scratch.
This is destructive and irreversible. Requires --force, and permission This is destructive and irreversible. Requires --force.`,
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 {
@@ -77,10 +75,7 @@ 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
@@ -103,7 +98,7 @@ need the age private key.`,
}, 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.Info("Failed to get remote info", "error", err) log.Error("Failed to get remote info", "error", err)
ReportErrorf("Failed to get remote info: %v", err) ReportErrorf("Failed to get remote info: %v", err)
}) })
}, },
+5 -9
View File
@@ -35,10 +35,9 @@ 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 an incremental backup tool that encrypts data to age public Long: `vaultik is a secure incremental backup tool that encrypts data using age
keys and uploads it to an S3-compatible object store, a local directory or public keys and uploads to S3-compatible storage. No private keys are needed
an rclone remote. The backed-up machine needs no private key: only on the source system.`,
'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
@@ -51,16 +50,13 @@ an rclone remote. The backed-up machine needs no private key: only
// 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, then the platform "+ "Path to config file (default: $VAULTIK_CONFIG or platform config dir)")
"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,
"Leave out the banner, status lines, tables and reports (warnings, "+ "Suppress non-error output")
"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 "+
+31 -77
View File
@@ -60,22 +60,6 @@ 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{}
@@ -83,8 +67,15 @@ 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: snapshotCreateLong, Long: `Creates new snapshots of the configured directories.
Args: cobra.ArbitraryArgs,
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,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
err := opts.Validate() err := opts.Validate()
if err != nil { if err != nil {
@@ -118,7 +109,7 @@ func newSnapshotCreateCommand() *cobra.Command {
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.Info("Post-backup prune failed", "error", err) log.Error("Post-backup prune failed", "error", err)
ReportErrorf("Backup completed, but %v", err) ReportErrorf("Backup completed, but %v", err)
return return
@@ -127,13 +118,13 @@ func newSnapshotCreateCommand() *cobra.Command {
if errors.Is(err, vaultik.ErrFilesUnreadable) || if errors.Is(err, vaultik.ErrFilesUnreadable) ||
errors.Is(err, vaultik.ErrSourcePathEmpty) || errors.Is(err, vaultik.ErrSourcePathEmpty) ||
errors.Is(err, vaultik.ErrVolumeNotMounted) { errors.Is(err, vaultik.ErrVolumeNotMounted) {
log.Info("Backup completed with an error", "error", err) log.Error("Backup completed with an error", "error", err)
ReportErrorf("Backup completed, but %v", err) ReportErrorf("Backup completed, but %v", err)
return return
} }
log.Info("Snapshot creation failed", "error", err) log.Error("Snapshot creation failed", "error", err)
ReportErrorf("Snapshot creation failed: %v", err) ReportErrorf("Snapshot creation failed: %v", err)
}) })
}, },
@@ -165,15 +156,8 @@ func newSnapshotListCommand() *cobra.Command {
Use: "list", Use: "list",
Aliases: []string{"ls"}, Aliases: []string{"ls"},
Short: "List all snapshots", Short: "List all snapshots",
Long: `Lists the snapshots in the local index and on the backup destination Long: "Lists all snapshots with their ID, timestamp, and compressed size",
store, with their ID, timestamp, and sizes. Args: cobra.NoArgs,
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,
RunE: func(cmd *cobra.Command, _ []string) error { RunE: func(cmd *cobra.Command, _ []string) error {
return runVaultikApp(cmd, readOnly, false, return runVaultikApp(cmd, readOnly, false,
"Failed to list snapshots", "Failed to list snapshots",
@@ -195,19 +179,11 @@ 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. Exactly one of Long: `Removes snapshots based on age or count criteria.
--keep-latest and --older-than is required.
Retention is per-snapshot-name: --keep-latest keeps the latest snapshot of Retention is per-snapshot-name: --keep-latest keeps the latest of each
each name, not the latest globally. Use --snapshot to restrict the configured snapshot name, not the latest globally. Use --snapshot to
operation to specific snapshot names. restrict the 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
@@ -246,30 +222,14 @@ 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 Long: "Checks that every blob the snapshot's manifest lists is present\n" +
in storage with the size the manifest records, and that the "in storage with the size the manifest records, and that the\n" +
snapshot's encrypted database is present. It does not read blob "snapshot's encrypted database is present. It does not read blob\n" +
contents and does not need the age private key. "contents; use --deep to download, decrypt, and re-hash every blob\n" +
"to detect corruption -- integrity, not who wrote it.\n\n" +
Needs permission to read the backup destination store, and to list it "The snapshot may be named by its ID or, on a host with no local\n" +
when given a remote key, or a snapshot ID that is not in the local "index, by the remote key that 'snapshot list' prints for a\n" +
index. "remote-only snapshot (an unambiguous leading part is enough).",
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]
@@ -298,16 +258,13 @@ remote-only snapshot (an unambiguous leading part is enough).`,
return return
} }
log.Info("Verification failed", "error", err) log.Error("Verification failed", "error", err)
ReportErrorf("Verification failed: %v", err) ReportErrorf("Verification failed: %v", err)
}) })
}, },
} }
cmd.Flags().BoolVar(&opts.Deep, "deep", false, cmd.Flags().BoolVar(&opts.Deep, "deep", false, "Download and verify blob hashes")
"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
@@ -331,10 +288,8 @@ know about), which is what 'vaultik prune' does. The command prints the
'vaultik prune' invocation to run as a follow-up only when it removed the '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. snapshot's metadata from the destination store, and never under --json.
The argument must be a snapshot ID: a remote key is not accepted. An ID An ID that names no snapshot in the local index or on the destination
that names no snapshot in the local index or on the destination store is store is an error.
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
@@ -363,8 +318,7 @@ 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; needs no permission "+ "Show what would be removed without removing")
"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,
+5 -14
View File
@@ -37,22 +37,13 @@ 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).
Each file is restored under its original absolute path inside the target Requires the age private key in the VAULTIK_AGE_SECRET_KEY environment
directory. File owners are restored only when the command runs as root. variable. The variable may hold the whole age-keygen file (comments and
all of its identities are accepted); read it from the file rather than
Requires the age private key, in the VAULTIK_AGE_SECRET_KEY environment typing the key, so it does not land in your shell history:
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
@@ -113,7 +104,7 @@ func runRestore(cmd *cobra.Command, args []string, opts *RestoreOptions) error {
SkipErrors: rootFlags.SkipErrors, SkipErrors: rootFlags.SkipErrors,
}) })
}, func(err error) { }, func(err error) {
log.Info("Restore operation failed", "error", err) log.Error("Restore operation failed", "error", err)
ReportErrorf("Restore failed: %v", err) ReportErrorf("Restore failed: %v", err)
}) })
} }
+6 -11
View File
@@ -79,13 +79,8 @@ var (
"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.
@@ -529,11 +524,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
@@ -560,11 +555,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
+3 -5
View File
@@ -1875,12 +1875,10 @@ 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 {
// The record is at info level: the returned error ends the backup // Only an interrupt cancels ctx, and the user is told about it,
// and is printed as its ERROR: line. Only an interrupt cancels // so an upload it stopped is not logged as a storage failure.
// ctx, and the user is told about it, so an upload it stopped is
// not logged as a storage failure.
if ctx.Err() == nil { if ctx.Err() == nil {
log.Info("Failed to upload blob", "hash", finishedBlob.Hash, "error", err) log.Error("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)
+6 -6
View File
@@ -37,9 +37,9 @@ func uploadTestBlob(ctx context.Context, store storage.Storer) error {
} }
// captureLog runs fn and returns what the logger wrote to stderr // captureLog runs fn and returns what the logger wrote to stderr
// meanwhile, at the --verbose level. The logger writes to the os.Stderr // meanwhile. The logger writes to the os.Stderr it finds when it is
// it finds when it is initialized, so os.Stderr is pointed at a file // initialized, so os.Stderr is pointed at a file first. Not
// first. Not parallel-safe: os.Stderr and the logger are process-global. // parallel-safe: os.Stderr and the logger are process-global.
func captureLog(t *testing.T, fn func()) string { func captureLog(t *testing.T, fn func()) string {
t.Helper() t.Helper()
@@ -53,7 +53,7 @@ func captureLog(t *testing.T, fn func()) string {
previous := os.Stderr previous := os.Stderr
os.Stderr = stderrFile os.Stderr = stderrFile
log.Initialize(log.Config{Verbose: true}) log.Initialize(log.Config{})
fn() fn()
@@ -107,10 +107,10 @@ func TestInterruptedUploadIsNotLoggedAsError(t *testing.T) {
} }
} }
// An upload that fails for any other reason is still logged. // An upload that fails for any other reason is still logged as an error.
// //
//nolint:paralleltest // captureLog replaces os.Stderr //nolint:paralleltest // captureLog replaces os.Stderr
func TestFailedUploadIsLogged(t *testing.T) { func TestFailedUploadIsLoggedAsError(t *testing.T) {
inner, err := storage.NewFileStorer(t.TempDir()) inner, err := storage.NewFileStorer(t.TempDir())
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
+1 -1
View File
@@ -161,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 layout in docs/REPOSTRUCTURE.md, with the prefix joined to // writes: the README's remote storage layout, 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
+3 -3
View File
@@ -667,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. A fatal failure is not logged here: the returned // dropped from the plan.
// 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)