Author SHA1 Message Date
clawbot eb5b4f9fdf Do not prune when a backup misses an earlier mount point (closes #395)
check / check (push) Canceled after 0s
snapshot create --prune purged the older snapshots holding a volume's
files when the volume, mounted below a configured path, was not
mounted: its empty mount point read as deleted files.

The scan now records in the local index each directory at or below a
configured path that is on a different device from its parent
directory. Under --prune, when an earlier snapshot of that name
recorded such a directory and this run does not find it on a different
device, the run skips the prune, keeps every older snapshot, names the
directory and exits non-zero, as for an empty configured path. For a
volume retired, or a directory no longer backed up, on purpose, the
older snapshots are removed with snapshot purge.

Model: opus-5-5
2026-10-15 03:01:20 +00:00
20 changed files with 1939 additions and 2902 deletions
+394 -384
View File
@@ -1,401 +1,411 @@
# 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 data model, type instantiation, and the relationships between core modules.
things:
- 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).
- [docs/DATAMODEL.md](docs/DATAMODEL.md) describes the local index. ## Data Flow
- [docs/REPOSTRUCTURE.md](docs/REPOSTRUCTURE.md) describes the layout of the
destination.
## Terms
- **Snapshot**: one backup of the paths listed under one name in the config.
- **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.
- **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.
[docs/DATAMODEL.md](docs/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.
- `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 keeps two data-changing commands from
running at once.
- `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](docs/REPOSTRUCTURE.md#blobs-directory-blobs))
- `UncompressedSize`: Total raw chunk data before compression
- `CompressedSize`: Size after zstd compression and age encryption
- `CreatedTS`, `FinishedTS`, `UploadedTS`: Lifecycle timestamps
Blob creation process:
1. Chunks are accumulated (up to MaxBlobSize, typically 10GB)
2. As each chunk is added, its uncompressed bytes are fed to a running SHA-256
3. Concurrently, the same bytes are compressed with zstd, then encrypted with age (recipients configured in config), and written to a temporary file
4. On finalize, the blob's name is the double SHA-256 of the uncompressed contents — `hex(SHA256(SHA256(...)))` — not a hash of the compressed, encrypted bytes
5. The finished file is uploaded to `blobs/{hash[0:2]}/{hash[2:4]}/{hash}` and then deleted
A backup needs free temporary space, because each blob is written whole to a temporary file before it is uploaded (up to about `blob_size_limit`; an rclone destination that cannot stream uploads needs about twice that) and the metadata export writes copies of the local index. Temporary files go to `$TMPDIR` (default `/tmp`); with `TMPDIR` unset, SQLite writes one of those copies to `/var/tmp`.
#### BlobChunk (`database.BlobChunk`)
Maps chunks to their position within blobs:
- `BlobID`: Reference to the blob
- `ChunkHash`: Reference to the chunk
- `Offset`: Byte offset within the uncompressed blob
- `Length`: Chunk size
#### Snapshot (`database.Snapshot`)
Represents a point-in-time backup:
- `ID`: Format is `{hostname}_{snapshot-name}_{RFC3339}` (e.g. `server1_home_2025-06-01T12:00:00Z`)
- Tracks file count, chunk count, blob count, sizes, compression ratio
- `CompletedAt`: Null until snapshot finishes successfully
#### SnapshotFile / SnapshotBlob
Join tables linking snapshots to their files and blobs.
### Relationship Summary
```
Snapshot 1──────────▶ N SnapshotFile N ◀────────── 1 File
Snapshot 1──────────▶ N SnapshotBlob N ◀────────── 1 Blob
File 1──────────▶ N FileChunk N ◀────────── 1 Chunk
Blob 1──────────▶ N BlobChunk N ◀────────── 1 Chunk
```
## Type Instantiation
### Application Startup
The CLI uses fx for dependency injection. Here's the instantiation order:
```go ```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
- `config.Module` provides `*config.Config`, from `config.Load`. #### 1. Config (`config.Config`)
- `database.Module` provides `*database.DB` and `*database.Repositories`. It - **Created by**: `config.Module` via `config.LoadConfig()`
creates the index file with mode `0600` when it does not exist. - **When**: Application startup (fx DI)
- `storage.Module` provides the `storage.Storer` that the config selects. - **Contains**: All configuration from YAML file (S3 credentials, encryption keys, paths, etc.)
- `snapshot.Module` provides the `snapshot.ScannerFactory` and the
`*snapshot.SnapshotManager`. #### 2. Database (`database.DB`)
- `vaultik.New` provides `*vaultik.Vaultik`. - **Created by**: `database.Module` via `database.New()`
- **When**: Application startup (fx DI)
A command runs through `cli.RunOperation`. It works like this: - **Contains**: SQLite connection, path reference
- `RunWithApp` takes the lock when the command changes data, and then starts the #### 3. Repositories (`database.Repositories`)
application. - **Created by**: `database.Module` via `database.NewRepositories()`
- The command's function runs in its own goroutine. - **When**: Application startup (fx DI)
- When the function returns, it asks the application to shut down. - **Contains**: All repository interfaces (Files, Chunks, Blobs, Snapshots, etc.)
- 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. #### 4. Vaultik (`vaultik.Vaultik`)
- The wait ends after 30 seconds at most. - **Created by**: `vaultik.New(VaultikParams)`
- **When**: Application startup (fx DI)
The `config` commands, `database delete`, `completion` and `version` do not - **Contains**: All dependencies for backup operations
build the application.
```go
## Backup type Vaultik struct {
Globals *globals.Globals
`Vaultik.CreateSnapshot` runs a backup. It does these steps in order: Config *config.Config
DB *database.DB
- `EnsureStorageBinding` checks that the local index belongs to the configured Repositories *database.Repositories
destination. Storage storage.Storer
- `markDestination` checks the marker file and then writes it. ScannerFactory snapshot.ScannerFactory
- `PruneDatabase` removes unfinished snapshots and unused records from the SnapshotManager *snapshot.SnapshotManager
local index. Shutdowner fx.Shutdowner
- `createNamedSnapshot` runs once for each snapshot name. Fs afero.Fs
- With `--prune`, `runPostBackupPrune` removes old snapshots and unused data. ctx context.Context
cancel context.CancelFunc
`CreateSnapshot` calls `skipPostBackupPrune` instead of `runPostBackupPrune` in }
these cases: ```
- `--skip-errors` skipped a file or directory that could not be read. #### 5. SnapshotManager (`snapshot.SnapshotManager`)
- A configured path held nothing. - **Created by**: `snapshot.Module` via `snapshot.NewSnapshotManager()`
- An earlier snapshot of the same name found a mount point that this backup did - **When**: Application startup (fx DI)
not find. - **Responsibility**: Creates/completes snapshots, exports metadata to S3
`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 - **Created by**: `ScannerFactory(ScannerParams)`
stored. - **When**: Each `CreateSnapshot()` call
- **Contains**: Chunker, Packer, progress reporter
### One snapshot
```go
`createNamedSnapshot` does these steps: // vaultik/snapshot.go: CreateSnapshot()
scanner := v.ScannerFactory(snapshot.ScannerParams{
- `resolveSnapshotPaths` makes each configured path absolute and resolves EnableProgress: !opts.Cron,
symlinks. Fs: v.Fs,
- `SnapshotManager.CreateSnapshotWithName` creates the snapshot record. The })
record is not yet marked complete. ```
- `Scanner.Scan` backs up each path in turn.
- `finalizeSnapshotMetadata` uploads the snapshot's metadata and marks the #### 7. Chunker (`chunker.Chunker`)
snapshot complete. - **Created by**: `chunker.NewChunker(avgChunkSize)`
- **When**: Inside `snapshot.NewScanner()`
A new `Scanner` is created for each snapshot, from `ScannerFactory`. It owns a - **Configuration**:
`Chunker` and a `Packer`. - `avgChunkSize`: From config (default 10MB)
- `minChunkSize`: avgChunkSize / 4
### Scanning one path - `maxChunkSize`: avgChunkSize * 4
`Scanner.Scan` works in these steps: #### 8. Packer (`blob.Packer`)
- **Created by**: `blob.NewPacker(PackerConfig)`
- `repairInterruptedBlobs` deletes the records of blobs whose upload never - **When**: Inside `snapshot.NewScanner()`
finished. Their data is then stored again. - **Configuration**:
- `loadDatabaseState` loads the known files and chunks into memory. - `MaxBlobSize`: Maximum blob size before finalization (typically 10GB)
- `scanPhase` walks the path. It sorts each entry into 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. ```go
- `recordDirectories` records every directory, and the mount points found. // snapshot/scanner.go: NewScanner()
- `processPhase` reads each changed file and stores it. packerCfg := blob.PackerConfig{
MaxBlobSize: cfg.MaxBlobSize,
The walk handles each kind of entry differently: CompressionLevel: cfg.CompressionLevel,
Recipients: cfg.AgeRecipients,
- A regular file or a symlink is compared with its record in the index. Repositories: cfg.Repositories,
- A directory is recorded with its metadata. It has no content to store. Fs: cfg.FS,
- 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 packer, err := blob.NewPacker(packerCfg)
everything in it. ```
### Deduplication ## Module Responsibilities
Data is stored once at two levels. ### `internal/cli`
Entry point for fx application. Combines all modules and handles signal interrupts.
- **Files.** A file is not read again when its record in the index still matches
it. Key functions:
- **Chunks.** A chunk is named by the SHA-256 hash of its content. A chunk whose - `NewApp(AppOptions)` → Creates fx.App with all modules
hash is already in the index is not stored again. - `RunApp(ctx, app)` → Starts app, handles graceful shutdown
- `RunWithApp(ctx, opts)` → Convenience wrapper
A file's record matches when all of these are unchanged:
### `internal/vaultik`
- the size Main orchestrator containing all dependencies and command implementations.
- the modification time
- the mode Key methods:
- the owner and group - `New(VaultikParams)` → Constructor (fx DI)
- the link target - `CreateSnapshot(opts)` → Main backup operation
- `ListSnapshots(jsonOutput)` → List available snapshots
One case overrides the file check. A file is read again when it lists a chunk - `VerifySnapshot(id, deep)` → Verify snapshot integrity
that no uploaded blob holds. This happens when the only snapshot that used the - `PurgeSnapshots(...)` → Remove old snapshots
blob holding that chunk is removed. `loadFilesToRechunk` finds these files.
### `internal/chunker`
### Chunks Content-defined chunking using FastCDC algorithm.
`chunker.Chunker` cuts a file with the FastCDC algorithm. The cut points depend Key types:
on the content, so an insertion early in a file moves only nearby chunks. - `Chunk` → Hash, Data, Offset, Size
- `Chunker` → avgChunkSize, minChunkSize, maxChunkSize
`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 Key methods:
the default of 10 MB, those limits are 40 MB and 2.5 MB. - `NewChunker(avgChunkSize)` → Constructor
- `ChunkReaderStreaming(reader, callback)` → Stream chunks with callback (preferred)
`ChunkReaderStreaming` passes each chunk to a callback as it is found. The whole - `ChunkReader(reader)` → Return all chunks at once (memory-intensive)
file is never held in memory.
### `internal/blob`
### Blobs Blob packing: accumulates chunks, compresses, encrypts, tracks metadata.
`blob.Packer` collects new chunks into a blob. For each blob it does this: Key types:
- `Packer` → Thread-safe blob accumulator
- It gives the blob a UUID and creates a temporary file. - `ChunkRef` → Hash + Data for adding to packer
- It adds each chunk's bytes to a running SHA-256 hash. - `FinishedBlob` → Completed blob ready for upload
- In the same pass it compresses the bytes with zstd and encrypts them with - `BlobWithReader` → FinishedBlob + io.Reader for streaming upload
age. The result goes to the temporary file.
- It records each chunk's offset and length within the blob. Key methods:
- It closes the blob when the next chunk would pass `blob_size_limit`, and at - `NewPacker(PackerConfig)` → Constructor
the end of each path. - `AddChunk(ChunkRef)` → Add chunk to current blob
- `FinalizeBlob()` → Compress, encrypt, hash current blob
The blob's name is `hex(SHA256(SHA256(uncompressed contents)))`. It is computed - `Flush()` → Finalize any in-progress blob
from the chunk data before compression and encryption. - `SetBlobHandler(func)` → Set callback for upload
[docs/REPOSTRUCTURE.md](docs/REPOSTRUCTURE.md#blobs) says what follows from
that. ### `internal/snapshot`
When a blob is closed, `Scanner.handleBlobReady` stores it: #### Scanner
Orchestrates the backup process for a directory.
- It looks the blob up at the destination. A blob that is already there is not
uploaded again. Key methods:
- Otherwise it uploads the temporary file to - `NewScanner(ScannerConfig)` → Constructor (creates Chunker + Packer)
`blobs/<hash[0:2]>/<hash[2:4]>/<hash>`. - `Scan(ctx, path, snapshotID)` → Main scan operation
- It records the upload in the index and links the blob to the snapshot.
- It deletes the temporary file. Scan phases:
1. **Phase 1**: Walk directory, identify files needing processing
A file's record is written to the index only after every blob that holds its 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
chunks is uploaded. 3. **Phase 2**: Process files (chunk → pack → upload)
### Snapshot metadata #### SnapshotManager
Manages snapshot lifecycle and metadata export.
`finalizeSnapshotMetadata` does these steps in order:
Key methods:
- `PopulateSnapshotBlobs` records every blob the snapshot uses. - `CreateSnapshot(ctx, hostname, version, commit)` → Create snapshot record
- `UpdateSnapshotStatsExtended` stores the snapshot's totals. - `PopulateSnapshotBlobs(ctx, snapshotID)` → Record every blob the snapshot references
- `ExportSnapshotMetadata` uploads the metadata. - `ExportSnapshotMetadata(ctx, dbPath, snapshotID)` → Export to S3
- `MarkSnapshotComplete` marks the snapshot complete. - `MarkSnapshotComplete(ctx, snapshotID)` → Record completion, only after a successful export
- `CompleteSnapshot(ctx, snapshotID)` → Convenience: populate blobs, then mark complete (no export between)
Completion is recorded last on purpose. A backup that stops during the upload
leaves the snapshot unfinished, and the next backup removes it and stores its ### `internal/database`
data again. SQLite database for local index. Single-writer mode for thread safety.
`ExportSnapshotMetadata` works on a copy of the index, never on the index Key types:
itself: - `DB` → Database connection wrapper
- `Repositories` → Collection of all repository interfaces
- 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 Repository interfaces:
files and blobs that this snapshot does not use. It then deletes each chunk - `FilesRepository` → CRUD for File records
that no remaining file and no remaining blob uses. - `ChunksRepository` → CRUD for Chunk records
- It keeps the `local_meta` and `schema_migrations` tables as they are. The - `BlobsRepository` → CRUD for Blob records
uploaded database therefore names the destination, and lists the migrations - `SnapshotsRepository` → CRUD for Snapshot records
that have run. - Plus join table repositories (FileChunks, BlobChunks, etc.)
- It runs `VACUUM`, so that no deleted row is left in the file.
- It compresses the copy with zstd and encrypts it with age. ## Snapshot Creation Flow
- It builds the manifest from the copy.
- It uploads `metadata/<remote-key>/db.zst.age`, and then ```
`metadata/<remote-key>/manifest.json.zst`. CreateSnapshot(opts)
│
The manifest is uploaded last. A directory under `metadata/` with no manifest ├─► markDestination() // Check the marker (see Storage Layout in S3), then write it
therefore marks a backup that did not finish. │
├─► PruneDatabase() // Critical: avoid dedup errors
## Restore │
├─► SnapshotManager.CreateSnapshot() // Create DB record
`Vaultik.Restore` does these steps: │
├─► For each source directory:
- `restoreIdentities` parses the age private key. A missing or malformed key │ │
stops the restore before anything is downloaded. │ ├─► scanner.Scan(ctx, path, snapshotID)
- `downloadSnapshotDB` downloads and decrypts the snapshot's database into a │ │ │
temporary directory. │ │ ├─► Phase 1: scanPhase()
- `getFilesToRestore` selects the files, from the paths given. │ │ │ Walk directory
- `restoreAllFiles` downloads blobs and writes the files. │ │ │ Check file metadata changes
- With `--verify`, `verifyRestoredFiles` reads each restored file and checks its │ │ │ Build list of files to process
chunk hashes. │ │ │
│ │ ├─► Phase 1b: detectDeletedFiles()
The restore does not read the local index or the manifest. It finds each file's │ │ │
chunks and blobs through the downloaded database. │ │ └─► Phase 2: processPhase()
│ │ For each file:
### Checks on the downloaded database │ │ chunker.ChunkReaderStreaming()
│ │ For each chunk:
Decryption shows that a database is readable. It does not show that the │ │ packer.AddChunk()
database is honest. The restore therefore checks it: │ │ If blob full → FinalizeBlob()
│ │ → handleBlobReady()
- `database.OpenReadOnly` opens the file read-only. It refuses a file that has a │ │ → s3Client.PutObjectWithProgress()
trigger, a view or a virtual table, or that lacks an expected table. │ │ packer.Flush() // Final blob
- `verifySnapshotDBIdentity` checks that the snapshot in the database hashes to │ │
the remote key that was asked for. │ └─► Accumulate statistics
- `containedRestorePath` refuses a stored path that would leave the target │
directory. It also refuses to write through a symlink below the target. ├─► SnapshotManager.PopulateSnapshotBlobs() // record referenced blobs
- 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. ├─► SnapshotManager.UpdateSnapshotStatsExtended()
│
### Order of work ├─► SnapshotManager.ExportSnapshotMetadata()
│ │
`restorePlan` orders the files so that few blobs are on disk at once: │ ├─► Copy database to temp file
│ ├─► Clean to only current snapshot data (VACUUM)
- Downloaded blobs are kept decrypted in a cache directory. │ ├─► Compress binary SQLite with zstd
- A file is restored once every blob it needs is in the cache. │ ├─► Encrypt with age
- When no file is ready, the restore downloads the blobs for the file that │ ├─► Upload db.zst.age to storage
needs the fewest. │ └─► Upload manifest.json.zst to storage
- `restoreSweeper` deletes a cached blob once no remaining file needs it. │
└─► SnapshotManager.MarkSnapshotComplete() // only after the export succeeds
A regular file is created with mode `0600` and gets its stored mode after its ```
content is written. A directory gets its stored mode, owner and modification
time after the whole restore. ## Deduplication Strategy
## Verify 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
`Vaultik.VerifySnapshotWithOptions` checks a snapshot without the private key: 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.
- It downloads the manifest. 3. **Blob-level**: Blobs contain only unique chunks. Duplicate chunks within a blob are skipped.
- It checks that the snapshot's encrypted database is present.
- It checks that each listed blob is present with the listed size. ## Storage Layout in S3
`Vaultik.RunDeepVerify` needs the private key. It does these steps: ```
bucket/
- It downloads the manifest and the database. It checks the database as a ├── vaultik-destination.txt # Marker written by every backup
restore does. │
- `verifyManifestAgainstDatabase` checks that both list the same blobs with the ├── blobs/
same sizes. │ └── {hash[0:2]}/
- `verifyBlobExistenceFromDB` checks that every blob is present with the │ └── {hash[2:4]}/
recorded size. │ └── {full-hash} # Compressed+encrypted blob
- `verifyBlob` downloads each blob, checks the hash of each chunk in it, and │
checks the hash that names the blob. └── metadata/
└── {remote-key}/
## Prune ├── db.zst.age # Encrypted binary SQLite database
└── manifest.json.zst # Blob list (for pruning/verification)
`Vaultik.Prune` does three steps: ```
- `CleanupLocalSnapshots` drops from the local index each snapshot whose A destination that answers a look-up of `vaultik-destination.txt` with not
manifest is gone from the destination. found is treated as not present while the local index holds snapshots stored
- `PruneDatabase` removes unfinished snapshots and unused records from the there (`checkDestinationMarker` in `internal/vaultik/destination_marker.go`):
local index. `snapshot create` refuses to write to it, `prune`, `snapshot purge` and
- `PruneBlobs` deletes unused data at the destination. `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
`PruneBlobs` reads every manifest at the destination. It then deletes two kinds unplugged volume's empty mount point for a destination whose snapshots are
of object, once they are older than `prune_grace_period`: 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
- a blob that no manifest lists skipped, so a host with such credentials does not notice an emptied
- a directory under `metadata/` that has no manifest, and whose snapshot is not destination; see
in the local index [docs/REPOSTRUCTURE.md](docs/REPOSTRUCTURE.md#marker-file-vaultik-destinationtxt).
The `file://` backend never creates its destination directory, so a write to a
It deletes nothing when any manifest cannot be read. missing one fails.
[docs/REPOSTRUCTURE.md](docs/REPOSTRUCTURE.md#pruning) explains why this is
safe, and where it is not. 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
## The destination written to the store as a directory name. See
[docs/REPOSTRUCTURE.md](docs/REPOSTRUCTURE.md#remote-key-derivation) for the
`storage.Storer` is the only way the code reaches the destination. It has these derivation and a worked example.
methods:
## Thread Safety
- `Put` and `PutWithProgress` store an object.
- `Get` reads an object. - `Packer`: Thread-safe via mutex. Multiple goroutines can call `AddChunk()`.
- `Stat` reports an object's size. - `Scanner`: Uses `packerMu` mutex to coordinate blob finalization.
- `Delete` removes an object. - `Database`: Single-writer mode (`MaxOpenConns=1`) ensures SQLite thread safety.
- `List` and `ListStream` list the objects under a prefix. - `Repositories.WithTx()`: Handles transaction lifecycle automatically.
- `DeletePartialUploads` removes what an interrupted upload left behind.
- `Info` describes the destination.
`storage.NewStorer` picks the implementation from `storage_url`:
- `s3://` gives the S3 implementation.
- `file://` gives the local directory implementation.
- `rclone://` gives the rclone implementation.
With no `storage_url`, it builds the S3 implementation from the `s3` settings.
## The marker file and the destination binding
Two checks keep the local index and the destination in step.
- `checkDestinationMarker` looks for `vaultik-destination.txt`. It fails when
the file is reported missing while the local index holds snapshots.
- `EnsureStorageBinding` records the configured destination in the local index
on first use. Later it fails when the config names a different destination.
Every command that changes data calls `EnsureStorageBinding` first.
## Concurrency
- The local index is opened with one connection, so its queries run one at a
time.
- The index runs in SQLite's WAL mode. A second process can therefore read it
while a backup writes to it.
- `Packer` guards its state with a mutex.
- `Scanner` holds `packerMu` while a chunk is added to the packer and while a
blob is closed.
- `Repositories.WithTx` runs a function in one transaction. It commits when the
function succeeds and rolls back when it fails.
+980 -1238
View File
File diff suppressed because it is too large Load Diff
+3 -63
View File
@@ -30,8 +30,7 @@ owner's; a `v*` tag runs the release build in
([issue #395](https://git.eeqj.de/sneak/vaultik/issues/395)). Such a ([issue #395](https://git.eeqj.de/sneak/vaultik/issues/395)). Such a
prune could delete every snapshot still holding the volume's files. prune could delete every snapshot still holding the volume's files.
Each snapshot now records in the local index the directories it found Each snapshot now records in the local index the directories it found
on a different filesystem from their parent and found an entry in that on a different filesystem from their parent.
the backup does not exclude.
- 2026-10-14: Stopped `snapshot create --prune` pruning after a backup - 2026-10-14: Stopped `snapshot create --prune` pruning after a backup
that found nothing in a configured path that found nothing in a configured path
@@ -1316,64 +1315,5 @@ owner's; a `v*` tag runs the release build in
# 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](docs/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. Running several of those in parallel against the disk
cache would close most of the remaining gap. This needs to interact
correctly with the locality planner and the sweeper.
- **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
[`docs/DATAMODEL.md`](docs/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.
+3 -26
View File
@@ -1,31 +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 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
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: prefer the environment variable,
# supplied only on the machine you restore on
# 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:
@@ -273,11 +258,6 @@ exclude:
# Storage URL - use either this OR the s3 section below # Storage URL - use either this OR the s3 section below
# 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 needs the remote to be defined 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
@@ -303,8 +283,6 @@ 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
# #
@@ -317,7 +295,6 @@ storage_url: "rclone://myremote/path/to/backups"
# 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
# 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
+272 -382
View File
@@ -1,418 +1,308 @@
# 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 each command uses 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 does not need the local index. It reads the copy that was uploaded - **Version Compatibility**: In rare cases, you may need to use the same version
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
- **Chunk**: a piece of a file's content, named by the SHA-256 hash of that `internal/database/schema/`, embedded in the binary: `000.sql` creates the
content. `schema_migrations` table, which records each migration that has run, and
- **Blob**: one object at the destination. It holds many chunks, compressed and `001.sql` creates the application tables. `database.New` opens a database and
then encrypted. applies, in order, every migration that database has not yet recorded.
- **Destination**: the place the backups are stored.
- **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.
**After 1.0** each schema change is a new numbered file, so an existing local
database is migrated the first time an updated vaultik opens it. A file that
has shipped in a release is never edited.
## Database Tables
### 1. `files`
Stores metadata about files in the filesystem being backed up.
**Columns:**
- `id` (TEXT PRIMARY KEY) - UUID for the file record
- `path` (TEXT NOT NULL UNIQUE) - Absolute file path
- `mtime` (INTEGER NOT NULL) - Modification time, whole seconds since the Unix epoch
- `mtime_nsec` (INTEGER NOT NULL) - Nanoseconds within that second, 0 to 999999999
- `size` (INTEGER NOT NULL) - File size in bytes
- `mode` (INTEGER NOT NULL) - Unix file permissions and type
- `uid` (INTEGER NOT NULL) - User ID of file owner
- `gid` (INTEGER NOT NULL) - Group ID of file owner
- `link_target` (TEXT) - Symlink target path (NULL for regular files)
**Indexes:**
- `idx_files_path` on `path` for efficient lookups
**Purpose:** Tracks file metadata to detect changes between backup runs. Used for incremental backup decisions. The UUID primary key provides stable references that don't change if files are moved.
### 2. `chunks`
Stores information about content-defined chunks created from files.
**Columns:**
- `chunk_hash` (TEXT PRIMARY KEY) - SHA256 hash of chunk content
- `size` (INTEGER NOT NULL) - Chunk size in bytes
The index is the file `index.sqlite` in the platform data directory. That is **Purpose:** Enables deduplication by tracking unique chunks across all files.
`~/.local/share/vaultik/` on Linux and `~/Library/Application Support/vaultik/`
on macOS. The `index_path` setting and the `VAULTIK_INDEX_PATH` environment ### 3. `file_chunks`
variable change the path. Maps files to their constituent chunks in order.
The file is readable only by its owner, because it lists every backed-up path. **Columns:**
`vaultik` sets that mode each time it opens the index. - `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`)
## Schema migrations **Purpose:** Allows reconstruction of files from chunks during restore.
`vaultik` supports schema migrations. They are the numbered SQL files in ### 4. `chunk_files`
`internal/database/schema/`, which are built into the binary. Reverse mapping showing which files contain each chunk.
**Columns:**
- `chunk_hash` (TEXT) - Chunk hash (FK to chunks.chunk_hash)
- `file_id` (TEXT) - File ID (FK to files.id)
- `file_offset` (INTEGER) - Byte offset of chunk within file
- `length` (INTEGER) - Length of chunk in bytes
- PRIMARY KEY (`chunk_hash`, `file_id`)
- `000.sql` creates the `schema_migrations` table. That table records each **Purpose:** Supports efficient queries for chunk usage and deduplication statistics.
migration that has run.
- `001.sql` creates every other table.
`database.New` opens the index. It then applies, in order, every migration that ### 5. `blobs`
the index has not recorded. Stores information about packed, compressed, and encrypted blob files.
Before version 1.0, no migration is added, because nothing is installed anywhere **Columns:**
yet. A schema change edits `001.sql` directly, with the code that uses the - `id` (TEXT PRIMARY KEY) - UUID assigned when blob creation starts
changed tables. - `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)
This has one consequence. An index created before such a change has already **Purpose:** Tracks blob lifecycle from creation through upload. The UUID primary key allows immediate association of chunks with blobs.
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 ### 6. `blob_chunks`
is then migrated the first time an updated `vaultik` opens it. A file that was Maps chunks to the blobs that contain them.
part of a release is never edited.
**Columns:**
- `blob_id` (TEXT) - Blob ID (FK to blobs.id)
- `chunk_hash` (TEXT) - Chunk hash (FK to chunks.chunk_hash)
- `offset` (INTEGER) - Byte offset of chunk within blob (before compression)
- `length` (INTEGER) - Length of chunk in bytes
- PRIMARY KEY (`blob_id`, `chunk_hash`)
**Purpose:** Enables chunk retrieval from blobs during restore operations.
### 7. `snapshots`
Tracks backup snapshots.
**Columns:**
- `id` (TEXT PRIMARY KEY) - Snapshot ID (format: `hostname_name_timestamp`, e.g. `server1_home_2025-06-01T12:00:00Z`: the hostname up to its first `.`, the snapshot name, and an RFC 3339 UTC timestamp)
- `hostname` (TEXT) - Hostname where backup was created
- `vaultik_version` (TEXT) - Version of Vaultik used
- `vaultik_git_revision` (TEXT) - Git revision of Vaultik used
- `started_at` (INTEGER) - Start timestamp
- `completed_at` (INTEGER) - Completion timestamp (NULL if in progress)
- `file_count` (INTEGER) - Number of files in snapshot
- `chunk_count` (INTEGER) - Number of chunks this snapshot stored that were not stored before
- `new_chunk_size` (INTEGER) - Total size of those chunks before compression; `snapshot list` shows it as NEW CHUNK SIZE
- `blob_count` (INTEGER) - Number of blobs this snapshot created
- `total_size` (INTEGER) - Total size of all files
- `uncompressed_chunk_size` (INTEGER) - Total size before compression of the distinct chunks the snapshot's files reference, recorded when its backup ran; `snapshot list` shows it as UNCOMPRESSED SIZE
- `blob_size` (INTEGER) - Total compressed size of all referenced blobs
- `blob_uncompressed_size` (INTEGER) - Total uncompressed size of all referenced blobs
- `compression_ratio` (REAL) - Compression ratio achieved
- `compression_level` (INTEGER) - Compression level used for this snapshot
- `upload_bytes` (INTEGER) - Total bytes uploaded during this snapshot
- `upload_duration_ms` (INTEGER) - Total milliseconds spent uploading to S3
**Purpose:** Provides snapshot metadata and statistics including version tracking for compatibility.
### 8. `snapshot_files`
Maps snapshots to the files they contain.
**Columns:**
- `snapshot_id` (TEXT) - Snapshot ID (FK to snapshots.id)
- `file_id` (TEXT) - File ID (FK to files.id)
- PRIMARY KEY (`snapshot_id`, `file_id`)
**Purpose:** Records which files are included in each snapshot.
### 9. `snapshot_blobs`
Maps snapshots to the blobs they reference.
**Columns:**
- `snapshot_id` (TEXT) - Snapshot ID (FK to snapshots.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`)
**Purpose:** Tracks blob dependencies for snapshots and enables manifest generation.
### 10. `snapshot_mount_points`
The directories at or below a configured path that a snapshot's backup found on a different filesystem from their parent directory (a different device number), as a mount point is, the configured path itself included.
**Columns:**
- `snapshot_id` (TEXT) - Snapshot ID (FK to snapshots.id)
- `path` (TEXT) - Absolute path of the directory
- PRIMARY KEY (`snapshot_id`, `path`)
**Purpose:** Lets `snapshot create --prune` skip the prune when a backup does not find a mount point that an earlier snapshot of the same name found, as when the volume is not mounted.
### 11. `uploads`
Tracks blob upload metrics.
**Columns:**
- `blob_hash` (TEXT PRIMARY KEY) - Hash of uploaded blob
- `snapshot_id` (TEXT NOT NULL) - The snapshot that triggered this upload (FK to snapshots.id)
- `uploaded_at` (INTEGER) - Upload timestamp
- `size` (INTEGER) - Size of uploaded blob
- `duration_ms` (INTEGER) - Upload duration in milliseconds
**Purpose:** Performance monitoring and tracking which blobs were newly created (uploaded) during each snapshot.
## Data Flow and Operations
### 1. Backup Process
1. **File Scanning**
- `INSERT OR REPLACE INTO files` - Update file metadata
- `SELECT * FROM files WHERE path = ?` - Check if file has changed
- `INSERT INTO snapshot_files` - Add file to current snapshot
- `INSERT INTO snapshot_mount_points` - Record each directory on a different filesystem from its parent
2. **Chunking** (for changed files)
- `INSERT OR IGNORE INTO chunks` - Store new chunks
- `INSERT INTO file_chunks` - Map chunks to file
- `INSERT INTO chunk_files` - Create reverse mapping
3. **Blob Packing**
- `INSERT INTO blobs` - Create blob record with UUID (blob_hash NULL)
- `INSERT INTO blob_chunks` - Associate chunks with blob immediately
- `UPDATE blobs SET blob_hash = ?, finished_ts = ?` - Finalize blob after packing
4. **Upload**
- `UPDATE blobs SET uploaded_ts = ?, compressed_size = ?` - Mark blob as uploaded, with the size of the object in storage (the upload is skipped when the blob is already stored)
- `INSERT INTO uploads` - Record upload metrics with snapshot_id
- `INSERT INTO snapshot_blobs` - Associate blob with snapshot
5. **Snapshot Completion**
- `UPDATE snapshots SET completed_at = ?, stats...` - Finalize snapshot
- Generate and upload blob manifest from `snapshot_blobs`
### 2. Incremental Backup
1. **Change Detection**
- `SELECT * FROM files WHERE path = ?` - Get previous file metadata
- Compare mtime, size, mode to detect changes
- Re-chunk a file that lists a chunk no uploaded blob holds, even when its metadata is unchanged
- Skip unchanged files but still add to `snapshot_files`
2. **Chunk Reuse**
- `SELECT * FROM blob_chunks WHERE chunk_hash = ?` - Find existing chunks
- `INSERT INTO snapshot_blobs` - Reference existing blobs for unchanged files
### 3. Snapshot Metadata Export
After a snapshot is completed:
1. Copy database to temporary file
2. Clean temporary database to contain only current snapshot data
3. VACUUM the trimmed database so deleted rows leave no pages behind
4. Compress with zstd and encrypt with age
5. Upload to S3 as `metadata/{remote-key}/db.zst.age`
6. Generate blob manifest and upload as `metadata/{remote-key}/manifest.json.zst`
Migrations apply to the local index only. A snapshot's database at the The `{remote-key}` directory name is a one-way hash of the human snapshot ID, so the ID is never written to the store in plaintext. The hash uses no secret, so a guessed hostname and snapshot name can still be confirmed against a listing; see [REPOSTRUCTURE.md](REPOSTRUCTURE.md#remote-key-derivation) and its [Accepted Risks](REPOSTRUCTURE.md#accepted-risks).
destination is read as it was written. Each snapshot records the `vaultik`
version that wrote it.
## Tables ### 4. Restore Process
Times are whole seconds since the Unix epoch, in UTC. The two exceptions are The restore process doesn't use the local database. Instead:
noted at their columns. 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
### `files` ### 5. Pruning
One row for each backed-up path: a regular file, a symlink or a directory. 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`
- `id`: a UUID. Primary key. ### 6. Incomplete Snapshot Cleanup
- `path`: the absolute path. Unique.
- `source_path`: the configured path that the scan found this entry under. 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 `NULL` for other entries.
`idx_files_path` indexes `path`. Before each backup:
1. Query incomplete snapshots (where `completed_at IS NULL`)
2. Delete each one and all its associations, without checking S3 for its metadata
3. Clean up orphaned files, chunks, and blobs
There is one row for each path, and it is updated when the file changes. The ## Repository Pattern
row 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` Vaultik uses a repository pattern for database access:
- `FileRepository` - CRUD operations for files and file metadata
- `ChunkRepository` - CRUD operations for content chunks
- `FileChunkRepository` - Manage file-to-chunk mappings
- `ChunkFileRepository` - Manage chunk-to-file reverse mappings
- `BlobRepository` - Manage blob lifecycle (creation, finalization, upload)
- `BlobChunkRepository` - Manage blob-to-chunk associations
- `SnapshotRepository` - Manage snapshots and their relationships
- `UploadRepository` - Track blob upload metrics
One row for each distinct chunk. Each repository provides methods like:
- `Create()` - Insert new record
- `GetByID()` / `GetByPath()` / `GetByHash()` - Retrieve records
- `Update()` - Update existing records
- `Delete()` - Remove records
- Specialized queries for each entity type (e.g., `DeleteOrphaned()`, `GetIncompleteByHostname()`)
- `chunk_hash`: the SHA-256 hash of the chunk's content, in hex. Primary key. ## Transaction Management
- `size`: the size in bytes.
### `file_chunks` All database operations that modify multiple tables are wrapped in transactions:
The chunks that make up each file, in order.
- `file_id`: the file. References `files.id`.
- `idx`: the position of the chunk within the file, counted from 0.
- `chunk_hash`: the chunk. References `chunks.chunk_hash`.
The primary key is (`file_id`, `idx`). `idx_file_chunks_chunk_hash` indexes
`chunk_hash`.
### `chunk_files`
The reverse of `file_chunks`: the files that contain each chunk.
- `chunk_hash`: the chunk. References `chunks.chunk_hash`.
- `file_id`: the file. References `files.id`.
- `file_offset`: the byte offset of the chunk within the file.
- `length`: the length of the chunk in bytes.
The primary key is (`chunk_hash`, `file_id`). `idx_chunk_files_file_id` indexes
`file_id`.
### `blobs`
One row for each blob.
- `id`: a UUID, given when packing of the blob starts. Primary key.
- `blob_hash`: the name of the blob at the destination. Unique. Until the blob
is closed, it holds a placeholder that starts with `temp-placeholder-`.
- `created_ts`: when packing started.
- `finished_ts`: when the blob was closed. It is `NULL` before that.
- `uncompressed_size`: the total size of the blob's chunks.
- `compressed_size`: the size after compression and encryption.
- `uploaded_ts`: when the upload finished. It is `NULL` before that.
`blob_hash` is `hex(SHA256(SHA256(uncompressed contents)))`. See
[REPOSTRUCTURE.md](REPOSTRUCTURE.md#blobs).
Once a blob is uploaded, `compressed_size` is the size of the object at the
destination. A blob that was already there can have a different size from the
local copy. That happens when it was written with another compression level or
other recipients.
### `blob_chunks`
The chunks that each blob holds.
- `blob_id`: the blob. References `blobs.id`.
- `chunk_hash`: the chunk. References `chunks.chunk_hash`.
- `offset`: the byte offset of the chunk within the uncompressed blob.
- `length`: the length of the chunk in bytes.
The primary key is (`blob_id`, `chunk_hash`). `idx_blob_chunks_chunk_hash`
indexes `chunk_hash`.
A restore finds the blob for each chunk through this table.
### `snapshots`
One row for each snapshot.
- `id`: the snapshot ID. Primary key.
- `hostname`: the hostname of the machine that made the backup.
- `vaultik_version`: the `vaultik` version that made it.
- `vaultik_git_revision`: the git commit of that version.
- `started_at`: when the backup started.
- `completed_at`: when the backup finished. It is `NULL` until then.
- `file_count`: the number of files and symlinks in the snapshot. Directories
are not counted.
- `chunk_count`: the number of chunks this snapshot stored that were not stored
before.
- `new_chunk_size`: the total size of those chunks before compression.
- `blob_count`: the number of blobs this snapshot created.
- `total_size`: the total size of all files in the snapshot.
- `uncompressed_chunk_size`: the total size, before compression, of the
distinct chunks the snapshot's files use.
- `blob_size`: the total compressed size of the blobs the snapshot uses.
- `blob_uncompressed_size`: the total uncompressed size of those blobs.
- `compression_ratio`: the compression ratio of those blobs.
- `compression_level`: the compression level the backup used.
- `upload_bytes`: the bytes uploaded during the backup.
- `upload_duration_ms`: the time spent uploading, in milliseconds.
The snapshot ID has the form `<hostname>_<snapshot-name>_<timestamp>`. An
example is `server1_home_2025-06-01T12:00:00Z`. It has three parts:
- the hostname, up to its first `.`
- the snapshot name
- the start time, in UTC
`snapshot list` shows `uncompressed_chunk_size` as `UNCOMPRESSED SIZE` and
`new_chunk_size` as `NEW CHUNK SIZE`.
A snapshot with `completed_at` set to `NULL` is unfinished. `snapshot list`
does not show it. The next backup deletes it, and so does `prune`.
### `snapshot_files`
The files in each snapshot.
- `snapshot_id`: the snapshot. References `snapshots.id`.
- `file_id`: the file. References `files.id`.
The primary key is (`snapshot_id`, `file_id`). `idx_snapshot_files_file_id`
indexes `file_id`.
### `snapshot_blobs`
The blobs that each snapshot uses.
- `snapshot_id`: the snapshot. References `snapshots.id`.
- `blob_id`: the blob. References `blobs.id`.
- `blob_hash`: a copy of the blob's hash.
The primary key is (`snapshot_id`, `blob_id`). `idx_snapshot_blobs_blob_id`
indexes `blob_id`.
The manifest is built from this table.
### `snapshot_mount_points`
The mount points that a snapshot's backup found.
- `snapshot_id`: the snapshot. References `snapshots.id`.
- `path`: the absolute path of the directory.
The primary key is (`snapshot_id`, `path`).
A directory is recorded here when all of these hold:
- It is at or below a configured path.
- The backup found an entry in it that is not excluded.
- It is on a different filesystem from its parent directory.
The device number is read after the directory has been listed. Listing an
automount point mounts its volume, so the number is that of the volume. An
automount point whose volume cannot be mounted is not recorded, because the
backup finds no entry in it.
`snapshot create --prune` reads this table. It skips the prune when an earlier
snapshot of the same name recorded a mount point that the new backup did not
find. That happens when the volume is not mounted.
### `uploads`
One row for each blob that a backup uploaded.
- `blob_hash`: the blob. References `blobs.blob_hash`. Primary key.
- `snapshot_id`: the snapshot whose backup uploaded it. References
`snapshots.id`.
- `uploaded_at`: when the upload started. It is written as a date and time, not
as seconds.
- `size`: the size of the uploaded blob.
- `duration_ms`: the duration of the upload, in milliseconds.
`idx_uploads_snapshot_id` indexes `snapshot_id`.
A blob that was already at the destination gets no row here.
### `local_meta`
Settings that belong to this copy of the index.
- `key`: the name of the setting. Primary key.
- `value`: its value.
The only key is `storage_url`. It holds the destination that the index was
first used with. A command that changes data refuses to run when the config
names a different destination.
### `schema_migrations`
The migrations that have run.
- `version`: the number of the migration. Primary key.
- `applied_at`: when it ran. It is written as a date and time, not as seconds.
## How commands use the tables
### Backup
A backup works on the tables in this order:
- It deletes unfinished snapshots, and the rows that no snapshot uses.
- It creates a row in `snapshots`, with `completed_at` set to `NULL`.
- It deletes the `blobs` rows that were never uploaded.
- It walks each path and compares every entry with its row in `files`.
- It links each unchanged file to the snapshot in `snapshot_files`.
- It records each mount point in `snapshot_mount_points`.
- It reads each changed file and stores it. See below.
- It fills `snapshot_blobs` with every blob the snapshot uses.
- It stores the snapshot's totals in `snapshots`.
- It uploads the snapshot's metadata.
- It sets `completed_at`.
A file counts as changed when its row in `files` differs from the file on disk
in any of these:
- size
- modification time
- mode
- owner or group
- link target
A file is also read again when it lists a chunk that no uploaded blob holds.
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 new chunk in the 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`, with its `file_chunks`, `chunk_files` and
`snapshot_files` rows.
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 and encrypted with age.
- 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 WAL
mode, so rows that are already committed can still be in the `-wal` file beside
it.
### Restore
A restore does not use the local index.
- 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 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`.
It does not read the manifest.
### Prune
`prune` 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`
- `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. - Creating file-chunk mappings
- Associating chunks with blobs
- Updating snapshot statistics
## SQLite settings ## Performance Considerations
- The index runs in WAL mode. A command that only reads, such as 1. **Indexes**:
`snapshot list`, can read the index while a backup writes to it. - Primary keys are automatically indexed
- A connection waits up to 10 seconds for a lock before it fails. - `idx_files_path` on `files(path)` for efficient file lookups
- Foreign keys are enforced.
- Each open database uses one connection, so its queries run one at a time. 2. **Prepared Statements**: All queries use prepared statements for performance and security
- Queries pass their values as parameters.
3. **Batch Operations**: Where possible, operations are batched within transactions
4. **Write-Ahead Logging**: The local index runs in SQLite WAL mode with a 10-second busy timeout, so a read-only command such as `snapshot list` can read it while a backup writes to it. Committed rows can sit in the `-wal` file beside the index until a checkpoint, so the metadata export copies the index through SQLite (`VACUUM INTO`), not as a file
## Data Integrity
1. **Foreign Keys**: Enforced through CASCADE DELETE and application-level repository methods
2. **Unique Constraints**: Chunk hashes, file paths, and blob hashes are unique
3. **Null Handling**: Nullable fields clearly indicate in-progress operations
4. **Timestamp Tracking**: All major operations record timestamps for auditing
+143 -375
View File
@@ -1,416 +1,184 @@
# 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 bucket by Vaultik.
layout, what the layout protects, and the risks it accepts.
The destination is the place the backups are stored. It is an S3 bucket and ## Overview
prefix, a local directory or an rclone remote. 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:
1. **Blobs** - The actual backup data (content-addressed, encrypted)
2. **Metadata** - Snapshot information and manifests (partially encrypted)
- **Snapshot**: one backup of the paths listed under one name in the config. ## Directory Structure
- **Snapshot ID**: the name of a snapshot, in the form
`<hostname>_<snapshot-name>_<timestamp>`.
- **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 public key that backups are encrypted to.
## Layout
``` ```
<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 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.
Every backup writes `vaultik-destination.txt` before it uploads anything else. 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.
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 ## Blobs Directory (`blobs/`)
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. When the destination ### Structure
then answers that the marker does not exist, commands treat the destination as - **Path format**: `blobs/<first-2-chars>/<next-2-chars>/<full-hash>`
not present: - **Example**: `blobs/ca/fe/cafebabe1234567890abcdef1234567890abcdef1234567890abcdef12345678`
- **Sharding**: The two-level directory structure (using the first 4 characters of the hash) prevents any single directory from containing too many objects
- `snapshot create` refuses to write to it. ### Content
- `prune`, `snapshot purge` and `remote nuke` fail. - **What it contains**: Packed collections of content-defined chunks from files
- `snapshot list` shows the local index alone. - **Format**: Zstandard compressed, then Age encrypted
- `snapshot remove` deletes nothing at the destination. - **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.
The check is skipped when the look-up fails for any other reason. S3 refuses ### Why Encrypted
the look-up to credentials that can only write. A machine with such credentials Blobs contain the actual file data from backups and must be encrypted for security. The content-addressing ensures deduplication while the encryption ensures privacy.
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.
`remote nuke` deletes the marker last. ## Metadata Directory (`metadata/`)
## Blobs 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)).
A blob holds the content of many chunks, one after another. ### Snapshot ID Format
- **Path**: `blobs/<first 2 characters>/<next 2 characters>/<full hash>`. The human-readable snapshot ID is used in CLI arguments, log lines, and the local database. It is not written to the destination store.
- **Example**: - **Format**: `<hostname>_<snapshot-name>_<RFC3339>` (or `<hostname>_<RFC3339>` if no
`blobs/ca/fe/cafebabe1234567890abcdef1234567890abcdef1234567890abcdef12345678`. name was specified)
- **Format**: the chunk data, compressed with zstd and then encrypted with age. - **Example**: `laptop_home_2024-01-15T14:30:52Z`
- **Recipients**: the public keys in `age_recipients` when the blob was written. - **Components**:
- Short hostname (everything before the first dot is stripped from the FQDN)
- Snapshot name from the configured `snapshots:` map (optional)
- RFC3339 UTC timestamp
The two directory levels keep any one directory from holding too many objects. 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.
### Blob names ### Remote Key Derivation
A blob's name is `hex(SHA256(SHA256(uncompressed contents)))`. The contents are 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`.
the chunk data, joined in order, before compression and encryption. The name is
not a hash of the stored bytes.
This has two consequences:
- Checking a blob against its name needs the private key. The blob must be
decrypted and decompressed first. `snapshot restore` and
`snapshot verify --deep` do this.
- Without the key, only a blob's presence and size can be checked.
`snapshot verify` does this.
`blobgen.DoubleSHA256` in `internal/blobgen` computes the name.
### Uploads
A backup looks a blob up by name before it uploads it. It does not upload a
blob that is already there.
When the destination refuses the look-up, the backup uploads the blob. An
object with that name is then replaced by one that holds the same chunk data.
## Metadata
Each snapshot has one directory under `metadata/`. The directory is named with
the snapshot's remote key, not with its snapshot ID.
### Snapshot IDs
A snapshot ID has the form `<hostname>_<snapshot-name>_<timestamp>`. An example
is `laptop_home_2024-01-15T14:30:52Z`. It has three parts:
- the hostname, up to its first `.`
- the snapshot name from the config
- the time the backup started, in UTC
The snapshot ID is used on the command line, in log records and in the local
index. It reveals the hostname, the snapshot name and the backup time. It is
therefore never written to the destination without encryption.
### Remote keys
The remote key is `hex(SHA256(SHA256("vaultik|" + snapshot ID)))`. It is 64 hex
characters long. `internal/snapshot/remotekey.go` computes it.
For example:
Worked example:
- Snapshot ID: `server1_home_2025-06-01T12:00:00Z` - Snapshot ID: `server1_home_2025-06-01T12:00:00Z`
- Remote key: `17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa` - Remote key: `17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa`
- Directory: - 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
- **What it contains**: Pruned binary SQLite database for this snapshot
The snapshot's database. A restore needs it. - **Format**: Binary SQLite → Zstandard compressed → Age encrypted
- **Encryption**: Encrypted with Age
- **Contents**: a SQLite database. It is a copy of the local index without the - **Purpose**: Contains full file metadata, chunk mappings, and all relationships
rows of other snapshots. It lists every file in the snapshot with its - **Why encrypted**: Contains sensitive metadata like file paths, permissions, and ownership
metadata, its chunks, and the blob that holds each chunk.
- **Format**: the database file, compressed with zstd and then encrypted with
age.
It is encrypted because it holds file paths, permissions and owners.
[DATAMODEL.md](DATAMODEL.md) describes its tables.
### `manifest.json.zst`
The list of the blobs the snapshot uses.
- **Format**: JSON, compressed with zstd. It is not encrypted.
#### `manifest.json.zst` - Unencrypted Blob Manifest
- **What it contains**: JSON list of all blob hashes referenced by this snapshot
- **Format**: JSON → Zstandard compressed (NOT encrypted)
- **Encryption**: NOT encrypted
- **Purpose**: Enables pruning operations without requiring decryption keys
- **Structure**:
```json ```json
{ {
"snapshot_id": "17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa", "snapshot_id": "17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa",
"timestamp": "2025-06-01T12: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", "compressed_size": 24576 },
"hash": "cafebabe1234567890abcdef1234567890abcdef1234567890abcdef12345678", { "hash": "deadbeef1234567890abcdef1234567890abcdef1234567890abcdef12345678", "compressed_size": 32768 }
"compressed_size": 24576 ]
},
{
"hash": "deadbeef1234567890abcdef1234567890abcdef1234567890abcdef12345678",
"compressed_size": 32768
}
]
} }
``` ```
`snapshot_id` is the remote key (a hash), not the human ID; `timestamp` is written in the clear.
- `snapshot_id`: the remote key, not the snapshot ID.
- `timestamp`: when the manifest was written, at the end of the backup. ### Why Manifest is Unencrypted
- `blob_count`: the number of blobs. The manifest must be readable without the private key to enable:
- `total_compressed_size`: the total size of those blobs, in bytes. 1. **Pruning operations** - Identifying unreferenced blobs for deletion
- `blobs`: the hash and the size of each blob. 2. **Storage analysis** - Understanding space usage without decryption
3. **Verification** - Checking blob existence without decryption
The manifest holds no file name and no path. 4. **Cross-snapshot deduplication analysis** - Finding shared blobs between snapshots
The manifest is not encrypted so that these commands work without the private The manifest contains the remote key, the backup timestamp, the blob count and total compressed size, and each blob's hash and compressed size. It contains no file names, paths, or other decrypted metadata.
key:
## Security Considerations
- `prune`, which must know which blobs are still in use
- `snapshot verify`, which checks that each blob is present ### What's Encrypted
- `snapshot list` and `remote info`, which report what the destination holds - **All file content** (in blobs)
- **All file metadata** (paths, permissions, timestamps, ownership in db.zst.age)
## What is protected - **File-to-chunk mappings** (in db.zst.age)
These are encrypted: ### What's Not Encrypted
- **The remote key** — directory names and the manifest `snapshot_id`, a one-way hash of the snapshot ID (see [Remote Key Derivation](#remote-key-derivation))
- file contents, in the blobs - **The backup timestamp** (in manifest.json.zst)
- in `db.zst.age`: - **Blob hashes and their compressed sizes** (in manifest.json.zst)
- file names and paths - **Blob count and total compressed size per snapshot** (in manifest.json.zst)
- permissions and owners
- modification times ### Privacy Implications
- which chunks make up which file From the unencrypted data, an observer of the destination store can determine:
- **When each backup was taken** — not from the directory name, which is a one-way hash, but from the plaintext `timestamp` field in manifest.json.zst, which is published in the clear
These are not encrypted: - How many blobs each snapshot references, and the total compressed size
- The compressed size of each blob, and which blobs are shared between snapshots (deduplication patterns)
- the remote key of each snapshot - **Whether a guessed hostname and snapshot name are present** — the remote key is an unkeyed hash, so an observer holding candidate names can hash each one and match it against the directory listing. The human ID is never published, so it cannot be read off directly, but it can be confirmed by guessing.
- the time each backup finished, in the manifest
- the hash and the compressed size of each blob, in the manifest Together these give an observer a timing-and-size profile of every snapshot. This is an accepted, documented property of the format, not a defect: the manifest is unencrypted so that pruning can run without the private key, and the timing channel could not be closed by encrypting it anyway — object creation times and per-object sizes stay visible at the storage layer on both `s3://` and `file://` destinations regardless.
- the number of blobs and their total size for each snapshot, in the manifest
An observer cannot determine:
Someone who can read the destination therefore learns these: - The hostname or snapshot name of any backup by reading it off the store — the directory name and the manifest `snapshot_id` are unkeyed hashes of the human ID, so the text is never published (though a guessed name can be confirmed, as above)
- File names or paths
- when each backup finished - File contents
- how many blobs each snapshot uses, and how large they are - File permissions or ownership
- which blobs two snapshots share - Directory structure
- whether a snapshot ID they guess exists - Which chunks belong to which files
They do not learn these: ### Accepted Risks
- any hostname or snapshot name, unless they guess it These are known, deliberate properties of the format and the tooling, recorded so an operator can weigh them rather than discover them.
- file names or paths
- file contents 1. **No proof of authorship.** Restore and `verify --deep` prove that data decrypts with the age private key and matches its unkeyed content hashes. They do not prove who wrote it: anyone who knows a recipient public key and can replace objects on the destination can substitute a snapshot they built. The recipient string is not stored at the destination, but a compromised backed-up host has it. Defences live on the destination side — bucket versioning or object lock, credentials for the source host that cannot delete or overwrite existing versions, and pruning from a trusted host. Note that S3 `PutObject` overwrites an existing key, so PUT permission alone is not append-only.
- permissions or owners 2. **Compression reveals sizes.** Blobs and `db.zst.age` are zstd-compressed then age-encrypted; the manifest is compressed only. age does not pad, so an object's size is the exact compressed length of its contents. All new chunks packed into one blob share a single zstd stream (8 MiB window, 4 MiB at compression levels 1-2), and a blob is closed at `blob_size_limit` and at the end of each configured path. Because a stored chunk is never packed again, someone who can write into a backed-up file and watch blob sizes learns something only when their controlled data and a secret land in the same chunk of a file that keeps changing. Advice: back up any outsider-writable directory as its own snapshot.
- the directory structure 3. **Chunking uses no secret.** The FastCDC parameters are fixed and public. The default 10 MB average yields chunks between 2.5 MB and 40 MB, and any file of 2.5 MB or less is a single chunk. At the default 10 GB `blob_size_limit` a blob holds hundreds of chunks, so individual chunk lengths are not visible in the blob's size; lowering the limit toward the chunk size begins to expose them.
- which chunks belong to which file 4. **Decrypted data on local disk.** Several commands stage plaintext under `$TMPDIR`: `snapshot restore` writes decrypted blobs under `vaultik-blobcache-*/` (no size cap) and the decrypted metadata database at `vaultik-restore-*/snapshot.db`; `verify --deep` writes that database at `vaultik-verify-*/snapshot.db`; `snapshot create` keeps a plaintext copy of the index at `vaultik-snapshot-*/snapshot.db`. These files are created `0600` and removed on success, but a `kill -9` or a power loss leaves them behind — delete any leftover `vaultik-*` directory under `$TMPDIR` by hand. `$TMPDIR` should be trusted to the same degree as the restore target.
5. **Store permissions differ per command.** The backed-up host needs only PUT to run `snapshot create`: it writes blobs and metadata and neither reads nor deletes them. Other commands need more — `snapshot verify`, `snapshot restore`, and `prune` list and read; `prune`, `snapshot purge`, `snapshot remove`, and `remote nuke` also delete. The recommended cron line uses `--prune`, which runs `prune` on the backed-up host, so granting that host `--prune` gives it credentials that can delete its own backups. To keep the source host to PUT only, prune from a separate trusted host instead.
The times and sizes could not be hidden by encrypting the manifest. The 6. **Changing recipients does not re-encrypt existing data.** Deduplicated chunks and same-named blobs already on the destination stay encrypted to the recipients in force when they were written. A new snapshot that reuses them cannot be restored with a newly added recipient's key alone, because those reused objects were never encrypted to it. To make everything readable by a new key, run `vaultik database delete` and take a full backup to a fresh destination or prefix.
destination itself shows when each object was written and how large it is. 7. **X25519 recipients only.** vaultik rejects age ssh and plugin recipients. Long-lived ciphertext held by a third party (the destination operator) has no fallback if X25519 is ever broken: there is no second recipient type and no post-quantum option.
## Accepted risks ## Consistency Guarantees
These are known properties of the format. They are written down so that you can 1. **Blobs are immutable** - Once written, a blob is never modified
weigh them. 2. **Blobs are written before metadata** - A snapshot's metadata is only written after all its blobs are successfully uploaded
3. **Metadata is written atomically** - Both db.zst.age and manifest.json.zst are written as complete files
### No proof of who wrote the data 4. **A snapshot is marked complete in the local DB only after its metadata is uploaded** - `finalizeSnapshotMetadata` runs `ExportSnapshotMetadata` first and records completion (`MarkSnapshotComplete`) only once the export succeeds (see the backup data flow in [ARCHITECTURE.md](../ARCHITECTURE.md)). A crash during the export therefore leaves the snapshot incomplete, so the next backup's `PruneDatabase` drops it and re-backs-up its data, rather than leaving a completed-looking row in the local index with no matching metadata on the destination store. (A crash in the brief moment after the export succeeds but before completion is recorded leaves a fully-restorable snapshot on the destination that the local index drops as incomplete on the next run; `snapshot list` then reports it honestly as remote-only, which is the safe direction: the destination copy stays restorable.)
A restore and `snapshot verify --deep` prove two things. The data decrypts with ## Pruning Safety
your private key, and it matches its hashes. They do not prove who wrote it.
The prune operation is safe because:
The hashes use no secret. Someone who knows a recipient's public key and can 1. It keeps every blob listed in any snapshot's manifest and deletes only blobs that no manifest references. Under `metadata/` it deletes only a directory that has no `manifest.json.zst`, whose name is a remote key, and that no local snapshot record claims
replace objects at the destination can put a snapshot of their own there. The 2. Manifests are unencrypted and can be read without keys
public key is not stored at the destination, but a backed-up machine that is 3. If any manifest cannot be downloaded or decoded, prune deletes nothing and exits with an error, rather than treating that snapshot's blobs as unreferenced
broken into has it. 4. It deletes an unreferenced blob only once the blob is older than `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 its `db.zst.age` before its manifest, so a prune running meanwhile, on this host or another sharing the destination, leaves them as long as no backup run 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 backup skips the upload of a blob already on the destination, so that blob keeps the time of its first upload, and a prune before the backup's manifest is written can delete it; the new snapshot then cannot be restored. `remote nuke` has no such check
The defences are on the destination's side: ## Restoration Requirements
- object versioning or object lock on the bucket To restore from a backup, you need:
- credentials for the backed-up machine that cannot delete or overwrite 1. **The Age private key** - To decrypt blobs and database
- running `prune` from a trusted machine 2. **The snapshot metadata** - Both files from the snapshot's metadata directory
3. **All referenced blobs** - As listed in the manifest
An S3 `PutObject` replaces an existing object. Permission to write alone
therefore does not stop overwrites. The restoration process:
1. Download and decrypt the database dump to understand file structure
### Sizes are visible 2. Download and decrypt the required blobs
3. Reconstruct files from their chunks
An object's size follows exactly from the size of its compressed contents. 4. Restore file metadata (permissions, timestamps, etc.)
All the new chunks in one blob share one zstd stream. The stream looks back 8
MiB, or 4 MiB at compression levels 1 and 2. A blob is closed at
`blob_size_limit` and at the end of each configured path. A chunk that is
already stored is never packed again.
Data that an outsider writes compresses smaller when it matches a secret in the
same chunk, so the blob gets smaller. The blob's size therefore shows the
outsider whether their data matched the secret.
This works only when all of these hold:
- The outsider can write into a backed-up file.
- The outsider can see the blob sizes at the destination.
- The outsider's data and the secret land in the same chunk.
- The file keeps changing.
Back up a directory that outsiders can write to as a snapshot of its own.
### Chunk boundaries use no secret
The FastCDC parameters are fixed and public. `chunk_size` sets the average chunk
size. No chunk is larger than four times this size. Only a file's last chunk can
be smaller than a quarter of it. With the default of 10 MB, those limits are 40
MB and 2.5 MB. A file of 2.5 MB or less is one chunk.
At the default `blob_size_limit` of 10 GB, a blob holds hundreds of chunks. The
size of the blob then does not show the size of any one chunk. A limit close to
`chunk_size` begins to show chunk sizes.
### Decrypted data on the local disk
These commands write unencrypted data to the temporary directory:
- `snapshot restore` writes decrypted blobs under `vaultik-blobcache-*/`. There
is no limit on their total size.
- `snapshot restore` writes the decrypted database to
`vaultik-restore-*/snapshot.db`.
- `snapshot verify --deep` writes the decrypted database to
`vaultik-verify-*/snapshot.db`.
- `snapshot create` writes a copy of the local index to
`vaultik-snapshot-*/snapshot.db`.
These files are readable only by their owner, and are removed when the command
ends. A `kill -9` or a power loss leaves them behind. Delete any leftover
`vaultik-*` entry in the temporary directory yourself.
Trust the temporary directory as much as the directory you restore into.
### Commands need different permissions
`snapshot create` needs only permission to write objects. Other commands need
more:
- `snapshot list`, `snapshot verify`, `snapshot restore` and `remote info` list
and read.
- `snapshot purge`, `snapshot remove` and `remote nuke` list and delete.
- `prune` lists, reads and deletes.
`snapshot create --prune` runs a prune on the backed-up machine. That machine
then holds credentials that can delete its own backups. To keep it to
write-only credentials, run `prune` from a separate, trusted machine.
### New recipients cannot read old data
Changing `age_recipients` does not re-encrypt what is already stored. A chunk
that is already stored is not written again, so it stays encrypted to the
earlier recipients.
A new snapshot that uses such chunks cannot be restored with a new recipient's
key alone. To make everything readable by a new key, run
`vaultik database delete` and take a full backup to a new destination.
### X25519 recipients only
`vaultik` accepts age X25519 public keys only. It rejects age SSH and plugin
recipients.
The encrypted data is held by a third party for a long time. There is no second
kind of recipient to fall back on if X25519 is broken, and no post-quantum
option.
## Order of writes
A backup writes in a fixed order:
- the marker file
- the blobs
- `db.zst.age`
- `manifest.json.zst`
A snapshot's metadata is written only after all its blobs are uploaded. The
manifest is written last. A directory under `metadata/` that has no manifest
therefore belongs to a backup that did not finish.
The local index marks a snapshot complete only after both metadata files are
uploaded. A backup that stops during that upload leaves the snapshot
unfinished. The next backup removes it from the local index and stores its data
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 does not list it. `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 and does not need 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.
+5 -7
View File
@@ -289,14 +289,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()
@@ -334,7 +332,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()
+2 -2
View File
@@ -61,8 +61,8 @@ func TestDefaultConfigTemplateParses(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.
+1 -5
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
+5 -10
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 bucket empty and the next backup starts from scratch.
prefix 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
+5 -8
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,15 +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,
"Suppress status output (warnings, errors, JSON documents, "+ "Suppress non-error output")
"'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 "+
+26 -69
View File
@@ -60,21 +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.
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{}
@@ -82,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 {
@@ -164,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",
@@ -194,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
@@ -245,29 +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 a remote key is given. "index, by the remote key that 'snapshot list' prints for a\n" +
"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]
@@ -302,10 +264,7 @@ remote-only snapshot (an unambiguous leading part is enough).`,
}, },
} }
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
@@ -329,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
+4 -12
View File
@@ -37,21 +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 a remote key is given), and 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
+1 -1
View File
@@ -125,7 +125,7 @@ CREATE INDEX IF NOT EXISTS idx_snapshot_blobs_blob_id ON snapshot_blobs(blob_id)
-- Snapshot mount points table: the directories at or below a configured -- Snapshot mount points table: the directories at or below a configured
-- path that the snapshot's backup found on a different filesystem from -- path that the snapshot's backup found on a different filesystem from
-- their parent directory, as a mount point is, and found an entry in -- their parent directory, as a mount point is
CREATE TABLE IF NOT EXISTS snapshot_mount_points ( CREATE TABLE IF NOT EXISTS snapshot_mount_points (
snapshot_id TEXT NOT NULL, snapshot_id TEXT NOT NULL,
path TEXT NOT NULL, path TEXT NOT NULL,
+3 -3
View File
@@ -10,9 +10,9 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
// The device number a real backup reads is the same for a directory and // The device number a real backup reads is the same for two entries of
// a file in it, and differs between the temp directory and /dev. // one directory and differs between two filesystems. Without it no
// Without it no mount point is ever found. See // mount point is ever found. See
// https://git.eeqj.de/sneak/vaultik/issues/395. // https://git.eeqj.de/sneak/vaultik/issues/395.
func TestDeviceNumberFromStat(t *testing.T) { func TestDeviceNumberFromStat(t *testing.T) {
t.Parallel() t.Parallel()
+3 -151
View File
@@ -5,8 +5,6 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"slices" "slices"
"strings"
"syscall"
"testing" "testing"
"github.com/spf13/afero" "github.com/spf13/afero"
@@ -17,10 +15,9 @@ import (
) )
// A scan records with the snapshot each directory on a different device // A scan records with the snapshot each directory on a different device
// from its parent directory that the walk found an entry in, as a mount // from its parent directory, as a mount point is, the scanned path
// point is, the scanned path included. A filesystem that gives no device // included. A filesystem that gives no device numbers, as the in-memory
// numbers, as the in-memory one, records none. See // one, records none. See https://git.eeqj.de/sneak/vaultik/issues/395.
// https://git.eeqj.de/sneak/vaultik/issues/395.
func TestScanRecordsMountPoints(t *testing.T) { func TestScanRecordsMountPoints(t *testing.T) {
t.Parallel() t.Parallel()
@@ -98,148 +95,3 @@ func TestScanRecordsMountPoints(t *testing.T) {
}) })
} }
} }
// automountFs is an in-memory filesystem with a volume at mountPoint
// that is mounted on first access: opening mountPoint to list it mounts
// the volume or, if it cannot be mounted, fails with "no such file or
// directory", as with the autofs daemon. Stat records the device an
// entry is on at that moment, as a real stat does: unmountedDevice for
// mountPoint until the volume is mounted; the volume's, 2, for
// mountPoint once it is and for everything in it; 1 for everything else.
type automountFs struct {
afero.Fs
mountPoint string
unmountedDevice uint64
canMount bool
mounted bool
}
//nolint:ireturn // afero.Fs.Open is defined to return the interface.
func (fs *automountFs) Open(name string) (afero.File, error) {
if name == fs.mountPoint && !fs.canMount {
return nil, &os.PathError{Op: "open", Path: name, Err: syscall.ENOENT}
}
if name == fs.mountPoint {
fs.mounted = true
}
return fs.Fs.Open(name)
}
func (fs *automountFs) Stat(name string) (os.FileInfo, error) {
info, err := fs.Fs.Stat(name)
if err != nil {
return nil, err
}
device := uint64(1)
switch {
case name == fs.mountPoint && !fs.mounted:
device = fs.unmountedDevice
case name == fs.mountPoint || strings.HasPrefix(name, fs.mountPoint+"/"):
device = 2
}
return statWithDevice{FileInfo: info, device: device}, nil
}
// statWithDevice is an entry automountFs.Stat looked up, with the device
// it was on then.
type statWithDevice struct {
os.FileInfo
device uint64
}
// deviceAtLookup returns the device automountFs.Stat recorded for info.
func deviceAtLookup(info os.FileInfo) (uint64, bool) {
stat, ok := info.(statWithDevice)
return stat.device, ok
}
// A volume mounted on first access is mounted when the walk lists its
// mount point. Until then the mount point is on the automounter's own
// device (a systemd automount) or on its parent's (an autofs map that
// shows its mount points before mounting them). Either way only the
// mount point is recorded, by the backup that mounts the volume and by a
// later one that finds it mounted. Listing an automount point whose
// volume cannot be mounted fails; it is not recorded, because the walk
// finds no entry in it. See https://git.eeqj.de/sneak/vaultik/issues/395.
func TestScanRecordsOnlyTheMountPointOfAnAutomountedVolume(t *testing.T) {
t.Parallel()
const scannedPath = "/data/source"
mountPoint := scannedPath + "/nas"
tests := []struct {
name string
unmountedDevice uint64
canMount bool
want []string
}{
{
name: "on the automounter's device until listed",
unmountedDevice: 3, canMount: true, want: []string{mountPoint},
},
{
name: "on its parent's device until listed",
unmountedDevice: 1, canMount: true, want: []string{mountPoint},
},
{
name: "volume that cannot be mounted",
unmountedDevice: 1, canMount: false, want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx := context.Background()
fs := &automountFs{
Fs: afero.NewMemMapFs(), mountPoint: mountPoint,
unmountedDevice: tt.unmountedDevice, canMount: tt.canMount,
}
require.NoError(t, fs.MkdirAll(mountPoint+"/photos", 0o755))
for _, path := range []string{
scannedPath + "/notes.txt", mountPoint + "/photos/a.jpg",
} {
require.NoError(t, afero.WriteFile(fs, path, []byte(path), 0o644))
}
db, err := database.NewTestDB()
require.NoError(t, err)
t.Cleanup(func() { _ = db.Close() })
repos := database.NewRepositories(db)
for _, snapshotID := range []string{"first", "later"} {
createTestSnapshotRecord(ctx, t, repos, snapshotID)
scanner := snapshot.NewScanner(snapshot.ScannerConfig{
FS: fs,
ChunkSize: int64(1024 * 16),
Repositories: repos,
MaxBlobSize: int64(1024 * 1024),
CompressionLevel: 3,
AgeRecipients: []string{testAgePublicKey},
DeviceNumber: deviceAtLookup,
})
_, err = scanner.Scan(ctx, scannedPath, snapshotID)
require.NoError(t, err)
got, err := repos.Snapshots.GetMountPoints(ctx, snapshotID)
require.NoError(t, err)
assert.Equal(t, tt.want, got, "mount points of the %s backup", snapshotID)
}
})
}
}
+57 -78
View File
@@ -310,7 +310,7 @@ func (s *Scanner) Scan(
} }
} }
err = s.recordDirectories(ctx, scanResult) err = s.recordDirectories(ctx, scanResult.Directories)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -888,11 +888,6 @@ type ScanPhaseResult struct {
FilesToProcess []*FileToProcess FilesToProcess []*FileToProcess
UnchangedFileIDs []types.FileID // IDs of unchanged files to associate with snapshot UnchangedFileIDs []types.FileID // IDs of unchanged files to associate with snapshot
Directories []*FileToProcess // Every directory found, to record Directories []*FileToProcess // Every directory found, to record
// MountPoints are the directories, the scanned path included, that
// the walk found an entry in and, once it had listed them, on a
// different device from their parent directory (see
// recordIfMountPoint).
MountPoints []string
} }
// scanPhase performs the initial directory scan to identify files to process // scanPhase performs the initial directory scan to identify files to process
@@ -912,7 +907,7 @@ func (s *Scanner) scanPhase(
// estimate leaves them out. // estimate leaves them out.
estimatedTotal := countNonDirectories(knownFiles) estimatedTotal := countNonDirectories(knownFiles)
collector := &scanCollector{dirsWithEntries: make(map[string]struct{})} collector := &scanCollector{}
// Set up periodic status output // Set up periodic status output
startTime := time.Now() startTime := time.Now()
@@ -957,8 +952,6 @@ func (s *Scanner) scanPhase(
result.EntriesFound++ result.EntriesFound++
} }
s.recordIfMountPoint(filePath, path, collector)
// Handle symlinks and directories // Handle symlinks and directories
handled, err := s.recordSpecialEntry( handled, err := s.recordSpecialEntry(
filePath, info, existingFiles, knownFiles, collector, result) filePath, info, existingFiles, knownFiles, collector, result)
@@ -993,7 +986,6 @@ func (s *Scanner) scanPhase(
FilesToProcess: collector.filesToProcess, FilesToProcess: collector.filesToProcess,
UnchangedFileIDs: collector.unchangedFileIDs, UnchangedFileIDs: collector.unchangedFileIDs,
Directories: collector.directories, Directories: collector.directories,
MountPoints: collector.mountPoints,
}, nil }, nil
} }
@@ -1120,29 +1112,6 @@ type scanCollector struct {
unchangedFileIDs []types.FileID // Just IDs - no new records needed unchangedFileIDs []types.FileID // Just IDs - no new records needed
directories []*FileToProcess directories []*FileToProcess
filesScanned int64 filesScanned int64
// dirsWithEntries holds each directory the walk has found an entry in.
dirsWithEntries map[string]struct{}
mountPoints []string
}
// firstEntryIn notes that the walk found an entry in directory dir and
// reports whether it is the first one.
func (c *scanCollector) firstEntryIn(dir string) bool {
c.mu.Lock()
defer c.mu.Unlock()
_, seen := c.dirsWithEntries[dir]
c.dirsWithEntries[dir] = struct{}{}
return !seen
}
// addMountPoint sets a mount point aside to be recorded.
func (c *scanCollector) addMountPoint(dir string) {
c.mu.Lock()
defer c.mu.Unlock()
c.mountPoints = append(c.mountPoints, dir)
} }
// addDirectory sets a directory aside to be recorded. It is not counted // addDirectory sets a directory aside to be recorded. It is not counted
@@ -1338,23 +1307,29 @@ func (s *Scanner) buildDirectoryEntry(path string, info os.FileInfo) *database.F
// mount points (see recordMountPoints). A directory has no data to back // mount points (see recordMountPoints). A directory has no data to back
// up, so it is not among the files counted and backed up. // up, so it is not among the files counted and backed up.
func (s *Scanner) recordDirectories( func (s *Scanner) recordDirectories(
ctx context.Context, scanned *ScanPhaseResult, ctx context.Context, directories []*FileToProcess,
) error { ) error {
for _, dir := range scanned.Directories { for _, dir := range directories {
err := s.recordNonRegularFile(ctx, dir) err := s.recordNonRegularFile(ctx, dir)
if err != nil { if err != nil {
return fmt.Errorf("recording directory %s: %w", dir.Path, err) return fmt.Errorf("recording directory %s: %w", dir.Path, err)
} }
} }
return s.recordMountPoints(ctx, scanned.MountPoints) return s.recordMountPoints(ctx, directories)
} }
// recordMountPoints records with the snapshot the mount points the walk // recordMountPoints records with the snapshot each directory the walk
// found (see ScanPhaseResult.MountPoints). // found on a different device from its parent directory, as a mount
// point is, the scanned path included.
func (s *Scanner) recordMountPoints( func (s *Scanner) recordMountPoints(
ctx context.Context, mountPoints []string, ctx context.Context, directories []*FileToProcess,
) error { ) error {
mountPoints, err := s.findMountPoints(directories)
if err != nil {
return err
}
return s.repos.WithTx(ctx, func(txCtx context.Context, tx *sql.Tx) error { return s.repos.WithTx(ctx, func(txCtx context.Context, tx *sql.Tx) error {
for _, dir := range mountPoints { for _, dir := range mountPoints {
log.Info("Found a mount point", "path", dir) log.Info("Found a mount point", "path", dir)
@@ -1369,53 +1344,57 @@ func (s *Scanner) recordMountPoints(
}) })
} }
// recordIfMountPoint sets the directory that holds entry, a path the // findMountPoints returns the directories, among those the walk found,
// walk found, aside to be recorded as a mount point if it is on a // that are on a different device from their parent directory. The
// different device from its parent directory. The walk calls it for each // scanned path is compared with the directory that holds it, which the
// entry it finds, and it decides on the first entry in a directory, so a // walk did not reach.
// directory the walk found no entry in is never recorded. By then the func (s *Scanner) findMountPoints(
// walk has listed the directory, which mounts a volume mounted there on directories []*FileToProcess,
// first access, so the directory and its parent are looked up again: ) ([]string, error) {
// before the listing, the directory can be on the automounter's own walked := make(map[string]os.FileInfo, len(directories))
// device (a systemd automount) or on its parent's (an autofs map that for _, dir := range directories {
// shows its mount points before mounting them). The scanned path is in walked[dir.Path] = dir.FileInfo
// no listing the walk made, so it is skipped as an entry. A directory
// that cannot be looked up then, or whose parent cannot, is not
// recorded, so this snapshot does not stop the prune of a later backup
// that misses its volume.
func (s *Scanner) recordIfMountPoint(
entry, scannedPath string, collector *scanCollector,
) {
dir := filepath.Dir(entry)
if entry == scannedPath || !collector.firstEntryIn(dir) {
return
} }
dirDevice, ok := s.deviceNumberAt(dir) var mountPoints []string
if !ok {
return for _, dir := range directories {
parentPath := filepath.Dir(dir.Path)
parent, ok := walked[parentPath]
if !ok {
var err error
parent, err = s.fs.Stat(parentPath)
if err != nil {
return nil, fmt.Errorf(
"looking up the parent of %s: %w", dir.Path, err)
}
}
if s.onOtherDevice(dir.FileInfo, parent) {
mountPoints = append(mountPoints, dir.Path)
}
} }
parentDevice, ok := s.deviceNumberAt(filepath.Dir(dir)) return mountPoints, nil
if ok && dirDevice != parentDevice {
collector.addMountPoint(dir)
}
} }
// deviceNumberAt looks path up now and returns the number of the device // onOtherDevice reports whether dir is on a different device from
// that holds it, and false when the lookup fails (logged) or gives no // parent. A file whose device number cannot be read counts as on the
// device number. // same device as its parent.
func (s *Scanner) deviceNumberAt(path string) (uint64, bool) { func (s *Scanner) onOtherDevice(dir, parent os.FileInfo) bool {
info, err := s.fs.Stat(path) dirDevice, ok := s.deviceNumber(dir)
if err != nil { if !ok {
log.Warn("Could not look up a directory to check for a mount point", return false
"path", path, "error", err)
return 0, false
} }
return s.deviceNumber(info) parentDevice, ok := s.deviceNumber(parent)
if !ok {
return false
}
return dirDevice != parentDevice
} }
// deviceNumberFromStat returns the number of the device that holds the // deviceNumberFromStat returns the number of the device that holds the
+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
+10 -10
View File
@@ -56,11 +56,11 @@ var ErrSourcePathEmpty = errors.New("source path(s) were empty")
// ErrVolumeNotMounted is returned when --prune skipped the post-backup // ErrVolumeNotMounted is returned when --prune skipped the post-backup
// prune because a directory that an earlier snapshot of the same name // prune because a directory that an earlier snapshot of the same name
// found as a mount point was not found as one in this run: the volume // found as a mount point was not found as one in this run: the volume
// mounted there is not mounted, the run found no entry in it, or the // mounted there is not mounted, or the directory is no longer backed up.
// directory is no longer backed up. The snapshots are complete and // The snapshots are complete and kept, so the caller can report it apart
// kept, so the caller can report it apart from a failed backup. A run // from a failed backup. A run that also skipped unreadable files or
// that also skipped unreadable files or found a configured path empty // found a configured path empty returns ErrFilesUnreadable or
// returns ErrFilesUnreadable or ErrSourcePathEmpty instead. // ErrSourcePathEmpty instead.
var ErrVolumeNotMounted = errors.New( var ErrVolumeNotMounted = errors.New(
"mount point(s) an earlier snapshot recorded were not found") "mount point(s) an earlier snapshot recorded were not found")
@@ -237,11 +237,11 @@ func (v *Vaultik) skipPostBackupPrune(
if len(unmountedPaths) > 0 { if len(unmountedPaths) > 0 {
v.UI.Warningf("Skipped the post-backup prune because this backup "+ v.UI.Warningf("Skipped the post-backup prune because this backup "+
"did not find a mount point at %s, where an earlier snapshot "+ "did not find a mount point at %s, where an earlier snapshot "+
"found one: the volume is not mounted, this backup found no "+ "found one: either the volume is not mounted or the directory "+
"entry in it, or the directory is no longer backed up. Older "+ "is no longer backed up. Older snapshots are kept. Mount the "+
"snapshots are kept. Mount the volume again, or, if that is on "+ "volume again, or, if the volume was retired or the directory "+
"purpose, remove the older snapshots by hand with "+ "is no longer backed up on purpose, remove the older snapshots "+
"'vaultik snapshot purge'.", "by hand with 'vaultik snapshot purge'.",
strings.Join(unmountedPaths, ", ")) strings.Join(unmountedPaths, ", "))
} }
+21 -77
View File
@@ -4,10 +4,8 @@ import (
"context" "context"
"os" "os"
"path/filepath" "path/filepath"
"syscall"
"testing" "testing"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/config" "sneak.berlin/go/vaultik/internal/config"
@@ -263,69 +261,15 @@ func TestBackupWithNoEmptySourcePathPrunes(t *testing.T) {
} }
} }
// automountFs is the real filesystem with a volume at volumeDir that is
// mounted on first access, as by an autofs map that shows its mount
// points before mounting them: listing volumeDir mounts the volume or,
// while cannotMount, fails with "no such file or directory".
type automountFs struct {
afero.OsFs
volumeDir string
cannotMount bool
mounted bool
}
//nolint:ireturn // afero.Fs.Open is defined to return the interface.
func (f *automountFs) Open(name string) (afero.File, error) {
if name == f.volumeDir && f.cannotMount {
return nil, &os.PathError{Op: "open", Path: name, Err: syscall.ENOENT}
}
if name == f.volumeDir {
f.mounted = true
}
return f.OsFs.Open(name)
}
// A backup under --prune in which a volume that an earlier snapshot found // A backup under --prune in which a volume that an earlier snapshot found
// mounted below the configured path is missing does not prune: run after // mounted below the configured path is not mounted does not prune: run
// run, the snapshot that holds the volume's files is kept with everything // after run, the snapshot that holds the volume's files is kept with
// it stored, and the run fails. Once the volume is back, the prune runs. // everything it stored, and the run fails. Once the volume is mounted
// The volume is mounted on first access and every backup starts with it // again, the prune runs. See https://git.eeqj.de/sneak/vaultik/issues/395.
// not mounted, so its directory is on its parent's device until the walk
// lists it. While the volume is missing, its directory is left empty, or
// listing it fails with "no such file or directory", as when an autofs
// volume cannot be mounted, which the walk counts as a directory removed
// during the backup. See https://git.eeqj.de/sneak/vaultik/issues/395.
func TestBackupWithVolumeNotMountedDoesNotPrune(t *testing.T) { func TestBackupWithVolumeNotMountedDoesNotPrune(t *testing.T) {
log.Initialize(log.Config{}) log.Initialize(log.Config{})
t.Parallel() t.Parallel()
tests := []struct {
name string
listingFails bool
}{
{name: "directory left empty", listingFails: false},
{name: "listing fails", listingFails: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
backUpWithVolumeNotMounted(t, tt.listingFails)
})
}
}
// backUpWithVolumeNotMounted runs the backups of
// TestBackupWithVolumeNotMountedDoesNotPrune. While the volume is
// missing, listing its directory fails if listingFails; otherwise the
// directory is empty.
func backUpWithVolumeNotMounted(t *testing.T, listingFails bool) {
t.Helper()
// volume is the directory, below the configured path, where the // volume is the directory, below the configured path, where the
// volume is mounted. // volume is mounted.
const ( const (
@@ -338,21 +282,20 @@ func backUpWithVolumeNotMounted(t *testing.T, listingFails bool) {
volume + "/a.txt": []byte("file a"), volume + "/a.txt": []byte("file a"),
"notes.txt": []byte("notes"), "notes.txt": []byte("notes"),
}) })
env.setSnapshot(snapshotName, "") env.v.Config.Snapshots[snapshotName] = config.SnapshotConfig{
Paths: []string{env.src},
}
prune := &vaultik.SnapshotCreateOptions{ prune := &vaultik.SnapshotCreateOptions{
Prune: true, Snapshots: []string{snapshotName}, Prune: true, Snapshots: []string{snapshotName},
} }
// The volume's directory is on the volume's device, 2, while the // While the volume is mounted, its directory is on a device of its
// volume is mounted. Everything else is on device 1. // own.
volumeDir := filepath.Join(env.src, volume) mounted := true
fs := &automountFs{volumeDir: volumeDir}
newScanner := env.v.ScannerFactory newScanner := env.v.ScannerFactory
env.v.ScannerFactory = func(params snapshot.ScannerParams) *snapshot.Scanner { env.v.ScannerFactory = func(params snapshot.ScannerParams) *snapshot.Scanner {
fs.mounted = false
params.Fs = fs
params.DeviceNumber = func(info os.FileInfo) (uint64, bool) { params.DeviceNumber = func(info os.FileInfo) (uint64, bool) {
if info.Name() == volume && fs.mounted { if mounted && info.Name() == volume {
return 2, true return 2, true
} }
@@ -368,13 +311,13 @@ func backUpWithVolumeNotMounted(t *testing.T, listingFails bool) {
stored, err := env.v.Storage.List(ctx, "") stored, err := env.v.Storage.List(ctx, "")
require.NoError(t, err) require.NoError(t, err)
// The volume goes missing. // A volume that is not mounted leaves its mount point behind as an
fs.cannotMount = listingFails // empty directory on its parent's device.
volumeDir := filepath.Join(env.src, volume)
require.NoError(t, os.RemoveAll(volumeDir))
require.NoError(t, os.Mkdir(volumeDir, 0o750))
if !listingFails { mounted = false
require.NoError(t, os.RemoveAll(volumeDir))
require.NoError(t, os.Mkdir(volumeDir, 0o750))
}
for range 2 { for range 2 {
env.out.Reset() env.out.Reset()
@@ -399,11 +342,12 @@ func backUpWithVolumeNotMounted(t *testing.T, listingFails bool) {
require.NoError(t, err, "%s is kept", key) require.NoError(t, err, "%s is kept", key)
} }
// The volume is back. // The volume is mounted again.
fs.cannotMount = false
require.NoError(t, os.WriteFile( require.NoError(t, os.WriteFile(
filepath.Join(volumeDir, "a.txt"), []byte("file a"), 0o600)) filepath.Join(volumeDir, "a.txt"), []byte("file a"), 0o600))
mounted = true
require.NoError(t, env.v.CreateSnapshot(prune)) require.NoError(t, env.v.CreateSnapshot(prune))
snapshots, err = env.v.Repositories.Snapshots.ListRecent( snapshots, err = env.v.Repositories.Snapshots.ListRecent(