Compare commits
1
Commits
next
..
3441eec48c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3441eec48c |
+16
-19
@@ -74,16 +74,16 @@ Maps files to their constituent chunks:
|
||||
#### 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))
|
||||
- `Hash`: SHA256 of final compressed+encrypted content
|
||||
- `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 streamed to storage
|
||||
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
|
||||
2. Compressed with zstd
|
||||
3. Encrypted with age (recipients configured in config)
|
||||
4. SHA256 hash computed → becomes filename in S3
|
||||
5. Uploaded to `blobs/{hash[0:2]}/{hash[2:4]}/{hash}`
|
||||
|
||||
#### BlobChunk (`database.BlobChunk`)
|
||||
@@ -284,10 +284,9 @@ Manages snapshot lifecycle and metadata export.
|
||||
|
||||
Key methods:
|
||||
- `CreateSnapshot(ctx, hostname, version, commit)` → Create snapshot record
|
||||
- `PopulateSnapshotBlobs(ctx, snapshotID)` → Record every blob the snapshot references
|
||||
- `CompleteSnapshot(ctx, snapshotID)` → Mark snapshot complete
|
||||
- `ExportSnapshotMetadata(ctx, dbPath, snapshotID)` → Export to S3
|
||||
- `MarkSnapshotComplete(ctx, snapshotID)` → Record completion, only after a successful export
|
||||
- `CompleteSnapshot(ctx, snapshotID)` → Convenience: populate blobs, then mark complete (no export between)
|
||||
- `CleanupIncompleteSnapshots(ctx, hostname)` → Remove failed snapshots
|
||||
|
||||
### `internal/database`
|
||||
SQLite database for local index. Single-writer mode for thread safety.
|
||||
@@ -308,7 +307,7 @@ Repository interfaces:
|
||||
```
|
||||
CreateSnapshot(opts)
|
||||
│
|
||||
├─► PruneDatabase() // Critical: avoid dedup errors
|
||||
├─► CleanupIncompleteSnapshots() // Critical: avoid dedup errors
|
||||
│
|
||||
├─► SnapshotManager.CreateSnapshot() // Create DB record
|
||||
│
|
||||
@@ -337,18 +336,16 @@ CreateSnapshot(opts)
|
||||
│
|
||||
├─► SnapshotManager.UpdateSnapshotStatsExtended()
|
||||
│
|
||||
├─► SnapshotManager.PopulateSnapshotBlobs() // record referenced blobs
|
||||
├─► SnapshotManager.CompleteSnapshot()
|
||||
│
|
||||
├─► SnapshotManager.ExportSnapshotMetadata()
|
||||
│ │
|
||||
│ ├─► Copy database to temp file
|
||||
│ ├─► Clean to only current snapshot data (VACUUM)
|
||||
│ ├─► Compress binary SQLite with zstd
|
||||
│ ├─► Encrypt with age
|
||||
│ ├─► Upload db.zst.age to storage
|
||||
│ └─► Upload manifest.json.zst to storage
|
||||
│
|
||||
└─► SnapshotManager.MarkSnapshotComplete() // only after the export succeeds
|
||||
└─► SnapshotManager.ExportSnapshotMetadata()
|
||||
│
|
||||
├─► Copy database to temp file
|
||||
├─► Clean to only current snapshot data (VACUUM)
|
||||
├─► Compress binary SQLite with zstd
|
||||
├─► Encrypt with age
|
||||
├─► Upload db.zst.age to storage
|
||||
└─► Upload manifest.json.zst to storage
|
||||
```
|
||||
|
||||
## Deduplication Strategy
|
||||
|
||||
@@ -38,7 +38,7 @@ vaultik snapshot list
|
||||
|
||||
Features:
|
||||
|
||||
* modern encryption ([age](https://age-encryption.org/), X25519 + ChaCha20-Poly1305)
|
||||
* modern encryption ([age](https://age-encryption.org/), X25519 + XChaCha20-Poly1305)
|
||||
* content-defined chunking with deduplication (FastCDC)
|
||||
* incremental backups (only changed files are re-chunked)
|
||||
* multithreaded zstd compression at configurable levels
|
||||
@@ -71,21 +71,14 @@ Requirements that no existing tool meets:
|
||||
## daily use
|
||||
|
||||
```sh
|
||||
# verify a snapshot (shallow: checks all blobs are present with the listed size)
|
||||
# verify a snapshot (shallow: checks all blobs exist)
|
||||
vaultik snapshot verify <snapshot-id>
|
||||
|
||||
# put the private key file in the environment (reading it from the file
|
||||
# keeps the key out of your shell history); the whole age-keygen file,
|
||||
# with one or more identities, is accepted
|
||||
export VAULTIK_AGE_SECRET_KEY="$(cat vaultik_backup_private_key.txt)"
|
||||
# deep verify (downloads and cryptographically verifies every blob)
|
||||
VAULTIK_AGE_SECRET_KEY='AGE-SECRET-KEY-...' vaultik snapshot verify --deep <snapshot-id>
|
||||
|
||||
# deep verify (downloads every blob, decrypts it, and re-hashes it to
|
||||
# detect corruption — this checks integrity, not who wrote the blob)
|
||||
vaultik snapshot verify --deep <snapshot-id>
|
||||
|
||||
# restore (requires the private key). Restore into a new directory you own,
|
||||
# writable only by you — not a shared location like /tmp
|
||||
vaultik snapshot restore <snapshot-id> ~/vaultik-restore
|
||||
# restore (requires the private key)
|
||||
VAULTIK_AGE_SECRET_KEY='AGE-SECRET-KEY-...' vaultik snapshot restore <snapshot-id> /tmp/restored
|
||||
|
||||
# daily cron job: back up, keep a 4-week rolling window of snapshots
|
||||
# 0 3 * * * vaultik snapshot create --cron --prune --keep-newer-than 4w
|
||||
@@ -126,18 +119,15 @@ Use that remote key — the hex printed inside `<remote only:...>`, or the
|
||||
full `remote_key` from `snapshot list --json` — to restore and verify:
|
||||
|
||||
```sh
|
||||
# put the private key file in the environment (reading it from the file
|
||||
# keeps the key out of your shell history)
|
||||
export VAULTIK_AGE_SECRET_KEY="$(cat vaultik_backup_private_key.txt)"
|
||||
# restore everything to /tmp/restored, then check every restored file's
|
||||
# chunk hashes
|
||||
VAULTIK_AGE_SECRET_KEY='AGE-SECRET-KEY-...' \
|
||||
vaultik snapshot restore --verify <remote-key> /tmp/restored
|
||||
|
||||
# restore everything to a new directory you own (writable only by you, not a
|
||||
# shared location like /tmp), then check every restored file's chunk hashes
|
||||
vaultik snapshot restore --verify <remote-key> ~/vaultik-restore
|
||||
|
||||
# optionally, deep-verify the snapshot against the store (downloads every
|
||||
# blob, decrypts it, and re-hashes it to detect corruption — this checks
|
||||
# integrity, not who wrote the blob)
|
||||
vaultik snapshot verify --deep <remote-key>
|
||||
# optionally, deep-verify the snapshot against the store (downloads and
|
||||
# cryptographically checks every blob)
|
||||
VAULTIK_AGE_SECRET_KEY='AGE-SECRET-KEY-...' \
|
||||
vaultik snapshot verify --deep <remote-key>
|
||||
```
|
||||
|
||||
`age_recipients` (the public key) is not needed to restore — only the
|
||||
@@ -177,24 +167,22 @@ vaultik version
|
||||
* `--verbose`, `-v`: Enable verbose output (on stderr — see below)
|
||||
* `--debug`: Enable debug output (on stderr — see below)
|
||||
* `--quiet`, `-q`: Suppress non-error output (also suppresses startup banner)
|
||||
* `--skip-errors`: Skip files that cannot be read when creating a snapshot, or that cannot be restored when restoring, instead of aborting. Packing and storage errors (which would leave a chunk recorded but not stored) still abort the run.
|
||||
* `--skip-errors`: Continue past per-file errors instead of aborting (applies to `snapshot create` and `restore`)
|
||||
|
||||
### locking
|
||||
|
||||
Commands that write persistent state — `snapshot create`, `snapshot
|
||||
purge`, `snapshot remove`, `prune`, and `remote nuke` — take a
|
||||
Every command that opens the local index — `snapshot create`, `snapshot
|
||||
list`, `snapshot verify`, `snapshot purge`, `snapshot remove`, `snapshot
|
||||
restore`, `prune`, `info`, and `remote info`/`remote nuke` — takes a
|
||||
process-wide lock at `$XDG_DATA_HOME/vaultik/vaultik.pid`
|
||||
(`~/.local/share/vaultik/vaultik.pid` on Linux) for the whole run. Only
|
||||
one of them runs at a time: a second one exits immediately with an
|
||||
"already running" error rather than waiting, so two writers can never
|
||||
corrupt the local index or the destination store.
|
||||
|
||||
Read-only commands — `info`, `snapshot list`, `snapshot verify`, and
|
||||
`remote info` — do not take the lock and are never blocked, so they run
|
||||
even while a backup is in progress. `snapshot restore` does not take the
|
||||
lock either: it writes only to the target directory you name, not the
|
||||
local index or the destination store. `config`, `database delete`,
|
||||
`completion`, and `version` do not take the lock.
|
||||
one such command runs at a time: a second one exits immediately with an
|
||||
"already running" error rather than waiting. The lock is not scoped to
|
||||
mutating commands, so read-only commands are affected too — `vaultik
|
||||
snapshot list` fails while a backup is in progress; scoping it so
|
||||
read-only commands run during a backup is tracked in
|
||||
[issue #150](https://git.eeqj.de/sneak/vaultik/issues/150). `config`,
|
||||
`database delete`, `completion`, and `version` do not take the lock.
|
||||
|
||||
### stdout and stderr
|
||||
|
||||
@@ -227,7 +215,7 @@ and `vaultik prune --json | jq .` both work as written.
|
||||
|
||||
### environment variables
|
||||
|
||||
* `VAULTIK_AGE_SECRET_KEY`: Age private key for decryption (required for `snapshot restore` and `snapshot verify --deep`). May hold the whole `age-keygen` file — comments and every identity in it are accepted. Set it from the file, e.g. `export VAULTIK_AGE_SECRET_KEY="$(cat vaultik_backup_private_key.txt)"`, so the key is not typed into your shell history.
|
||||
* `VAULTIK_AGE_SECRET_KEY`: Age private key for decryption (required for `snapshot restore` and `snapshot verify --deep`)
|
||||
* `VAULTIK_CONFIG`: Path to config file (overridden by `--config`)
|
||||
* `VAULTIK_INDEX_PATH`: Override local SQLite index path
|
||||
* `VAULTIK_CPUPROFILE`: Write a CPU profile to this path for the duration of the run (development/debugging)
|
||||
@@ -322,9 +310,7 @@ local index alone, and still exits zero.
|
||||
logger, so stdout stays a single parseable document.
|
||||
|
||||
**`snapshot verify`**: Verify snapshot integrity.
|
||||
* Default (shallow): checks that every blob the manifest lists is present in
|
||||
storage with the size the manifest records, and that the encrypted database is
|
||||
present. It does not read blob contents.
|
||||
* Default (shallow): checks that all blobs referenced in the manifest exist in storage
|
||||
* `--deep`: Downloads and decrypts each blob, verifies chunk hashes against the
|
||||
encrypted metadata database
|
||||
* Accepts the same identifiers as `snapshot restore`: a snapshot ID, or a
|
||||
@@ -452,19 +438,14 @@ Snapshot IDs follow the human-readable format
|
||||
`<hostname>_<snapshot-name>_<RFC3339-timestamp>` (e.g.
|
||||
`server1_home_2025-06-01T12:00:00Z`), but this ID is never written to the
|
||||
destination store in plaintext. Each snapshot's metadata directory is named
|
||||
with its `<remote-key>`, a one-way double SHA-256 hash of the ID, so a plain
|
||||
listing of the store shows no hostname or snapshot name. The hash uses no
|
||||
secret, though, so an observer who guesses a candidate hostname and snapshot
|
||||
name can hash it and confirm the snapshot is present; the remote key keeps
|
||||
names out of a listing but does not hide them from a guess. The backup time is
|
||||
not hidden either: manifest.json.zst carries a plaintext timestamp, and object
|
||||
with its `<remote-key>`, a one-way double SHA-256 hash of the ID, so a listing
|
||||
of the store reveals no hostname or snapshot name. The backup time is not
|
||||
hidden: manifest.json.zst carries a plaintext timestamp, and object
|
||||
modification times are visible at the storage layer regardless. For example,
|
||||
`server1_home_2025-06-01T12:00:00Z` is stored under
|
||||
`metadata/17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa/`.
|
||||
See [docs/REPOSTRUCTURE.md](docs/REPOSTRUCTURE.md#remote-key-derivation) for the
|
||||
derivation, and [Security Considerations](docs/REPOSTRUCTURE.md#security-considerations)
|
||||
(including [Accepted Risks](docs/REPOSTRUCTURE.md#accepted-risks)) for what the
|
||||
format does and does not protect.
|
||||
derivation.
|
||||
|
||||
### data flow
|
||||
|
||||
@@ -505,7 +486,7 @@ format does and does not protect.
|
||||
|
||||
### encryption
|
||||
|
||||
* Asymmetric encryption using age (X25519 + ChaCha20-Poly1305)
|
||||
* Asymmetric encryption using age (X25519 + XChaCha20-Poly1305)
|
||||
* Only the public key is needed on the source host
|
||||
* Each blob and each metadata database is encrypted independently
|
||||
* Multiple recipients supported (encrypt to multiple keys)
|
||||
@@ -535,7 +516,7 @@ complete annotated example also lives in
|
||||
| `s3.*` | | Legacy S3 configuration (endpoint, bucket, credentials) |
|
||||
| `exclude` | | Global exclude patterns (applied to all snapshots) |
|
||||
| `chunk_size` | `10MB` | Average chunk size for content-defined chunking |
|
||||
| `blob_size_limit` | `10GB` | Maximum blob size before splitting. Must be at least four times `chunk_size` (the largest chunk the chunker can emit), otherwise a single-chunk blob could exceed the limit |
|
||||
| `blob_size_limit` | `10GB` | Maximum blob size before splitting |
|
||||
| `compression_level` | `3` | zstd compression level (1-19) |
|
||||
| `hostname` | system hostname | Hostname used in snapshot IDs |
|
||||
| `index_path` | platform data dir | Local SQLite index path |
|
||||
@@ -645,26 +626,17 @@ priority.
|
||||
|
||||
## output style
|
||||
|
||||
Every command's user-facing output is governed by `internal/ui`, in one
|
||||
of two ways. Color is enabled when stdout is a TTY and the `NO_COLOR`
|
||||
environment variable is unset (https://no-color.org/).
|
||||
|
||||
* **Status, progress, warnings, and errors** go through the `internal/ui`
|
||||
message methods below: marker-prefixed, colored on a TTY, and — except
|
||||
warnings and errors — silenced by `--quiet`. This is the operational
|
||||
narration of the long-running commands (`snapshot create`, `prune`,
|
||||
`snapshot restore`, and the like) and the confirmations of
|
||||
`config init`, `config set`, and `database delete`.
|
||||
* **The data a command exists to produce** is written plain, with no
|
||||
marker and no color, because a marker would corrupt a table or a
|
||||
parsed document. This covers the `version`, `info`, and `remote info`
|
||||
reports, the `snapshot list` table, `config get` values, and every
|
||||
`--json` document. `--quiet` silences the human reports and tables
|
||||
(`version`, `info`, `remote info`, `snapshot list`) but never the
|
||||
machine-consumed `config get` value or the `--json` documents, which a
|
||||
script depends on. The `database delete` confirmation prompt is also
|
||||
written this way and always shown: it is an interactive exchange the
|
||||
operator must see.
|
||||
The operational narration of the long-running commands — the Begin,
|
||||
Complete, Progress, and status lines of `snapshot create`, `prune`,
|
||||
`snapshot restore`, and the like — goes through helpers in `internal/ui`
|
||||
and conforms to the uniform style below. Some commands instead write
|
||||
plain text straight to stdout (`version`, `info`, `config`, the
|
||||
`database delete` prompt, and the `snapshot list` table); that output is
|
||||
unstyled and does not honor `--quiet`. Routing it through `internal/ui`
|
||||
is tracked in
|
||||
[issue #149](https://git.eeqj.de/sneak/vaultik/issues/149). Color is
|
||||
enabled when stdout is a TTY and the `NO_COLOR` environment variable is
|
||||
unset (https://no-color.org/).
|
||||
|
||||
`internal/ui` writes to stdout; it is the output the user asked for.
|
||||
Structured log records are a different thing and go through
|
||||
|
||||
@@ -25,57 +25,6 @@ release" is exactly the contradiction
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-09-22: Routed the last direct-to-stdout command output through
|
||||
`internal/ui`
|
||||
([issue #149](https://git.eeqj.de/sneak/vaultik/issues/149)). The
|
||||
`version`, `info`, `remote info`, `config`, and `database delete`
|
||||
commands wrote plain text straight to stdout, so they were unstyled and
|
||||
ignored `--quiet`. Output now falls in two buckets, both governed by
|
||||
`internal/ui`: status lines and confirmations go through its message
|
||||
methods (styled, and `--quiet` silences them), while the data a command
|
||||
exists to produce — the reports, the `snapshot list` table, `config get`
|
||||
values, and the `--json` documents — is written plain. `--quiet`
|
||||
silences the human reports and tables but never the `config get` value
|
||||
or the `--json` documents, which a script depends on, and the
|
||||
`database delete` confirmation prompt is always shown. The README
|
||||
output-style section now states this rule.
|
||||
|
||||
- 2026-09-22: Validated blob hashes, offsets and lengths read back from
|
||||
the destination before using them
|
||||
([issue #155](https://git.eeqj.de/sneak/vaultik/issues/155)). A blob
|
||||
hash taken from the downloaded database or the store listing was
|
||||
trusted unchecked, so a hostile remote could drive a decrypted blob to
|
||||
be written outside the cache directory (a hash like `aa/../../etc`) or
|
||||
crash a command with a short or negative value. `blobDiskCache.path`
|
||||
now refuses any key containing a path separator, and `ReadAt` rejects a
|
||||
negative offset or length and bounds with `length > size-offset` so a
|
||||
sum cannot overflow past the check. A new `isBlobHash` helper (a plain
|
||||
function, not a method — the packer stores `temp-placeholder-{uuid}` as
|
||||
a hash) gates `FetchBlob`, shallow and deep verify; the `blobs/` and
|
||||
`metadata/` listings skip a non-conforming name with a warning; and
|
||||
short-hash prefixes in log and error text go through a `shortHash`
|
||||
helper that cannot panic. `verify`'s chunk reader also rejects a
|
||||
negative `blob_chunks` length and streams the chunk instead of
|
||||
allocating a database-supplied size. `restore.go` and
|
||||
`internal/database` were left untouched to avoid colliding with the
|
||||
in-flight [issue #156](https://git.eeqj.de/sneak/vaultik/issues/156)
|
||||
work; the cache-path and `FetchBlob` guards already stop the unsafe
|
||||
write and fetch, so restore's own `buildBlobIndexes` early check is
|
||||
deferred as fail-fast defense in depth.
|
||||
|
||||
- 2026-09-21: Stopped an interrupted blob upload from making a later
|
||||
backup deduplicate against data that was never stored
|
||||
([issue #148](https://git.eeqj.de/sneak/vaultik/issues/148)). The
|
||||
packer commits a blob's `chunks`, `blob_chunks`, and `blobs` rows
|
||||
before the upload is attempted, so a failed upload left chunk rows
|
||||
behind and the next run skipped re-uploading them, producing a
|
||||
snapshot that reported success but could not be restored. A run now
|
||||
deduplicates only against chunks held by a blob whose `uploaded_ts` is
|
||||
set, and at startup drops any un-uploaded blob rows (and the chunks
|
||||
they orphan) so the affected data is re-chunked and re-uploaded. Blobs
|
||||
recorded with no remote backend are marked uploaded so this invariant
|
||||
holds uniformly.
|
||||
|
||||
- 2026-09-22: Made restore refuse any snapshot path that would write
|
||||
outside the target directory
|
||||
([issue #154](https://git.eeqj.de/sneak/vaultik/issues/154)).
|
||||
|
||||
+5
-10
@@ -257,16 +257,16 @@ exclude:
|
||||
|
||||
# Storage URL - use either this OR the s3 section below
|
||||
# Supports: s3://bucket/prefix, file:///path, rclone://remote/path
|
||||
storage_url: "rclone://myremote/path/to/backups"
|
||||
storage_url: "rclone://las1stor1//srv/pool.2024.04/backups/heraklion"
|
||||
|
||||
# S3-compatible storage configuration
|
||||
#s3:
|
||||
# # S3-compatible endpoint URL
|
||||
# # Examples: https://s3.amazonaws.com, https://storage.googleapis.com
|
||||
# endpoint: https://s3.example.com
|
||||
# endpoint: http://10.100.205.122:8333
|
||||
#
|
||||
# # Bucket name where backups will be stored
|
||||
# bucket: mybucket
|
||||
# bucket: testbucket
|
||||
#
|
||||
# # Prefix (folder) within the bucket for this host's backups
|
||||
# # Useful for organizing backups from multiple hosts
|
||||
@@ -274,8 +274,8 @@ storage_url: "rclone://myremote/path/to/backups"
|
||||
# #prefix: "hosts/myserver/"
|
||||
#
|
||||
# # S3 access credentials
|
||||
# access_key_id: YOUR_ACCESS_KEY
|
||||
# secret_access_key: YOUR_SECRET_KEY
|
||||
# access_key_id: Z9GT22M9YFU08WRMC5D4
|
||||
# secret_access_key: Pi0tPKjFbN4rZlRhcA4zBtEkib04yy2WcIzI+AXk
|
||||
#
|
||||
# # S3 region
|
||||
# # Default: us-east-1
|
||||
@@ -304,11 +304,6 @@ storage_url: "rclone://myremote/path/to/backups"
|
||||
|
||||
# Maximum blob size
|
||||
# Multiple chunks are packed into blobs up to this size
|
||||
# Must be at least four times chunk_size (the largest chunk the chunker can
|
||||
# emit); a smaller limit would let a single-chunk blob exceed it.
|
||||
# Chunking uses no secret (the FastCDC parameters are fixed and public). At a
|
||||
# large limit a blob holds hundreds of chunks, so individual chunk lengths are
|
||||
# not visible in its size; lowering the limit toward chunk_size exposes them.
|
||||
# Supports: 1GB, 10G, 500MB, 1GiB, etc.
|
||||
# Default: 10GB
|
||||
#blob_size_limit: 10GB
|
||||
|
||||
+2
-2
@@ -90,7 +90,7 @@ Stores information about packed, compressed, and encrypted blob files.
|
||||
|
||||
**Columns:**
|
||||
- `id` (TEXT PRIMARY KEY) - UUID assigned when blob creation starts
|
||||
- `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)
|
||||
- `blob_hash` (TEXT UNIQUE) - SHA256 hash of final blob (NULL until finalized)
|
||||
- `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
|
||||
@@ -216,7 +216,7 @@ After a snapshot is completed:
|
||||
5. Upload to S3 as `metadata/{remote-key}/db.zst.age`
|
||||
6. Generate blob manifest and upload as `metadata/{remote-key}/manifest.json.zst`
|
||||
|
||||
The `{remote-key}` directory name is a one-way hash of the human snapshot ID, so the ID is never written to the store in plaintext. The hash uses no secret, so a guessed hostname and snapshot name can still be confirmed against a listing; see [REPOSTRUCTURE.md](REPOSTRUCTURE.md#remote-key-derivation) and its [Accepted Risks](REPOSTRUCTURE.md#accepted-risks).
|
||||
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; see [REPOSTRUCTURE.md](REPOSTRUCTURE.md#remote-key-derivation).
|
||||
|
||||
### 4. Restore Process
|
||||
|
||||
|
||||
+8
-21
@@ -35,7 +35,7 @@ The metadata subdirectory is named with the **remote key**, a one-way hash of th
|
||||
- **What it contains**: Packed collections of content-defined chunks from files
|
||||
- **Format**: Zstandard compressed, then Age encrypted
|
||||
- **Encryption**: Always encrypted with Age using the configured recipients
|
||||
- **Naming**: Content-addressed. The blob's name is `hex(SHA256(SHA256(uncompressed blob contents)))` — the double SHA-256 of the concatenated chunk data, computed before compression and encryption, not a hash of the stored (compressed, encrypted) bytes. One consequence: only a holder of the age private key can check a stored blob's integrity, because matching a blob to its name means decrypting and decompressing it first — which is what `restore` and `verify --deep` do. Implemented in `internal/blobgen` (`DoubleSHA256`). This is the canonical description of blob naming; other documents and comments point here.
|
||||
- **Naming**: Content-addressed using SHA256 hash of the encrypted blob
|
||||
|
||||
### Why Encrypted
|
||||
Blobs contain the actual file data from backups and must be encrypted for security. The content-addressing ensures deduplication while the encryption ensures privacy.
|
||||
@@ -59,14 +59,14 @@ This ID reveals the hostname, the configured snapshot name, and the backup time,
|
||||
|
||||
### Remote Key Derivation
|
||||
|
||||
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 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 with no structure a remote observer can reverse. Implemented in `internal/snapshot/remotekey.go`.
|
||||
|
||||
Worked example:
|
||||
- Snapshot ID: `server1_home_2025-06-01T12:00:00Z`
|
||||
- Remote key: `17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa`
|
||||
- Directory: `metadata/17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa/`
|
||||
|
||||
A plain listing of the destination store therefore shows only these hashes, not the hostname or snapshot name of any backup — but because the hash uses no secret, a guessed hostname and snapshot name can be hashed and confirmed against the listing. The same remote key is stored in the manifest's `snapshot_id` field.
|
||||
Because the hash is one-way, a listing of the destination store reveals neither the hostname nor the snapshot name of any backup. The same remote key is stored in the manifest's `snapshot_id` field.
|
||||
|
||||
### Files in Each Snapshot Directory
|
||||
|
||||
@@ -124,44 +124,31 @@ From the unencrypted data, an observer of the destination store can determine:
|
||||
- **When each backup was taken** — not from the directory name, which is a one-way hash, but from the plaintext `timestamp` field in manifest.json.zst, which is published in the clear
|
||||
- How many blobs each snapshot references, and the total compressed size
|
||||
- The compressed size of each blob, and which blobs are shared between snapshots (deduplication patterns)
|
||||
- **Whether a guessed hostname and snapshot name are present** — the remote key is an unkeyed hash, so an observer holding candidate names can hash each one and match it against the directory listing. The human ID is never published, so it cannot be read off directly, but it can be confirmed by guessing.
|
||||
|
||||
Together these give an observer a timing-and-size profile of every snapshot. This is an accepted, documented property of the format, not a defect: the manifest is unencrypted so that pruning can run without the private key, and the timing channel could not be closed by encrypting it anyway — object creation times and per-object sizes stay visible at the storage layer on both `s3://` and `file://` destinations regardless.
|
||||
|
||||
An observer cannot determine:
|
||||
- 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)
|
||||
- The hostname or snapshot name of any backup (the directory name and the manifest `snapshot_id` are one-way hashes of the human ID)
|
||||
- File names or paths
|
||||
- File contents
|
||||
- File permissions or ownership
|
||||
- Directory structure
|
||||
- Which chunks belong to which files
|
||||
|
||||
### Accepted Risks
|
||||
|
||||
These are known, deliberate properties of the format and the tooling, recorded so an operator can weigh them rather than discover them.
|
||||
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
6. **Changing recipients does not re-encrypt existing data.** Deduplicated chunks and same-named blobs already on the destination stay encrypted to the recipients in force when they were written. A new snapshot that reuses them cannot be restored with a newly added recipient's key alone, because those reused objects were never encrypted to it. To make everything readable by a new key, run `vaultik database delete` and take a full backup to a fresh destination or prefix.
|
||||
7. **X25519 recipients only.** vaultik rejects age ssh and plugin recipients. Long-lived ciphertext held by a third party (the destination operator) has no fallback if X25519 is ever broken: there is no second recipient type and no post-quantum option.
|
||||
|
||||
## Consistency Guarantees
|
||||
|
||||
1. **Blobs are immutable** - Once written, a blob is never modified
|
||||
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
|
||||
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.)
|
||||
4. **Snapshots are marked complete in local DB only after metadata upload** - Ensures consistency between local and remote state
|
||||
|
||||
## Pruning Safety
|
||||
|
||||
The prune operation is safe because:
|
||||
1. It keeps every blob listed in any snapshot's manifest and deletes only blobs that no manifest references
|
||||
1. It only deletes blobs not referenced in any manifest
|
||||
2. Manifests are unencrypted and can be read without keys
|
||||
3. If any manifest cannot be downloaded or decoded, prune deletes nothing and exits with an error, rather than treating that snapshot's blobs as unreferenced
|
||||
4. Prune requires exclusive access to the destination: running it during a concurrent backup can race a snapshot whose manifest is not yet written, so do not prune while a backup is in progress
|
||||
3. The operation compares the latest local DB snapshot with the latest S3 snapshot to ensure consistency
|
||||
4. Pruning will fail if these don't match, preventing accidental deletion of needed blobs
|
||||
|
||||
## Restoration Requirements
|
||||
|
||||
|
||||
+8
-10
@@ -1,16 +1,14 @@
|
||||
// Package blob handles the creation of blobs - the final storage units for Vaultik.
|
||||
// A blob is a large file (up to 10GB) containing many compressed and encrypted chunks
|
||||
// from multiple source files. Blobs are content-addressed: a blob's filename is
|
||||
// hex(SHA256(SHA256(uncompressed blob contents))), computed from the concatenated
|
||||
// chunk data before compression and encryption, not from the stored bytes. See
|
||||
// blobgen.DoubleSHA256 and docs/REPOSTRUCTURE.md.
|
||||
// from multiple source files. Blobs are content-addressed, meaning their filename
|
||||
// is derived from the SHA256 hash of their compressed and encrypted content.
|
||||
//
|
||||
// The blob creation process:
|
||||
// 1. Chunks are accumulated from multiple files
|
||||
// 2. Each chunk's uncompressed bytes are fed to a running SHA-256 and, in the same
|
||||
// pass, compressed with zstd and encrypted with age into the temp file
|
||||
// 3. On finalize, the name is the double SHA-256 of that uncompressed content
|
||||
// 4. The blob is uploaded to S3 using the name as the filename
|
||||
// 1. Chunks are accumulated from multiple files
|
||||
// 2. The collection is compressed using zstd
|
||||
// 3. The compressed data is encrypted using age
|
||||
// 4. The encrypted blob is hashed to create its content-addressed name
|
||||
// 5. The blob is uploaded to S3 using the hash as the filename
|
||||
//
|
||||
// This design optimizes storage efficiency by batching many small chunks into
|
||||
// larger blobs, reducing the number of S3 operations and associated costs.
|
||||
@@ -489,7 +487,7 @@ func (p *Packer) closeBlobWriter() (string, int64, error) {
|
||||
return "", 0, fmt.Errorf("seeking to start: %w", err)
|
||||
}
|
||||
|
||||
finalHash := p.currentBlob.writer.ContentID()
|
||||
finalHash := p.currentBlob.writer.Sum256()
|
||||
|
||||
return hex.EncodeToString(finalHash), finalSize, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// Package blobgen implements the blob data pipeline: streaming zstd
|
||||
// compression, age encryption, and SHA256 content hashing for blob
|
||||
// creation, plus the matching decrypt/decompress/verify reader.
|
||||
package blobgen
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// CompressResult contains the results of compression
|
||||
type CompressResult struct {
|
||||
Data []byte
|
||||
UncompressedSize int64
|
||||
CompressedSize int64
|
||||
SHA256 string
|
||||
}
|
||||
|
||||
// CompressData compresses and encrypts data, returning the result with hash
|
||||
func CompressData(
|
||||
data []byte, compressionLevel int, recipients []string,
|
||||
) (*CompressResult, error) {
|
||||
var buf bytes.Buffer
|
||||
|
||||
// Create writer
|
||||
w, err := NewWriter(&buf, compressionLevel, recipients)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating writer: %w", err)
|
||||
}
|
||||
|
||||
// Write data
|
||||
_, err = w.Write(data)
|
||||
if err != nil {
|
||||
_ = w.Close()
|
||||
|
||||
return nil, fmt.Errorf("writing data: %w", err)
|
||||
}
|
||||
|
||||
// Close to flush
|
||||
err = w.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("closing writer: %w", err)
|
||||
}
|
||||
|
||||
return &CompressResult{
|
||||
Data: buf.Bytes(),
|
||||
UncompressedSize: int64(len(data)),
|
||||
CompressedSize: int64(buf.Len()),
|
||||
SHA256: hex.EncodeToString(w.Sum256()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CompressStream compresses and encrypts from reader to writer, returning
|
||||
// the number of uncompressed bytes written and the content hash.
|
||||
func CompressStream(
|
||||
dst io.Writer, src io.Reader, compressionLevel int, recipients []string,
|
||||
) (int64, string, error) {
|
||||
// Create writer
|
||||
w, err := NewWriter(dst, compressionLevel, recipients)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("creating writer: %w", err)
|
||||
}
|
||||
|
||||
closed := false
|
||||
|
||||
defer func() {
|
||||
if !closed {
|
||||
_ = w.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
// Copy data
|
||||
_, err = io.Copy(w, src)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("copying data: %w", err)
|
||||
}
|
||||
|
||||
// Close to flush
|
||||
err = w.Close()
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("closing writer: %w", err)
|
||||
}
|
||||
|
||||
closed = true
|
||||
|
||||
return w.BytesWritten(), hex.EncodeToString(w.Sum256()), nil
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package blobgen_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||
)
|
||||
|
||||
// testRecipient is a static age recipient for tests.
|
||||
const testRecipient = "age1cplgrwj77ta54dnmydvvmzn64ltk83ankxl5sww04mrtmu62kv3s89gmvv"
|
||||
|
||||
// TestCompressStreamNoDoubleClose is a regression test for issue #28.
|
||||
// It verifies that CompressStream does not panic or return an error due to
|
||||
// double-closing the underlying blobgen.Writer. Before the fix in PR #33,
|
||||
// the explicit Close() on the happy path combined with defer Close() would
|
||||
// cause a double close.
|
||||
func TestCompressStreamNoDoubleClose(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
input := []byte("regression test data for issue #28 double-close fix")
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
written, hash, err := blobgen.CompressStream(
|
||||
&buf, bytes.NewReader(input), 3, []string{testRecipient})
|
||||
require.NoError(t, err, "CompressStream should not return an error")
|
||||
assert.Positive(t, written, "expected bytes written > 0")
|
||||
assert.NotEmpty(t, hash, "expected non-empty hash")
|
||||
assert.Positive(t, buf.Len(), "expected non-empty output")
|
||||
}
|
||||
|
||||
// TestCompressStreamLargeInput exercises CompressStream with a larger payload
|
||||
// to ensure no double-close issues surface under heavier I/O.
|
||||
func TestCompressStreamLargeInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
data := make([]byte, 512*1024) // 512 KB
|
||||
_, err := rand.Read(data)
|
||||
require.NoError(t, err)
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
written, hash, err := blobgen.CompressStream(
|
||||
&buf, bytes.NewReader(data), 3, []string{testRecipient})
|
||||
require.NoError(t, err)
|
||||
assert.Positive(t, written)
|
||||
assert.NotEmpty(t, hash)
|
||||
}
|
||||
|
||||
// TestCompressStreamEmptyInput verifies CompressStream handles empty input
|
||||
// without double-close issues.
|
||||
func TestCompressStreamEmptyInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
_, hash, err := blobgen.CompressStream(
|
||||
&buf, strings.NewReader(""), 3, []string{testRecipient})
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, hash)
|
||||
}
|
||||
|
||||
// TestCompressDataNoDoubleClose mirrors the stream test for CompressData,
|
||||
// ensuring the explicit Close + error-path Close pattern is also safe.
|
||||
func TestCompressDataNoDoubleClose(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
input := []byte("CompressData regression test for double-close")
|
||||
|
||||
result, err := blobgen.CompressData(input, 3, []string{testRecipient})
|
||||
require.NoError(t, err)
|
||||
assert.Positive(t, result.CompressedSize)
|
||||
assert.Equal(t, result.UncompressedSize, int64(len(input)))
|
||||
assert.NotEmpty(t, result.SHA256)
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
package blobgen_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||
)
|
||||
|
||||
// ageChunkSize is age's STREAM plaintext chunk size (64 KiB); each encrypted
|
||||
// chunk adds a 16-byte ChaCha20-Poly1305 tag.
|
||||
const (
|
||||
ageChunkSize = 64 * 1024
|
||||
ageChunkTagSize = 16
|
||||
ageSegmentSize = ageChunkSize + ageChunkTagSize
|
||||
ageNonceSize = 16
|
||||
)
|
||||
|
||||
// makeIdentity returns a fresh X25519 identity and its recipient string.
|
||||
func makeIdentity(t *testing.T) (*age.X25519Identity, string) {
|
||||
t.Helper()
|
||||
|
||||
id, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
|
||||
return id, id.Recipient().String()
|
||||
}
|
||||
|
||||
// randomBytes returns n cryptographically random bytes, which do not compress
|
||||
// so the encrypted payload spans multiple age segments.
|
||||
func randomBytes(t *testing.T, n int) []byte {
|
||||
t.Helper()
|
||||
|
||||
b := make([]byte, n)
|
||||
_, err := rand.Read(b)
|
||||
require.NoError(t, err)
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
// compressibleBytes returns n bytes of a repeating pattern, which zstd packs
|
||||
// down to a small payload.
|
||||
func compressibleBytes(n int) []byte {
|
||||
pattern := bytes.Repeat([]byte("compressible-"), n/13+1)
|
||||
|
||||
return pattern[:n]
|
||||
}
|
||||
|
||||
// encryptBlob compresses, encrypts and returns a blob for plaintext at
|
||||
// compression level 1.
|
||||
func encryptBlob(t *testing.T, plaintext []byte, recipients ...string) []byte {
|
||||
t.Helper()
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
w, err := blobgen.NewWriter(&buf, 1, recipients)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = w.Write(plaintext)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, w.Close())
|
||||
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// ageHeaderLen returns the byte length of blob's age header, i.e. the offset
|
||||
// of the 16-byte payload nonce that follows it. The header ends with a MAC
|
||||
// line "--- <mac>\n"; the nonce begins right after that newline.
|
||||
func ageHeaderLen(t *testing.T, blob []byte) int {
|
||||
t.Helper()
|
||||
|
||||
i := bytes.Index(blob, []byte("\n--- "))
|
||||
require.GreaterOrEqual(t, i, 0, "age MAC footer line not found")
|
||||
|
||||
nl := bytes.IndexByte(blob[i+1:], '\n')
|
||||
require.GreaterOrEqual(t, nl, 0, "newline ending MAC line not found")
|
||||
|
||||
return i + 1 + nl + 1
|
||||
}
|
||||
|
||||
// requireBlobUnreadable asserts that data never decrypts to a plaintext with a
|
||||
// nil error: either NewReader fails, or reading it does.
|
||||
func requireBlobUnreadable(t *testing.T, data []byte, id age.Identity) {
|
||||
t.Helper()
|
||||
|
||||
r, err := blobgen.NewReader(bytes.NewReader(data), id)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_, err = io.ReadAll(r)
|
||||
_ = r.Close()
|
||||
|
||||
require.Error(t, err, "reading a damaged blob must fail")
|
||||
}
|
||||
|
||||
// errFailWriter is returned by failAfterWriter once its byte limit is passed.
|
||||
var errFailWriter = errors.New("destination write failed")
|
||||
|
||||
// failAfterWriter accepts writes until more than limit bytes have been sent,
|
||||
// then fails every write. It models a destination that dies mid-blob.
|
||||
type failAfterWriter struct {
|
||||
limit int
|
||||
written int
|
||||
}
|
||||
|
||||
func (f *failAfterWriter) Write(p []byte) (int, error) {
|
||||
f.written += len(p)
|
||||
if f.written > f.limit {
|
||||
return 0, errFailWriter
|
||||
}
|
||||
|
||||
return len(p), nil
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package blobgen
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
// ErrOutputTooLarge is returned by a reader from LimitReader once it has
|
||||
// been asked for more than its limit. It bounds how far an untrusted
|
||||
// compressed stream may expand, so a small, highly compressible object
|
||||
// from the store cannot decompress without limit.
|
||||
var ErrOutputTooLarge = errors.New("output exceeds size limit")
|
||||
|
||||
// LimitReader returns a reader that yields at most limit bytes from r and
|
||||
// then fails with ErrOutputTooLarge. Unlike io.LimitReader, which reports
|
||||
// a silent io.EOF at the limit (indistinguishable from a stream that
|
||||
// simply ended), this fails, so a caller decoding or copying the stream
|
||||
// sees an error rather than a truncated value. A stream of exactly limit
|
||||
// bytes reads back cleanly to EOF; the first byte beyond it is the error.
|
||||
func LimitReader(r io.Reader, limit int64) io.Reader {
|
||||
// remaining counts down from limit+1: the extra byte is the one that,
|
||||
// if it ever arrives, proves the stream is longer than the limit.
|
||||
return &limitReader{r: r, remaining: limit + 1}
|
||||
}
|
||||
|
||||
type limitReader struct {
|
||||
r io.Reader
|
||||
remaining int64
|
||||
}
|
||||
|
||||
func (l *limitReader) Read(p []byte) (int, error) {
|
||||
if l.remaining <= 0 {
|
||||
return 0, ErrOutputTooLarge
|
||||
}
|
||||
|
||||
if int64(len(p)) > l.remaining {
|
||||
p = p[:l.remaining]
|
||||
}
|
||||
|
||||
n, err := l.r.Read(p)
|
||||
l.remaining -= int64(n)
|
||||
|
||||
if l.remaining <= 0 {
|
||||
// The (limit+1)th byte was just read: the stream is too long.
|
||||
return n, ErrOutputTooLarge
|
||||
}
|
||||
|
||||
return n, err
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package blobgen_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||
)
|
||||
|
||||
// TestLimitReaderPassesExactSize checks that a stream of exactly the limit
|
||||
// reads back cleanly to EOF: the bound must not reject a legitimate blob
|
||||
// whose plaintext equals its recorded size.
|
||||
func TestLimitReaderPassesExactSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const n = 1000
|
||||
|
||||
r := blobgen.LimitReader(bytes.NewReader(bytes.Repeat([]byte("a"), n)), n)
|
||||
|
||||
got, err := io.ReadAll(r)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, n)
|
||||
}
|
||||
|
||||
// TestLimitReaderFailsPastLimit feeds a large, highly compressible run of
|
||||
// zeros — the decompressed output a zip bomb would produce — through a
|
||||
// small limit and checks it fails within the bound rather than passing
|
||||
// the whole stream through.
|
||||
func TestLimitReaderFailsPastLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const limit = 1000
|
||||
|
||||
r := blobgen.LimitReader(
|
||||
bytes.NewReader(bytes.Repeat([]byte{0}, limit*1000)), limit)
|
||||
|
||||
n, err := io.Copy(io.Discard, r)
|
||||
require.ErrorIs(t, err, blobgen.ErrOutputTooLarge)
|
||||
require.LessOrEqual(t, n, int64(limit)+1,
|
||||
"reader must stop within one byte of the limit")
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
package blobgen_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||
)
|
||||
|
||||
// TestNewReaderWrongIdentity covers issue case 4: opening a blob with an
|
||||
// identity other than the recipient reports no matching identity.
|
||||
func TestNewReaderWrongIdentity(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, recipient := makeIdentity(t)
|
||||
other, _ := makeIdentity(t)
|
||||
|
||||
blob := encryptBlob(t, []byte("secret payload"), recipient)
|
||||
|
||||
_, err := blobgen.NewReader(bytes.NewReader(blob), other)
|
||||
require.Error(t, err)
|
||||
|
||||
var noMatch *age.NoIdentityMatchError
|
||||
assert.ErrorAs(t, err, &noMatch)
|
||||
}
|
||||
|
||||
// TestNewReaderTruncated covers issue case 6: a multi-segment blob cut at
|
||||
// several points must never read back as valid data. The point immediately
|
||||
// after the header and nonce is intentionally excluded: it reads as a valid
|
||||
// empty blob today and is the regression case for
|
||||
// https://git.eeqj.de/sneak/vaultik/issues/152.
|
||||
func TestNewReaderTruncated(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
id, recipient := makeIdentity(t)
|
||||
blob := encryptBlob(t, randomBytes(t, 4*65536+123), recipient)
|
||||
h := ageHeaderLen(t, blob)
|
||||
|
||||
require.Greater(t, len(blob), h+ageNonceSize+ageSegmentSize,
|
||||
"test needs a blob of at least two age segments")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
size int
|
||||
}{
|
||||
{"inside header", h / 2},
|
||||
{"inside nonce", h + 8},
|
||||
{"inside first segment", h + ageNonceSize + 100},
|
||||
{"end of first full segment", h + ageNonceSize + ageSegmentSize},
|
||||
{"last byte removed", len(blob) - 1},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
requireBlobUnreadable(t, blob[:tc.size], id)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewReaderCorrupted covers issue case 7: one flipped byte in each region
|
||||
// of a multi-segment blob makes it unreadable.
|
||||
func TestNewReaderCorrupted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
id, recipient := makeIdentity(t)
|
||||
blob := encryptBlob(t, randomBytes(t, 4*65536+123), recipient)
|
||||
h := ageHeaderLen(t, blob)
|
||||
|
||||
firstNL := bytes.IndexByte(blob, '\n')
|
||||
require.Positive(t, firstNL, "header must have a version line")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
pos int
|
||||
}{
|
||||
{"header stanza", firstNL + 5},
|
||||
{"header MAC line", h - 2},
|
||||
{"nonce", h + 4},
|
||||
{"body segment", h + ageNonceSize + 50},
|
||||
{"final tag", len(blob) - 1},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
corrupt := append([]byte(nil), blob...)
|
||||
corrupt[tc.pos] ^= 0xff
|
||||
requireBlobUnreadable(t, corrupt, id)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewReaderTrailingAndGarbage covers issue case 8: bytes appended after a
|
||||
// valid blob, empty input, and random garbage each fail to read.
|
||||
func TestNewReaderTrailingAndGarbage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
id, recipient := makeIdentity(t)
|
||||
|
||||
valid := encryptBlob(t, []byte("small payload"), recipient)
|
||||
appended := append(append([]byte(nil), valid...), []byte("trailing junk")...)
|
||||
|
||||
t.Run("appended bytes", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
requireBlobUnreadable(t, appended, id)
|
||||
})
|
||||
t.Run("empty input", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
requireBlobUnreadable(t, []byte{}, id)
|
||||
})
|
||||
t.Run("random garbage", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
requireBlobUnreadable(t, randomBytes(t, 512), id)
|
||||
})
|
||||
}
|
||||
|
||||
// TestNewWriterInvalidLevel covers the rejected end of issue case 9: an
|
||||
// out-of-range compression level errors and writes nothing to the destination.
|
||||
func TestNewWriterInvalidLevel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, recipient := makeIdentity(t)
|
||||
|
||||
for _, level := range []int{0, -1, 20} {
|
||||
t.Run(fmt.Sprintf("level%d", level), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
w, err := blobgen.NewWriter(&buf, level, []string{recipient})
|
||||
require.ErrorIs(t, err, blobgen.ErrInvalidCompressionLevel)
|
||||
assert.Nil(t, w)
|
||||
assert.Zero(t, buf.Len(), "nothing written on an invalid level")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewWriterInvalidRecipients covers issue case 10: nil and empty recipient
|
||||
// lists and an unparsable recipient string each error.
|
||||
func TestNewWriterInvalidRecipients(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
recipients []string
|
||||
}{
|
||||
{"nil list", nil},
|
||||
{"empty list", []string{}},
|
||||
{"invalid recipient string", []string{"not-a-recipient"}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
w, err := blobgen.NewWriter(&buf, 1, tc.recipients)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, w)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewWriterFailingDestination covers issue case 11: a destination that
|
||||
// fails mid-blob surfaces its error from Write or Close.
|
||||
func TestNewWriterFailingDestination(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, recipient := makeIdentity(t)
|
||||
|
||||
// The limit clears the age header and nonce so NewWriter succeeds, then
|
||||
// trips once the compressed body starts flowing.
|
||||
dst := &failAfterWriter{limit: 512}
|
||||
|
||||
w, err := blobgen.NewWriter(dst, 1, []string{recipient})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, writeErr := w.Write(randomBytes(t, 256*1024))
|
||||
closeErr := w.Close()
|
||||
|
||||
assert.True(t, writeErr != nil || closeErr != nil,
|
||||
"destination failure must surface from Write or Close")
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package blobgen
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
@@ -21,12 +20,10 @@ type Reader struct {
|
||||
bytesRead int64
|
||||
}
|
||||
|
||||
// NewReader creates a new Reader that decrypts, decompresses, and verifies
|
||||
// data. Every supplied identity is offered to age.Decrypt, so a blob
|
||||
// encrypted to any one of them can be read.
|
||||
func NewReader(r io.Reader, identities ...age.Identity) (*Reader, error) {
|
||||
// NewReader creates a new Reader that decrypts, decompresses, and verifies data
|
||||
func NewReader(r io.Reader, identity age.Identity) (*Reader, error) {
|
||||
// Create decryption reader
|
||||
decReader, err := age.Decrypt(r, identities...)
|
||||
decReader, err := age.Decrypt(r, identity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating decryption reader: %w", err)
|
||||
}
|
||||
@@ -57,22 +54,6 @@ func (r *Reader) Read(p []byte) (int, error) {
|
||||
n, err := r.teeReader.Read(p)
|
||||
r.bytesRead += int64(n)
|
||||
|
||||
// When the ciphertext is cut right after the age header plus its
|
||||
// 16-byte nonce, the age reader's first read fails with
|
||||
// io.ErrUnexpectedEOF, and the zstd decoder maps that to a clean
|
||||
// io.EOF at frame start. That makes a truncated stream look like a
|
||||
// valid empty one. Distinguish the two: on EOF, read once more from
|
||||
// the age reader. A genuine end leaves it at (0, io.EOF); a truncated
|
||||
// stream leaves its stored io.ErrUnexpectedEOF, which we surface.
|
||||
if errors.Is(err, io.EOF) {
|
||||
var probe [1]byte
|
||||
|
||||
m, ageErr := r.decryptor.Read(probe[:])
|
||||
if m != 0 || !errors.Is(ageErr, io.EOF) {
|
||||
return n, io.ErrUnexpectedEOF
|
||||
}
|
||||
}
|
||||
|
||||
return n, err
|
||||
}
|
||||
|
||||
@@ -83,9 +64,7 @@ func (r *Reader) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sum256 returns the single SHA-256 of the plaintext read so far. This is the
|
||||
// first hash only; the stored object name is its double hash, which callers
|
||||
// obtain by passing this digest to DoubleSHA256.
|
||||
// Sum256 returns the SHA256 hash of all data read
|
||||
func (r *Reader) Sum256() []byte {
|
||||
return r.hasher.Sum(nil)
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
package blobgen_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||
)
|
||||
|
||||
// TestMultipleRecipients verifies that data written for several recipients can
|
||||
// be read back by each recipient's identity. Moved from internal/crypto, which
|
||||
// held the only multi-recipient test; blobgen is now the sole encryption path.
|
||||
func TestMultipleRecipients(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
identities := make([]*age.X25519Identity, 3)
|
||||
recipients := make([]string, 3)
|
||||
|
||||
for i := range identities {
|
||||
identity, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
|
||||
identities[i] = identity
|
||||
recipients[i] = identity.Recipient().String()
|
||||
}
|
||||
|
||||
plaintext := []byte("Secret message for multiple recipients")
|
||||
|
||||
var encrypted bytes.Buffer
|
||||
|
||||
writer, err := blobgen.NewWriter(&encrypted, 3, recipients)
|
||||
require.NoError(t, err)
|
||||
_, err = writer.Write(plaintext)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, writer.Close())
|
||||
|
||||
// Every recipient's identity must recover the original plaintext.
|
||||
for i, identity := range identities {
|
||||
reader, err := blobgen.NewReader(
|
||||
bytes.NewReader(encrypted.Bytes()), identity)
|
||||
require.NoError(t, err, "recipient %d should open the reader", i+1)
|
||||
|
||||
got, err := io.ReadAll(reader)
|
||||
require.NoError(t, err, "recipient %d should read the plaintext", i+1)
|
||||
require.NoError(t, reader.Close())
|
||||
|
||||
assert.Equal(t, plaintext, got,
|
||||
"recipient %d should recover the original plaintext", i+1)
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
package blobgen_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||
)
|
||||
|
||||
// checkRoundTrip writes input through a Writer, reads it back through a Reader,
|
||||
// and verifies the plaintext, the byte counts, and the content hashes.
|
||||
func checkRoundTrip(
|
||||
t *testing.T, id *age.X25519Identity, recipient string,
|
||||
level int, input []byte,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
w, err := blobgen.NewWriter(&buf, level, []string{recipient})
|
||||
require.NoError(t, err)
|
||||
|
||||
n, err := w.Write(input)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, len(input), n)
|
||||
require.NoError(t, w.Close())
|
||||
require.Equal(t, int64(len(input)), w.BytesWritten())
|
||||
|
||||
r, err := blobgen.NewReader(bytes.NewReader(buf.Bytes()), id)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := io.ReadAll(r)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, r.Close())
|
||||
|
||||
assert.Equal(t, input, got, "decrypted output must equal input")
|
||||
require.Equal(t, int64(len(input)), r.BytesRead())
|
||||
|
||||
// The hash values are checked by decrypting: the reader's single SHA-256
|
||||
// is the hash of the plaintext, and hashing it once more (DoubleSHA256)
|
||||
// gives the writer's ContentID.
|
||||
single := sha256.Sum256(got)
|
||||
assert.Equal(t, single[:], r.Sum256())
|
||||
assert.Equal(t, blobgen.DoubleSHA256(r.Sum256()), w.ContentID())
|
||||
}
|
||||
|
||||
// TestWriterReaderRoundTrip covers issue cases 1 and 2: every size round trips
|
||||
// for both random and compressible data, and the reader hash, its double hash
|
||||
// and the byte counts all agree.
|
||||
func TestWriterReaderRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
id, recipient := makeIdentity(t)
|
||||
|
||||
// Sizes exercise the age segment boundary (64 KiB) from just below to a
|
||||
// few segments above it, plus the empty and single-byte edges.
|
||||
sizes := []int{0, 1, 65535, 65536, 65537, 4*65536 + 123}
|
||||
|
||||
kinds := []struct {
|
||||
name string
|
||||
fill func(*testing.T, int) []byte
|
||||
}{
|
||||
{"random", randomBytes},
|
||||
{"compressible", func(_ *testing.T, n int) []byte {
|
||||
return compressibleBytes(n)
|
||||
}},
|
||||
}
|
||||
|
||||
for _, k := range kinds {
|
||||
for _, size := range sizes {
|
||||
name := fmt.Sprintf("%s/%d", k.name, size)
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
checkRoundTrip(t, id, recipient, 1, k.fill(t, size))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestZeroLengthNoWrite covers issue case 3: a Writer closed with no Write at
|
||||
// all produces the double hash of the empty input, and the blob reads back as
|
||||
// empty with no error.
|
||||
func TestZeroLengthNoWrite(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
id, recipient := makeIdentity(t)
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
w, err := blobgen.NewWriter(&buf, 1, []string{recipient})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, w.Close())
|
||||
assert.Equal(t, int64(0), w.BytesWritten())
|
||||
|
||||
empty := sha256.Sum256(nil)
|
||||
doubled := sha256.Sum256(empty[:])
|
||||
assert.Equal(t, doubled[:], w.ContentID(),
|
||||
"ContentID of empty input is SHA256(SHA256(\"\"))")
|
||||
|
||||
r, err := blobgen.NewReader(bytes.NewReader(buf.Bytes()), id)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := io.ReadAll(r)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, r.Close())
|
||||
|
||||
assert.Empty(t, got, "empty blob decrypts to empty output")
|
||||
assert.Equal(t, int64(0), r.BytesRead())
|
||||
assert.Equal(t, empty[:], r.Sum256())
|
||||
}
|
||||
|
||||
// TestNewWriterValidLevelsRoundTrip covers the accepted end of issue case 9:
|
||||
// the boundary compression levels 1 and 19 both round trip.
|
||||
func TestNewWriterValidLevelsRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
id, recipient := makeIdentity(t)
|
||||
input := randomBytes(t, 4096)
|
||||
|
||||
for _, level := range []int{1, 19} {
|
||||
t.Run(fmt.Sprintf("level%d", level), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
checkRoundTrip(t, id, recipient, level, input)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
package blobgen_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||
)
|
||||
|
||||
// TestReaderRejectsHeaderNonceTruncation guards against a stream cut right
|
||||
// after the age header plus its 16-byte nonce. age.Decrypt still succeeds on
|
||||
// such an object, and the zstd decoder maps the age reader's
|
||||
// io.ErrUnexpectedEOF to a clean io.EOF at frame start, so without the extra
|
||||
// check the truncated stream would read as a valid empty one. Reading it must
|
||||
// now fail.
|
||||
func TestReaderRejectsHeaderNonceTruncation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
identity, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Encrypting empty plaintext yields header + nonce(16) + a single
|
||||
// 16-byte final chunk tag. Dropping the trailing tag leaves exactly the
|
||||
// age header plus its nonce — the truncation point that triggers the bug.
|
||||
var full bytes.Buffer
|
||||
|
||||
w, err := age.Encrypt(&full, identity.Recipient())
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, w.Close())
|
||||
|
||||
truncated := full.Bytes()[:full.Len()-16]
|
||||
|
||||
reader, err := blobgen.NewReader(bytes.NewReader(truncated), identity)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { _ = reader.Close() }()
|
||||
|
||||
_, err = io.ReadAll(reader)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, io.ErrUnexpectedEOF)
|
||||
}
|
||||
|
||||
// TestReaderReadsGenuinelyEmptyBlob confirms the truncation check does not
|
||||
// reject a legitimately empty payload: a blob written with no data must round
|
||||
// trip back to zero bytes with no error.
|
||||
func TestReaderReadsGenuinelyEmptyBlob(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
identity, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
|
||||
var encrypted bytes.Buffer
|
||||
|
||||
writer, err := blobgen.NewWriter(
|
||||
&encrypted, 3, []string{identity.Recipient().String()})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, writer.Close())
|
||||
|
||||
reader, err := blobgen.NewReader(bytes.NewReader(encrypted.Bytes()), identity)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { _ = reader.Close() }()
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, data)
|
||||
}
|
||||
+13
-36
@@ -1,6 +1,3 @@
|
||||
// Package blobgen implements the blob data pipeline: streaming zstd
|
||||
// compression, age encryption, and SHA256 content hashing for blob
|
||||
// creation, plus the matching decrypt/decompress/verify reader.
|
||||
package blobgen
|
||||
|
||||
import (
|
||||
@@ -15,24 +12,6 @@ import (
|
||||
"github.com/klauspost/compress/zstd"
|
||||
)
|
||||
|
||||
// DoubleSHA256 returns the double SHA-256 of content whose single SHA-256
|
||||
// digest is sum: it hashes that digest once more. Stored objects — a blob, and
|
||||
// the metadata database export — are named by this second hash.
|
||||
//
|
||||
// The second hash does not hide whether known content is stored: an attacker
|
||||
// who can reproduce an object's entire plaintext computes the same name simply
|
||||
// by hashing twice, exactly as this code does. What limits that is blob
|
||||
// packing, not the double hash — a blob's name covers all of its concatenated
|
||||
// chunk plaintext, so a name can be confirmed only by someone who can
|
||||
// reproduce the whole blob (a snapshot made entirely of known content, or a
|
||||
// known file large enough to fill blobs on its own). An ordinary file that
|
||||
// shares a blob with other, unknown data cannot be confirmed this way.
|
||||
func DoubleSHA256(sum []byte) []byte {
|
||||
h := sha256.Sum256(sum)
|
||||
|
||||
return h[:]
|
||||
}
|
||||
|
||||
// Zstd compression level bounds accepted by NewWriter.
|
||||
const (
|
||||
minCompressionLevel = 1
|
||||
@@ -48,11 +27,6 @@ const reservedCompressionCPUs = 2
|
||||
var ErrInvalidCompressionLevel = errors.New(
|
||||
"invalid compression level: must be between 1 and 19")
|
||||
|
||||
// errInvalidRecipient is returned when a recipient string does not parse as
|
||||
// an X25519 age1... public key. It omits the value, which can be sensitive.
|
||||
var errInvalidRecipient = errors.New(
|
||||
"not a valid X25519 age1... recipient")
|
||||
|
||||
// Writer wraps compression and encryption with SHA256 hashing.
|
||||
// Data flows: input -> tee(hasher, compressor -> encryptor -> destination)
|
||||
// The hash is computed on the uncompressed input for deterministic content-addressing.
|
||||
@@ -83,12 +57,10 @@ func NewWriter(
|
||||
// Parse recipients
|
||||
var ageRecipients []age.Recipient
|
||||
|
||||
for i, recipient := range recipients {
|
||||
// The recipient string can be sensitive (e.g. a secret key pasted by
|
||||
// mistake), so the error names its position, never its value.
|
||||
for _, recipient := range recipients {
|
||||
r, err := age.ParseX25519Recipient(recipient)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: recipient %d", errInvalidRecipient, i)
|
||||
return nil, fmt.Errorf("parsing recipient %s: %w", recipient, err)
|
||||
}
|
||||
|
||||
ageRecipients = append(ageRecipients, r)
|
||||
@@ -151,12 +123,17 @@ func (w *Writer) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ContentID returns the double SHA-256 of the uncompressed input data: the
|
||||
// name under which this content is stored. It is the second hash of the
|
||||
// running SHA-256, via DoubleSHA256; see that function for what naming content
|
||||
// this way does and does not hide.
|
||||
func (w *Writer) ContentID() []byte {
|
||||
return DoubleSHA256(w.hasher.Sum(nil))
|
||||
// Sum256 returns the double SHA256 hash of the uncompressed input data.
|
||||
// Double hashing (SHA256(SHA256(data))) prevents information leakage about
|
||||
// the plaintext - an attacker cannot confirm existence of known content
|
||||
// by computing its hash and checking for a matching blob filename.
|
||||
func (w *Writer) Sum256() []byte {
|
||||
// First hash: SHA256(plaintext)
|
||||
firstHash := w.hasher.Sum(nil)
|
||||
// Second hash: SHA256(firstHash) - this is the blob ID
|
||||
secondHash := sha256.Sum256(firstHash)
|
||||
|
||||
return secondHash[:]
|
||||
}
|
||||
|
||||
// BytesWritten returns the number of uncompressed bytes written
|
||||
|
||||
@@ -12,10 +12,9 @@ import (
|
||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||
)
|
||||
|
||||
// TestWriterHashIsDoubleHash verifies that Writer.ContentID() returns
|
||||
// SHA256(SHA256(plaintext)). Stored objects are named by this second hash so a
|
||||
// name is not the plaintext's own SHA-256; this does not stop someone who
|
||||
// already holds the plaintext from confirming it.
|
||||
// TestWriterHashIsDoubleHash verifies that Writer.Sum256() returns
|
||||
// the double hash SHA256(SHA256(plaintext)) for security.
|
||||
// Double hashing prevents attackers from confirming existence of known content.
|
||||
func TestWriterHashIsDoubleHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -44,7 +43,7 @@ func TestWriterHashIsDoubleHash(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get the hash from the writer
|
||||
writerHash := hex.EncodeToString(writer.ContentID())
|
||||
writerHash := hex.EncodeToString(writer.Sum256())
|
||||
|
||||
// Calculate the expected double hash: SHA256(SHA256(plaintext))
|
||||
firstHash := sha256.Sum256(testData)
|
||||
@@ -61,11 +60,11 @@ func TestWriterHashIsDoubleHash(t *testing.T) {
|
||||
|
||||
// The writer hash should match the double hash
|
||||
assert.Equal(t, expectedDoubleHash, writerHash,
|
||||
"Writer.ContentID() must be SHA256(SHA256(plaintext))")
|
||||
"Writer.Sum256() should return SHA256(SHA256(plaintext)) for security")
|
||||
|
||||
// It must be the second hash, not the plaintext's own SHA-256.
|
||||
// Verify it's NOT the single hash (would leak information)
|
||||
assert.NotEqual(t, singleHashStr, writerHash,
|
||||
"Writer hash must be the double hash, not the single SHA-256")
|
||||
"Writer hash should not be single hash (would allow content confirmation attacks)")
|
||||
}
|
||||
|
||||
// TestWriterDeterministicHash verifies that the same input always produces
|
||||
@@ -94,8 +93,8 @@ func TestWriterDeterministicHash(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, writer2.Close())
|
||||
|
||||
hash1 := hex.EncodeToString(writer1.ContentID())
|
||||
hash2 := hex.EncodeToString(writer2.ContentID())
|
||||
hash1 := hex.EncodeToString(writer1.Sum256())
|
||||
hash2 := hex.EncodeToString(writer2.Sum256())
|
||||
|
||||
// Hashes should be identical (deterministic)
|
||||
assert.Equal(t, hash1, hash2, "Same input should produce same hash")
|
||||
@@ -109,20 +108,3 @@ func TestWriterDeterministicHash(t *testing.T) {
|
||||
t.Logf("Encrypted size 1: %d bytes", buf1.Len())
|
||||
t.Logf("Encrypted size 2: %d bytes", buf2.Len())
|
||||
}
|
||||
|
||||
// TestNewWriterSecretKeyNotEchoed verifies that a secret key mistakenly passed
|
||||
// as a recipient does not appear in the returned error. A recipient string can
|
||||
// be sensitive, so the error must name only the position, not the value.
|
||||
func TestNewWriterSecretKeyNotEchoed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
secretKey := "AGE-SECRET-KEY-19CR5YSFW59HM4TLD6GX" +
|
||||
"VEDMZFTVVF7PPHKUT68TXSFPK7APHXA2QS2NJA5"
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
_, err := blobgen.NewWriter(&buf, 3, []string{secretKey})
|
||||
require.Error(t, err)
|
||||
assert.NotContains(t, err.Error(), secretKey,
|
||||
"error must not echo the recipient value")
|
||||
}
|
||||
|
||||
@@ -33,10 +33,9 @@ type Chunker struct {
|
||||
maxChunkSize int
|
||||
}
|
||||
|
||||
// ChunkSizeSpread is the FastCDC-recommended factor between the average
|
||||
// chunkSizeSpread is the FastCDC-recommended factor between the average
|
||||
// chunk size and the minimum (avg/spread) and maximum (avg*spread) sizes.
|
||||
// The largest chunk the chunker can emit is therefore avg*ChunkSizeSpread.
|
||||
const ChunkSizeSpread = 4
|
||||
const chunkSizeSpread = 4
|
||||
|
||||
// NewChunker creates a new chunker with the specified average chunk size.
|
||||
// The actual chunk sizes will vary between avgChunkSize/4 and avgChunkSize*4
|
||||
@@ -46,8 +45,8 @@ func NewChunker(avgChunkSize int64) *Chunker {
|
||||
// FastCDC recommends min = avg/4 and max = avg*4
|
||||
return &Chunker{
|
||||
avgChunkSize: int(avgChunkSize),
|
||||
minChunkSize: int(avgChunkSize / ChunkSizeSpread),
|
||||
maxChunkSize: int(avgChunkSize * ChunkSizeSpread),
|
||||
minChunkSize: int(avgChunkSize / chunkSizeSpread),
|
||||
maxChunkSize: int(avgChunkSize * chunkSizeSpread),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+81
-111
@@ -7,9 +7,12 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/adrg/xdg"
|
||||
@@ -30,33 +33,14 @@ import (
|
||||
// may take before we give up.
|
||||
const shutdownTimeout = 30 * time.Second
|
||||
|
||||
// lockMode says whether a command mutates persistent state — the local
|
||||
// index database or the remote store — and so must hold the process-wide
|
||||
// PID lock, or only reads that state and may run alongside a mutator.
|
||||
type lockMode int
|
||||
|
||||
const (
|
||||
// mutating commands (snapshot create, snapshot purge, snapshot remove,
|
||||
// prune, remote nuke) write the local index or the remote store. They
|
||||
// hold the PID lock so that at most one runs at a time.
|
||||
mutating lockMode = iota
|
||||
// readOnly commands (info, snapshot list, snapshot verify, remote info,
|
||||
// snapshot restore) do not write the local index or the remote store,
|
||||
// so they run without the lock and are never blocked by a running
|
||||
// mutator. restore writes only to the target directory it is given.
|
||||
readOnly
|
||||
)
|
||||
|
||||
// AppOptions contains common options for creating and running the fx
|
||||
// application: the configuration file path, logging options, additional fx
|
||||
// modules and invocations, and whether the command mutates persistent
|
||||
// state (which decides whether it takes the PID lock).
|
||||
// AppOptions contains common options for creating the fx application.
|
||||
// It includes the configuration file path, logging options, and additional
|
||||
// fx modules and invocations that should be included in the application.
|
||||
type AppOptions struct {
|
||||
ConfigPath string
|
||||
LogOptions log.Options
|
||||
Modules []fx.Option
|
||||
Invokes []fx.Option
|
||||
Mode lockMode
|
||||
}
|
||||
|
||||
// setupGlobals records the startup time and, when an output-suppression
|
||||
@@ -158,45 +142,64 @@ func cleanStartupError(err error) error {
|
||||
return &startupError{msg: msg}
|
||||
}
|
||||
|
||||
// RunApp starts the fx application, blocks until it is asked to stop, and
|
||||
// then stops it. The app is asked to stop either by an OS interrupt
|
||||
// (SIGINT/SIGTERM — fx installs its own handler when app.Wait is called) or,
|
||||
// on normal completion, by the finished operation calling
|
||||
// Shutdowner.Shutdown(); both arrive on the app.Wait channel.
|
||||
//
|
||||
// Stopping runs the fx OnStop hooks, and RunApp does not return until Stop
|
||||
// returns. On an interrupt the operation's OnStop hook cancels the running
|
||||
// command and waits for it to unwind — removing its decrypted scratch files —
|
||||
// so the process cannot proceed to exit mid-cleanup (issue #159). Waiting for
|
||||
// Stop before returning is what makes that hook effective: routing the
|
||||
// interrupt through app.Stop and not returning until it completes is required,
|
||||
// because fx also fires the app.Wait channel on the signal, and an earlier
|
||||
// version returned on that alone — unwinding to os.Exit while the concurrent
|
||||
// cleanup still ran. The stop is bounded by shutdownTimeout. Returns an error
|
||||
// if startup fails.
|
||||
// RunApp starts and stops the fx application within the given context.
|
||||
// It handles graceful shutdown on interrupt signals (SIGINT, SIGTERM) and
|
||||
// ensures the application stops cleanly. The function blocks until the
|
||||
// application completes or is interrupted. Returns an error if startup fails.
|
||||
func RunApp(ctx context.Context, app *fx.App) error {
|
||||
// Set up signal handling for graceful shutdown
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
// Create a context that will be cancelled on signal
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
// Start the app
|
||||
err := app.Start(ctx)
|
||||
if err != nil {
|
||||
return cleanStartupError(err)
|
||||
}
|
||||
|
||||
// Block until an interrupt or the finished operation's
|
||||
// Shutdowner.Shutdown() arrives, then stop the app in this goroutine so we
|
||||
// return only after its OnStop hooks — including the operation's cleanup
|
||||
// wait — have run. Detach the stop from ctx's cancellation but keep its
|
||||
// values, and bound it by shutdownTimeout.
|
||||
<-app.Wait()
|
||||
// Handle shutdown
|
||||
shutdownComplete := make(chan struct{})
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(
|
||||
context.WithoutCancel(ctx), shutdownTimeout)
|
||||
defer cancel()
|
||||
go func() {
|
||||
defer close(shutdownComplete)
|
||||
|
||||
err = app.Stop(shutdownCtx)
|
||||
if err != nil {
|
||||
log.Error("Error during shutdown", "error", err)
|
||||
<-sigChan
|
||||
log.Notice("Received interrupt signal, shutting down gracefully...")
|
||||
|
||||
// Create a timeout context for shutdown. The parent ctx is being
|
||||
// cancelled, so detach from its cancellation but keep its values.
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(
|
||||
context.WithoutCancel(ctx), shutdownTimeout)
|
||||
defer shutdownCancel()
|
||||
|
||||
err := app.Stop(shutdownCtx)
|
||||
if err != nil {
|
||||
log.Error("Error during shutdown", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for the signal handler to complete shutdown or the app to
|
||||
// request shutdown.
|
||||
select {
|
||||
case <-shutdownComplete:
|
||||
// Shutdown completed via signal
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
// Context cancelled (shouldn't happen in normal operation)
|
||||
err := app.Stop(context.WithoutCancel(ctx))
|
||||
if err != nil {
|
||||
log.Error("Error stopping app", "error", err)
|
||||
}
|
||||
|
||||
return ctx.Err()
|
||||
case <-app.Done():
|
||||
// App finished running (e.g., backup completed)
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// errReported marks a failure the operation has already shown the user
|
||||
@@ -216,10 +219,7 @@ var errReported = errors.New("operation failed")
|
||||
//
|
||||
// op runs in a goroutine so OnStart returns promptly and an interrupt
|
||||
// can still cancel through OnStop; when it finishes, success or failure,
|
||||
// it triggers shutdown, which is what lets RunWithApp return. On an
|
||||
// interrupt OnStop cancels op and waits for the goroutine to return, so
|
||||
// op's cleanup (removing decrypted scratch files) runs before the
|
||||
// process exits; the wait is bounded by shutdownTimeout. report is
|
||||
// it triggers shutdown, which is what lets RunWithApp return. report is
|
||||
// called with a non-canceled failure so the caller can log it (and
|
||||
// suppress it under --json) before it becomes errReported. A context
|
||||
// cancellation is the interrupt path, not a failure: it is neither
|
||||
@@ -235,11 +235,9 @@ func RunOperation(
|
||||
|
||||
opts.Invokes = append(opts.Invokes,
|
||||
fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) {
|
||||
var stop func(context.Context) bool
|
||||
|
||||
lc.Append(fx.Hook{
|
||||
OnStart: func(_ context.Context) error {
|
||||
stop = v.StartOperation(func() {
|
||||
go func() {
|
||||
err := op(v)
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
report(err)
|
||||
@@ -253,20 +251,12 @@ func RunOperation(
|
||||
if stopErr != nil {
|
||||
log.Error("Failed to shutdown", "error", stopErr)
|
||||
}
|
||||
})
|
||||
}()
|
||||
|
||||
return nil
|
||||
},
|
||||
// On an interrupt, cancel the operation and wait for it to
|
||||
// unwind so its cleanup defers (which remove decrypted
|
||||
// scratch files from the temp directory) run before the
|
||||
// process exits. The wait is bounded by ctx, the existing
|
||||
// shutdownTimeout.
|
||||
OnStop: func(ctx context.Context) error {
|
||||
if !stop(ctx) {
|
||||
log.Warn("Shutdown timed out before the operation " +
|
||||
"finished; decrypted temporary files may remain")
|
||||
}
|
||||
OnStop: func(_ context.Context) error {
|
||||
v.Cancel()
|
||||
|
||||
return nil
|
||||
},
|
||||
@@ -291,15 +281,14 @@ func RunOperation(
|
||||
}
|
||||
|
||||
// runVaultikApp runs the standard single-operation command lifecycle
|
||||
// shared by the snapshot list/purge/remove and remote nuke subcommands:
|
||||
// shared by the list/purge/verify/remove/remote-info subcommands:
|
||||
// resolve the config, then run op against the Vaultik instance through
|
||||
// RunOperation, reporting a failure prefixed with failMsg (suppressed
|
||||
// while suppressErrors is true, e.g. under --json). mode says whether the
|
||||
// command takes the PID lock. jsonOutput marks a command whose stdout is a
|
||||
// JSON document: it quiets the UI but, unlike Quiet, leaves the stderr log
|
||||
// level alone.
|
||||
// while suppressErrors is true, e.g. under --json). jsonOutput marks a
|
||||
// command whose stdout is a JSON document: it quiets the UI but, unlike
|
||||
// Quiet, leaves the stderr log level alone.
|
||||
func runVaultikApp(
|
||||
cmd *cobra.Command, mode lockMode, jsonOutput, suppressErrors bool,
|
||||
cmd *cobra.Command, jsonOutput, suppressErrors bool,
|
||||
failMsg string, op func(v *vaultik.Vaultik) error,
|
||||
) error {
|
||||
configPath, err := ResolveConfigPath()
|
||||
@@ -317,7 +306,6 @@ func runVaultikApp(
|
||||
Quiet: rootFlags.Quiet,
|
||||
JSON: jsonOutput,
|
||||
},
|
||||
Mode: mode,
|
||||
}, op, func(err error) {
|
||||
if suppressErrors {
|
||||
return
|
||||
@@ -331,46 +319,28 @@ func runVaultikApp(
|
||||
// RunWithApp is a helper that creates and runs an fx app with the given options.
|
||||
// It combines NewApp and RunApp into a single convenient function. This is the
|
||||
// preferred way to run CLI commands that need the full application context.
|
||||
// A mutating command takes the process-wide PID lock before starting so that
|
||||
// only one runs at a time; a read-only command runs without it and is not
|
||||
// blocked while a mutator holds the lock (opts.Mode).
|
||||
// It acquires a PID lock before starting to prevent concurrent instances.
|
||||
func RunWithApp(ctx context.Context, opts AppOptions) error {
|
||||
release, err := acquireLockIfMutating(opts.Mode,
|
||||
filepath.Join(xdg.DataHome, "vaultik"))
|
||||
// Acquire PID lock to prevent concurrent instances
|
||||
lockDir := filepath.Join(xdg.DataHome, "vaultik")
|
||||
|
||||
lock, err := pidlock.Acquire(lockDir)
|
||||
if err != nil {
|
||||
return err
|
||||
if errors.Is(err, pidlock.ErrAlreadyRunning) {
|
||||
return fmt.Errorf("cannot start: %w", err)
|
||||
}
|
||||
|
||||
return fmt.Errorf("failed to acquire lock: %w", err)
|
||||
}
|
||||
|
||||
defer release()
|
||||
defer func() {
|
||||
err := lock.Release()
|
||||
if err != nil {
|
||||
log.Warn("Failed to release PID lock", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
app := NewApp(opts)
|
||||
|
||||
return RunApp(ctx, app)
|
||||
}
|
||||
|
||||
// acquireLockIfMutating takes the process-wide PID lock in lockDir for a
|
||||
// mutating command and returns a function that releases it. A read-only
|
||||
// command takes no lock, so it returns a no-op release and is never blocked
|
||||
// while a mutator holds the lock. ErrAlreadyRunning (another mutator holds
|
||||
// the lock) is surfaced as a "cannot start" error.
|
||||
func acquireLockIfMutating(mode lockMode, lockDir string) (func(), error) {
|
||||
if mode != mutating {
|
||||
return func() {}, nil
|
||||
}
|
||||
|
||||
lock, err := pidlock.Acquire(lockDir)
|
||||
if err != nil {
|
||||
if errors.Is(err, pidlock.ErrAlreadyRunning) {
|
||||
return nil, fmt.Errorf("cannot start: %w", err)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("failed to acquire lock: %w", err)
|
||||
}
|
||||
|
||||
return func() {
|
||||
err := lock.Release()
|
||||
if err != nil {
|
||||
log.Warn("Failed to release PID lock", "error", err)
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -2,10 +2,7 @@ package cli //nolint:testpackage // needs access to unexported cleanStartupError
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/vaultik/internal/pidlock"
|
||||
)
|
||||
|
||||
func TestCleanStartupError(t *testing.T) {
|
||||
@@ -56,42 +53,3 @@ func TestCleanStartupError(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLockScopedToMutatingCommands proves the partition the PID lock now
|
||||
// enforces: a read-only command runs while a mutator holds the lock, and
|
||||
// two mutating commands still mutually exclude.
|
||||
func TestLockScopedToMutatingCommands(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lockDir := filepath.Join(t.TempDir(), "vaultik")
|
||||
|
||||
// A mutating command takes the process-wide lock.
|
||||
releaseMutator, err := acquireLockIfMutating(mutating, lockDir)
|
||||
if err != nil {
|
||||
t.Fatalf("mutating command could not acquire lock: %v", err)
|
||||
}
|
||||
|
||||
// A read-only command runs to completion even while the lock is held.
|
||||
releaseReader, err := acquireLockIfMutating(readOnly, lockDir)
|
||||
if err != nil {
|
||||
t.Fatalf("read-only command was blocked by held lock: %v", err)
|
||||
}
|
||||
|
||||
releaseReader()
|
||||
|
||||
// A second mutating command is refused while the first holds the lock.
|
||||
_, err = acquireLockIfMutating(mutating, lockDir)
|
||||
if !errors.Is(err, pidlock.ErrAlreadyRunning) {
|
||||
t.Fatalf("second mutating command was not excluded, got: %v", err)
|
||||
}
|
||||
|
||||
// Once the first mutator releases, another mutating command may run.
|
||||
releaseMutator()
|
||||
|
||||
release, err := acquireLockIfMutating(mutating, lockDir)
|
||||
if err != nil {
|
||||
t.Fatalf("mutating command could not acquire released lock: %v", err)
|
||||
}
|
||||
|
||||
release()
|
||||
}
|
||||
|
||||
+40
-67
@@ -12,7 +12,6 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"gopkg.in/yaml.v3"
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
)
|
||||
|
||||
// configFileMode is the permission set for freshly written config files;
|
||||
@@ -46,11 +45,8 @@ const defaultConfigTemplate = `# vaultik configuration
|
||||
# ─── REQUIRED ────────────────────────────────────────────────────────────────
|
||||
|
||||
# Age recipient public keys for encryption.
|
||||
# Backups are encrypted to ALL listed recipients; any one of the corresponding
|
||||
# private keys can decrypt. Adding a recipient later does not re-encrypt data
|
||||
# already stored: deduplicated chunks and existing blobs stay encrypted to the
|
||||
# earlier recipients, so a newly added key cannot restore them on its own (see
|
||||
# docs/REPOSTRUCTURE.md, Accepted Risks). Generate a keypair with:
|
||||
# Backups are encrypted to ALL listed recipients. Any one of the corresponding
|
||||
# private keys can decrypt. Generate a keypair with:
|
||||
# age-keygen -o vaultik_backup_private_key.txt
|
||||
# grep 'public key' vaultik_backup_private_key.txt
|
||||
age_recipients:
|
||||
@@ -196,8 +192,8 @@ storage_url: ""
|
||||
# access_key_id: YOUR_ACCESS_KEY
|
||||
# secret_access_key: YOUR_SECRET_KEY
|
||||
# # region: us-east-1 # Default: us-east-1
|
||||
# # use_ssl: true # Default: true
|
||||
# # part_size: 5MB # Multipart upload part size. Default: 5MB
|
||||
# # For the s3:// form, disable TLS with ?ssl=false in the URL, not use_ssl.
|
||||
|
||||
# ─── OPTIONAL ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -216,8 +212,6 @@ storage_url: ""
|
||||
# chunk_size: 10MB
|
||||
|
||||
# Maximum blob size before splitting into a new blob.
|
||||
# Must be at least four times chunk_size (the largest chunk the chunker can
|
||||
# emit); a smaller limit would let a single-chunk blob exceed it.
|
||||
# Accepts: 1GB, 10G, 500MB, etc.
|
||||
# Default: 10GB
|
||||
# blob_size_limit: 10GB
|
||||
@@ -265,7 +259,7 @@ The config is written to the path from --config, $VAULTIK_CONFIG, or
|
||||
the platform default config directory (e.g. ~/Library/Application Support/
|
||||
on macOS, ~/.config/ on Linux, /etc/vaultik/ as root).`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
path := configPathForInit()
|
||||
|
||||
_, err := os.Stat(path)
|
||||
@@ -285,11 +279,8 @@ on macOS, ~/.config/ on Linux, /etc/vaultik/ as root).`,
|
||||
return fmt.Errorf("writing config file: %w", err)
|
||||
}
|
||||
|
||||
// A written-confirmation, not scriptable output: route it
|
||||
// through the UI so it is styled and --quiet silences it.
|
||||
out := commandUI(cmd)
|
||||
out.Infof("Config written to %s.", path)
|
||||
out.Infof(
|
||||
_, _ = fmt.Fprintf(os.Stdout, "Config written to %s\n", path)
|
||||
_, _ = fmt.Fprintln(os.Stdout,
|
||||
"Edit it to set your age_recipients, snapshots, and storage_url.")
|
||||
|
||||
return nil
|
||||
@@ -331,7 +322,7 @@ func newConfigGetCommand() *cobra.Command {
|
||||
Use: "get <key>",
|
||||
Short: "Print a config value by dotted path (e.g. storage_url, compression_level)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
path, err := ResolveConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -347,13 +338,8 @@ func newConfigGetCommand() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
// The value is scriptable output: it must stay machine-plain
|
||||
// (no marker, no color) and is never silenced by --quiet, so it
|
||||
// is written straight to stdout rather than through the UI.
|
||||
w := cmd.OutOrStdout()
|
||||
|
||||
if node.Kind == yaml.ScalarNode {
|
||||
_, _ = fmt.Fprintln(w, node.Value)
|
||||
_, _ = fmt.Fprintln(os.Stdout, node.Value)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -363,7 +349,7 @@ func newConfigGetCommand() *cobra.Command {
|
||||
return fmt.Errorf("marshaling value: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprint(w, string(out))
|
||||
_, _ = fmt.Fprint(os.Stdout, string(out))
|
||||
|
||||
return nil
|
||||
},
|
||||
@@ -385,59 +371,46 @@ Examples:
|
||||
vaultik config set compression_level 9
|
||||
vaultik config set s3.bucket mybucket # legacy S3 fields still supported`,
|
||||
Args: cobra.ExactArgs(configSetArgs),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
path, err := ResolveConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return writeConfigSet(commandUI(cmd), path, args[0], args[1])
|
||||
root, err := loadYAMLFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = yamlPathSet(root, strings.Split(args[0], "."), args[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := marshalConfigYAML(root)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling config: %w", err)
|
||||
}
|
||||
|
||||
mode := os.FileMode(configFileMode)
|
||||
|
||||
info, statErr := os.Stat(path)
|
||||
if statErr == nil {
|
||||
mode = info.Mode().Perm()
|
||||
}
|
||||
|
||||
err = os.WriteFile(path, out, mode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("writing config file: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(os.Stdout, "%s = %s\n", args[0], args[1])
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// writeConfigSet applies key=value to the config at path, writes it back
|
||||
// owner-only, and confirms the write by naming just the key through the
|
||||
// UI writer (styled, and silenced by --quiet). The value is never
|
||||
// echoed: it may be a secret such as s3.secret_access_key, and captured
|
||||
// stdout or a pasted terminal would then leak it.
|
||||
func writeConfigSet(out *ui.Writer, path, key, value string) error {
|
||||
root, err := loadYAMLFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = yamlPathSet(root, strings.Split(key, "."), value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := marshalConfigYAML(root)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling config: %w", err)
|
||||
}
|
||||
|
||||
err = os.WriteFile(path, data, configFileMode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("writing config file: %w", err)
|
||||
}
|
||||
|
||||
// os.WriteFile does not change the mode of a file that already exists,
|
||||
// so a config that was group- or world-readable stays that way. As it
|
||||
// may hold S3 credentials, tighten it to owner-only after writing.
|
||||
info, statErr := os.Stat(path)
|
||||
if statErr == nil && info.Mode().Perm()&0o044 != 0 {
|
||||
err = os.Chmod(path, configFileMode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tightening config file permissions: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
out.Infof("Set %s.", key)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// marshalConfigYAML renders a config document tree with 2-space indentation,
|
||||
// matching defaultConfigTemplate. yaml.Marshal defaults to 4 spaces, which
|
||||
// would reindent the whole file on the first `config set` despite the promise
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
package cli //nolint:testpackage // exercises unexported yamlPathGet/yamlPathSet
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
"sneak.berlin/go/vaultik/internal/config"
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
)
|
||||
|
||||
// TestDefaultConfigTemplateParses ensures the init template is valid YAML
|
||||
@@ -233,70 +229,6 @@ func TestConfigSetPreservesFormatting(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteConfigSetHidesSecret checks that setting a secret key prints
|
||||
// only the key name, never the value, to the confirmation output.
|
||||
func TestWriteConfigSetHidesSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const secret = "SUPERSECRETVALUE"
|
||||
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
|
||||
err := os.WriteFile(path, []byte("version: 1\n"), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
err = writeConfigSet(ui.NewWithColor(&buf, false), path,
|
||||
"s3.secret_access_key", secret)
|
||||
if err != nil {
|
||||
t.Fatalf("writeConfigSet: %v", err)
|
||||
}
|
||||
|
||||
if strings.Contains(buf.String(), secret) {
|
||||
t.Errorf("output echoed the secret value: %q", buf.String())
|
||||
}
|
||||
|
||||
if !strings.Contains(buf.String(), "s3.secret_access_key") {
|
||||
t.Errorf("output did not confirm the key name: %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteConfigSetTightensMode checks that a pre-existing group- or
|
||||
// world-readable config is tightened to owner-only after a set, since
|
||||
// os.WriteFile leaves an existing file's mode untouched.
|
||||
func TestWriteConfigSetTightensMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
|
||||
// Seed a world-readable config; the loose mode is the condition under
|
||||
// test, so gosec's G306 is expected here.
|
||||
err := os.WriteFile(path, []byte("version: 1\n"), 0o644) //nolint:gosec // G306
|
||||
if err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
err = writeConfigSet(ui.NewWithColor(&buf, false), path,
|
||||
"compression_level", "9")
|
||||
if err != nil {
|
||||
t.Fatalf("writeConfigSet: %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat config: %v", err)
|
||||
}
|
||||
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Errorf("config mode = %04o, want 0600", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
func splitPath(s string) []string {
|
||||
return strings.Split(s, ".")
|
||||
}
|
||||
|
||||
+11
-12
@@ -48,7 +48,7 @@ storage destination on that run.
|
||||
|
||||
Use --force to skip the confirmation prompt.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
// Resolve config path
|
||||
configPath, err := ResolveConfigPath()
|
||||
if err != nil {
|
||||
@@ -62,31 +62,26 @@ Use --force to skip the confirmation prompt.`,
|
||||
}
|
||||
|
||||
dbPath := cfg.IndexPath
|
||||
out := commandUI(cmd)
|
||||
|
||||
// Check if database exists
|
||||
_, err = os.Stat(dbPath)
|
||||
if os.IsNotExist(err) {
|
||||
out.Infof("Local state database does not exist: %s.", dbPath)
|
||||
_, _ = fmt.Fprintf(os.Stdout, "Database does not exist: %s\n", dbPath)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Confirm unless --force. The prompt and its immediate result
|
||||
// are an interactive exchange the operator must see, so they go
|
||||
// straight to stdout rather than through the UI and --quiet does
|
||||
// not silence them.
|
||||
// Confirm unless --force
|
||||
if !force {
|
||||
w := cmd.OutOrStdout()
|
||||
_, _ = fmt.Fprintf(w,
|
||||
_, _ = fmt.Fprintf(os.Stdout,
|
||||
"This will delete the local state database at:\n %s\n\n", dbPath)
|
||||
_, _ = fmt.Fprint(w, "Are you sure? Type 'yes' to confirm: ")
|
||||
_, _ = fmt.Fprint(os.Stdout, "Are you sure? Type 'yes' to confirm: ")
|
||||
|
||||
var confirm string
|
||||
|
||||
_, err = fmt.Scanln(&confirm)
|
||||
if err != nil || confirm != "yes" {
|
||||
_, _ = fmt.Fprintln(w, "Aborted.")
|
||||
_, _ = fmt.Fprintln(os.Stdout, "Aborted.")
|
||||
|
||||
//nolint:nilerr // a failed/aborted confirmation is a clean abort
|
||||
return nil
|
||||
@@ -105,7 +100,11 @@ Use --force to skip the confirmation prompt.`,
|
||||
_ = os.Remove(walPath) // Ignore errors - files may not exist
|
||||
_ = os.Remove(shmPath)
|
||||
|
||||
out.Infof("Local state database deleted: %s.", dbPath)
|
||||
rootFlags := GetRootFlags()
|
||||
if !rootFlags.Quiet {
|
||||
_, _ = fmt.Fprintf(os.Stdout, "Database deleted: %s\n", dbPath)
|
||||
}
|
||||
|
||||
log.Info("Local state database deleted", "path", dbPath)
|
||||
|
||||
return nil
|
||||
|
||||
@@ -35,7 +35,6 @@ func NewInfoCommand() *cobra.Command {
|
||||
Debug: rootFlags.Debug,
|
||||
Quiet: rootFlags.Quiet,
|
||||
},
|
||||
Mode: readOnly,
|
||||
}, func(v *vaultik.Vaultik) error {
|
||||
return v.ShowInfo()
|
||||
}, func(err error) {
|
||||
|
||||
@@ -44,7 +44,6 @@ work (e.g. after a crashed backup or to reclaim storage).`,
|
||||
Quiet: rootFlags.Quiet,
|
||||
JSON: opts.JSON,
|
||||
},
|
||||
Mode: mutating,
|
||||
}, func(v *vaultik.Vaultik) error {
|
||||
return v.Prune(opts)
|
||||
}, func(err error) {
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
package cli //nolint:testpackage // sets the unexported rootFlags directly
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// setRootFlags overrides the global rootFlags for the duration of one
|
||||
// test and restores it afterward. These tests must not run in parallel:
|
||||
// the flags are process-global, so the whole struct is saved and put
|
||||
// back rather than left mutated for the next test.
|
||||
func setRootFlags(t *testing.T, f RootFlags) {
|
||||
t.Helper()
|
||||
|
||||
old := rootFlags
|
||||
rootFlags = f
|
||||
|
||||
t.Cleanup(func() { rootFlags = old })
|
||||
}
|
||||
|
||||
// seedFile writes content to a fresh file and returns its path.
|
||||
func seedFile(t *testing.T, dir, name, content string) string {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(dir, name)
|
||||
|
||||
err := os.WriteFile(path, []byte(content), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("seeding %s: %v", name, err)
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
// mustExecute runs a command with its output captured and fails the test
|
||||
// if it errors, returning what the command printed.
|
||||
func mustExecute(t *testing.T, cmd *cobra.Command, args ...string) string {
|
||||
t.Helper()
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetArgs(args)
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("%s failed: %v", cmd.Name(), err)
|
||||
}
|
||||
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// TestVersionQuietSuppressesReport checks that --quiet silences the whole
|
||||
// version report: it is human-facing output, not a scriptable value.
|
||||
//
|
||||
//nolint:paralleltest // mutates the process-global rootFlags
|
||||
func TestVersionQuietSuppressesReport(t *testing.T) {
|
||||
setRootFlags(t, RootFlags{Quiet: true})
|
||||
|
||||
out := mustExecute(t, NewVersionCommand())
|
||||
|
||||
if out != "" {
|
||||
t.Errorf("--quiet version printed %q, want nothing", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigGetIgnoresQuiet checks that a config value is printed even
|
||||
// under --quiet: it is scriptable output a caller depends on, so --quiet
|
||||
// must not suppress it, and it stays machine-plain (no marker, no color).
|
||||
//
|
||||
//nolint:paralleltest // mutates the process-global rootFlags
|
||||
func TestConfigGetIgnoresQuiet(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := seedFile(t, dir, "config.yml", "storage_url: file:///mnt/x\n")
|
||||
|
||||
setRootFlags(t, RootFlags{Quiet: true, ConfigPath: path})
|
||||
|
||||
out := mustExecute(t, newConfigGetCommand(), "storage_url")
|
||||
|
||||
if out != "file:///mnt/x\n" {
|
||||
t.Errorf("config get --quiet = %q, want the plain value", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigSetQuietSuppressesConfirmation checks that --quiet silences
|
||||
// the confirmation line while still writing the value to the file.
|
||||
//
|
||||
//nolint:paralleltest // mutates the process-global rootFlags
|
||||
func TestConfigSetQuietSuppressesConfirmation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := seedFile(t, dir, "config.yml", "compression_level: 3\n")
|
||||
|
||||
setRootFlags(t, RootFlags{Quiet: true, ConfigPath: path})
|
||||
|
||||
out := mustExecute(t, newConfigSetCommand(), "compression_level", "9")
|
||||
|
||||
if out != "" {
|
||||
t.Errorf("--quiet config set printed %q, want nothing", out)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path) //nolint:gosec // G304: test-controlled path
|
||||
if err != nil {
|
||||
t.Fatalf("reading config back: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(data), "compression_level: 9") {
|
||||
t.Errorf("config set did not write the value under --quiet:\n%s", data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigSetConfirmsWhenNotQuiet checks that the confirmation names
|
||||
// the key (styled) when --quiet is not set.
|
||||
//
|
||||
//nolint:paralleltest // mutates the process-global rootFlags
|
||||
func TestConfigSetConfirmsWhenNotQuiet(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := seedFile(t, dir, "config.yml", "compression_level: 3\n")
|
||||
|
||||
setRootFlags(t, RootFlags{ConfigPath: path})
|
||||
|
||||
out := mustExecute(t, newConfigSetCommand(), "compression_level", "9")
|
||||
|
||||
if !strings.Contains(out, "compression_level") {
|
||||
t.Errorf("config set did not confirm the key: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigInitQuietSuppressesConfirmation checks that --quiet silences
|
||||
// the "config written" confirmation while still writing the file.
|
||||
//
|
||||
//nolint:paralleltest // mutates the process-global rootFlags
|
||||
func TestConfigInitQuietSuppressesConfirmation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "new-config.yml")
|
||||
|
||||
setRootFlags(t, RootFlags{Quiet: true, ConfigPath: path})
|
||||
|
||||
out := mustExecute(t, newConfigInitCommand())
|
||||
|
||||
if out != "" {
|
||||
t.Errorf("--quiet config init printed %q, want nothing", out)
|
||||
}
|
||||
|
||||
_, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Errorf("config init did not write the file under --quiet: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// seedDatabaseDeleteConfig writes a valid config whose index_path is a
|
||||
// seeded database file, and returns both paths.
|
||||
func seedDatabaseDeleteConfig(t *testing.T, dir string) (string, string) {
|
||||
t.Helper()
|
||||
|
||||
dbPath := seedFile(t, dir, "index.sqlite", "not-a-real-db")
|
||||
cfg := fmt.Sprintf(hermeticConfig,
|
||||
filepath.Join(dir, "source"), filepath.Join(dir, "store"), dbPath)
|
||||
cfgPath := seedFile(t, dir, "config.yml", cfg)
|
||||
|
||||
return dbPath, cfgPath
|
||||
}
|
||||
|
||||
// TestDatabaseDeleteQuietSuppressesMessage checks that --quiet silences
|
||||
// the "database deleted" line while still removing the file.
|
||||
//
|
||||
//nolint:paralleltest // mutates the process-global rootFlags
|
||||
func TestDatabaseDeleteQuietSuppressesMessage(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath, cfgPath := seedDatabaseDeleteConfig(t, dir)
|
||||
|
||||
setRootFlags(t, RootFlags{Quiet: true, ConfigPath: cfgPath})
|
||||
|
||||
out := mustExecute(t, newDatabaseDeleteCommand(), "--force")
|
||||
|
||||
if out != "" {
|
||||
t.Errorf("--quiet database delete printed %q, want nothing", out)
|
||||
}
|
||||
|
||||
_, err := os.Stat(dbPath)
|
||||
if !os.IsNotExist(err) {
|
||||
t.Errorf("database delete did not remove the file: stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDatabaseDeleteReportsWhenNotQuiet checks that the deletion is
|
||||
// reported when --quiet is not set.
|
||||
//
|
||||
//nolint:paralleltest // mutates the process-global rootFlags
|
||||
func TestDatabaseDeleteReportsWhenNotQuiet(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, cfgPath := seedDatabaseDeleteConfig(t, dir)
|
||||
|
||||
setRootFlags(t, RootFlags{ConfigPath: cfgPath})
|
||||
|
||||
out := mustExecute(t, newDatabaseDeleteCommand(), "--force")
|
||||
|
||||
if !strings.Contains(out, "deleted") {
|
||||
t.Errorf("database delete did not report the deletion: %q", out)
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ This is destructive and irreversible. Requires --force.`,
|
||||
return errNukeNeedsForce
|
||||
}
|
||||
|
||||
return runVaultikApp(cmd, mutating, false, false, "Remote nuke failed",
|
||||
return runVaultikApp(cmd, false, false, "Remote nuke failed",
|
||||
func(v *vaultik.Vaultik) error {
|
||||
return v.NukeRemote(true)
|
||||
})
|
||||
@@ -88,7 +88,6 @@ func newRemoteInfoCommand() *cobra.Command {
|
||||
Quiet: rootFlags.Quiet,
|
||||
JSON: jsonOutput,
|
||||
},
|
||||
Mode: readOnly,
|
||||
}, func(v *vaultik.Vaultik) error {
|
||||
return v.RemoteInfo(jsonOutput)
|
||||
}, func(err error) {
|
||||
|
||||
+2
-18
@@ -9,7 +9,6 @@ import (
|
||||
|
||||
"github.com/adrg/xdg"
|
||||
"github.com/spf13/cobra"
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
)
|
||||
|
||||
// errConfigNotFound is wrapped by all config-resolution failures.
|
||||
@@ -58,9 +57,8 @@ on the source system.`,
|
||||
cmd.PersistentFlags().BoolVarP(&rootFlags.Quiet, "quiet", "q", false,
|
||||
"Suppress non-error output")
|
||||
cmd.PersistentFlags().BoolVar(&rootFlags.SkipErrors, "skip-errors", false,
|
||||
"Skip files that cannot be read when creating a snapshot, or "+
|
||||
"that cannot be restored when restoring, instead of aborting "+
|
||||
"(packing and storage errors still abort)")
|
||||
"Continue past per-file errors instead of aborting "+
|
||||
"(applies to snapshot create and restore)")
|
||||
|
||||
// Add subcommands
|
||||
cmd.AddCommand(
|
||||
@@ -82,20 +80,6 @@ func GetRootFlags() RootFlags {
|
||||
return rootFlags
|
||||
}
|
||||
|
||||
// commandUI returns a UI writer for a command's stdout, in quiet mode
|
||||
// when the global --quiet flag is set. This is how the pure-cli
|
||||
// commands (version, config, database) reach internal/ui: color follows
|
||||
// the writer (a TTY gets color, a captured test buffer does not), and
|
||||
// --quiet silences the same message classes it silences everywhere else.
|
||||
func commandUI(cmd *cobra.Command) *ui.Writer {
|
||||
w := ui.New(cmd.OutOrStdout())
|
||||
if GetRootFlags().Quiet {
|
||||
w.SetQuiet(true)
|
||||
}
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
// ResolveConfigPath resolves the config file path from flags, environment, or default.
|
||||
// Search order: --config flag, VAULTIK_CONFIG env, XDG config dir,
|
||||
// /etc/vaultik/config.yml.
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/vaultik/internal/cli"
|
||||
)
|
||||
|
||||
// TestRunAppWaitsForOperationCleanupOnShutdown drives RunApp with an fx app
|
||||
// wired the way RunOperation wires a command: a single lifecycle hook whose
|
||||
// OnStart launches the operation in its own goroutine and whose OnStop cancels
|
||||
// it and blocks until that goroutine returns. The operation stands in for a
|
||||
// restore blocked mid-download — it holds a decrypted "scratch" file and only
|
||||
// removes it as it unwinds on cancellation.
|
||||
//
|
||||
// The app is asked to stop once the operation is running (standing in for an
|
||||
// OS interrupt; fx delivers a real signal and Shutdowner.Shutdown() on the
|
||||
// same app.Wait channel, so both drive the identical shutdown path). RunApp
|
||||
// must not return until app.Stop has run the OnStop hook, so the scratch file
|
||||
// must be gone by the time RunApp returns. Before the fix RunApp returned as
|
||||
// soon as the app.Wait/Done channel fired, without running app.Stop, so the
|
||||
// cleanup never ran and this file would still be on disk (issue #159).
|
||||
func TestRunAppWaitsForOperationCleanupOnShutdown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scratch := filepath.Join(t.TempDir(), "decrypted-scratch")
|
||||
require.NoError(t, os.WriteFile(scratch, []byte("secret"), 0o600))
|
||||
|
||||
// Cancel and reap the operation even if RunApp returns without doing so
|
||||
// (the buggy path), so the goroutine cannot leak past the test.
|
||||
opCtx, opCancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(opCancel)
|
||||
|
||||
var stop func(context.Context) bool
|
||||
|
||||
app := fx.New(
|
||||
fx.NopLogger,
|
||||
fx.Invoke(func(lc fx.Lifecycle, sh fx.Shutdowner) {
|
||||
lc.Append(fx.Hook{
|
||||
OnStart: func(_ context.Context) error {
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
|
||||
// Blocked mid-operation until cancelled, then run the
|
||||
// cleanup an interrupted restore would run.
|
||||
<-opCtx.Done()
|
||||
|
||||
_ = os.Remove(scratch)
|
||||
}()
|
||||
|
||||
stop = func(ctx context.Context) bool {
|
||||
opCancel()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Ask the app to stop now that the operation is running.
|
||||
go func() { _ = sh.Shutdown() }()
|
||||
|
||||
return nil
|
||||
},
|
||||
OnStop: func(ctx context.Context) error {
|
||||
stop(ctx)
|
||||
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cli.RunApp(context.Background(), app) }()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
require.NoError(t, err)
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("RunApp did not return after shutdown was requested")
|
||||
}
|
||||
|
||||
_, err := os.Stat(scratch)
|
||||
require.True(t, os.IsNotExist(err),
|
||||
"RunApp returned before the operation removed its decrypted scratch file")
|
||||
}
|
||||
@@ -92,7 +92,6 @@ specifying a path using --config or by setting VAULTIK_CONFIG to a path.`,
|
||||
Cron: opts.Cron,
|
||||
Quiet: rootFlags.Quiet,
|
||||
},
|
||||
Mode: mutating,
|
||||
}, func(v *vaultik.Vaultik) error {
|
||||
return v.CreateSnapshot(opts)
|
||||
}, func(err error) {
|
||||
@@ -126,7 +125,7 @@ func newSnapshotListCommand() *cobra.Command {
|
||||
Long: "Lists all snapshots with their ID, timestamp, and compressed size",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return runVaultikApp(cmd, readOnly, false, false,
|
||||
return runVaultikApp(cmd, false, false,
|
||||
"Failed to list snapshots",
|
||||
func(v *vaultik.Vaultik) error {
|
||||
return v.ListSnapshots(jsonOutput)
|
||||
@@ -162,7 +161,7 @@ restrict the operation to specific snapshot names.`,
|
||||
return errPurgeCriteriaBoth
|
||||
}
|
||||
|
||||
return runVaultikApp(cmd, mutating, false, false,
|
||||
return runVaultikApp(cmd, false, false,
|
||||
"Failed to purge snapshots",
|
||||
func(v *vaultik.Vaultik) error {
|
||||
return v.PurgeSnapshotsWithOptions(opts)
|
||||
@@ -188,12 +187,8 @@ func newSnapshotVerifyCommand() *cobra.Command {
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "verify <snapshot-id>",
|
||||
Short: "Check a snapshot's blobs are present with the listed size",
|
||||
Long: "Checks that every blob the snapshot's manifest lists is present\n" +
|
||||
"in storage with the size the manifest records, and that the\n" +
|
||||
"snapshot's encrypted database is present. It does not read blob\n" +
|
||||
"contents; use --deep to download, decrypt, and re-hash every blob\n" +
|
||||
"to detect corruption -- integrity, not who wrote it.\n\n" +
|
||||
Short: "Verify snapshot integrity",
|
||||
Long: "Verifies that all blobs referenced in a snapshot exist.\n\n" +
|
||||
"The snapshot may be named by its ID or, on a host with no local\n" +
|
||||
"index, by the remote key that 'snapshot list' prints for a\n" +
|
||||
"remote-only snapshot (an unambiguous leading part is enough).",
|
||||
@@ -217,7 +212,6 @@ func newSnapshotVerifyCommand() *cobra.Command {
|
||||
Quiet: rootFlags.Quiet,
|
||||
JSON: opts.JSON,
|
||||
},
|
||||
Mode: readOnly,
|
||||
}, func(v *vaultik.Vaultik) error {
|
||||
return v.VerifySnapshotWithOptions(snapshotID, opts)
|
||||
}, func(err error) {
|
||||
@@ -265,7 +259,7 @@ To wipe the entire destination store and start over, use 'vaultik remote
|
||||
nuke --force' — it is the single supported entry point for that.`,
|
||||
Args: requireSnapshotIDArg,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runVaultikApp(cmd, mutating, opts.JSON, opts.JSON,
|
||||
return runVaultikApp(cmd, opts.JSON, opts.JSON,
|
||||
"Failed to remove snapshot",
|
||||
func(v *vaultik.Vaultik) error {
|
||||
_, err := v.RemoveSnapshot(args[0], opts)
|
||||
|
||||
@@ -35,12 +35,8 @@ 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
|
||||
remote-only snapshot (an unambiguous leading part is enough).
|
||||
|
||||
Requires the age private key in the VAULTIK_AGE_SECRET_KEY environment
|
||||
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
|
||||
typing the key, so it does not land in your shell history:
|
||||
|
||||
export VAULTIK_AGE_SECRET_KEY="$(cat vaultik_backup_private_key.txt)"
|
||||
Requires the VAULTIK_AGE_SECRET_KEY environment variable to be set with
|
||||
the age private key.
|
||||
|
||||
Examples:
|
||||
# Restore entire snapshot
|
||||
@@ -92,7 +88,6 @@ func runRestore(cmd *cobra.Command, args []string, opts *RestoreOptions) error {
|
||||
Debug: rootFlags.Debug,
|
||||
Quiet: rootFlags.Quiet,
|
||||
},
|
||||
Mode: readOnly,
|
||||
}, func(v *vaultik.Vaultik) error {
|
||||
return v.Restore(&vaultik.RestoreOptions{
|
||||
SnapshotID: snapshotID,
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
package cli //nolint:testpackage // exercises the unexported command constructor
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// TestRestoreCommandDoesNotTakeKeyAsArgument guards the fix for the age
|
||||
// key being echoed on the command line: restore must take the key only
|
||||
// from the environment, never as a flag value, and its help must show the
|
||||
// file-based form rather than a literal key that would land in shell
|
||||
// history.
|
||||
func TestRestoreCommandDoesNotTakeKeyAsArgument(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := newSnapshotRestoreCommand()
|
||||
|
||||
cmd.Flags().VisitAll(func(f *pflag.Flag) {
|
||||
lower := strings.ToLower(f.Name)
|
||||
for _, banned := range []string{"key", "secret", "age", "identity"} {
|
||||
if strings.Contains(lower, banned) {
|
||||
t.Errorf("restore must not accept the key as a flag; found --%s", f.Name)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
help := cmd.Long
|
||||
if strings.Contains(help, "AGE-SECRET-KEY-") {
|
||||
t.Error("restore help must not show a literal age private key to type")
|
||||
}
|
||||
|
||||
if !strings.Contains(help, "$(cat ") {
|
||||
t.Error("restore help should read the key from a file, e.g. $(cat ...)")
|
||||
}
|
||||
}
|
||||
+5
-14
@@ -2,11 +2,11 @@ package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"runtime"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"sneak.berlin/go/vaultik/internal/globals"
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
)
|
||||
|
||||
// NewVersionCommand creates the version command
|
||||
@@ -17,25 +17,16 @@ func NewVersionCommand() *cobra.Command {
|
||||
Long: `Print version, git commit, and build information for vaultik.`,
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(cmd *cobra.Command, _ []string) {
|
||||
writeVersion(commandUI(cmd))
|
||||
writeVersion(cmd.OutOrStdout())
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// writeVersion prints the version report through the UI writer. The
|
||||
// report is the output this command exists to produce, so it is written
|
||||
// plain (markers would corrupt the aligned report) via the writer's
|
||||
// underlying stdout; --quiet silences it like any other non-error
|
||||
// output.
|
||||
func writeVersion(out *ui.Writer) {
|
||||
if out.Quiet() {
|
||||
return
|
||||
}
|
||||
|
||||
w := out.Out()
|
||||
|
||||
// writeVersion prints the version report. It takes a writer rather than
|
||||
// using os.Stdout directly so the output can be asserted on in tests.
|
||||
func writeVersion(w io.Writer) {
|
||||
_, _ = fmt.Fprintf(w, "vaultik %s\n", globals.Version)
|
||||
_, _ = fmt.Fprintf(w, " commit: %s\n", globals.Commit)
|
||||
_, _ = fmt.Fprintf(w, " build date: %s\n", globals.CommitDate)
|
||||
|
||||
@@ -34,9 +34,9 @@ func runVersionCommand(t *testing.T) string {
|
||||
// the report is the version the binary was actually built with. The
|
||||
// test binary carries no -ldflags, so that is the "dev" default -- the
|
||||
// same string an untagged `make vaultik` build stamps a prefix of.
|
||||
//
|
||||
//nolint:paralleltest // executes a command that reads the global rootFlags
|
||||
func TestVersionCommandReportsBuildVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
out := runVersionCommand(t)
|
||||
|
||||
wantFirst := "vaultik " + globals.Version
|
||||
@@ -55,9 +55,9 @@ func TestVersionCommandReportsBuildVersion(t *testing.T) {
|
||||
// being exactly "dev", so once untagged builds started carrying their
|
||||
// commit sha it would have gone silent and an unreleased binary would
|
||||
// have looked like a release.
|
||||
//
|
||||
//nolint:paralleltest // executes a command that reads the global rootFlags
|
||||
func TestVersionCommandFlagsDevelopmentBuild(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if !globals.IsDevVersion(globals.Version) {
|
||||
t.Skipf("test binary was stamped with release version %q",
|
||||
globals.Version)
|
||||
|
||||
+35
-106
@@ -16,19 +16,11 @@ import (
|
||||
"github.com/adrg/xdg"
|
||||
"go.uber.org/fx"
|
||||
"gopkg.in/yaml.v3"
|
||||
"sneak.berlin/go/vaultik/internal/chunker"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
)
|
||||
|
||||
const appName = "vaultik"
|
||||
|
||||
// secretKeyPrefix marks an age secret (private) key. It is compared
|
||||
// case-insensitively so a recipient entry that is actually a private key is
|
||||
// caught and never passed to age or echoed back.
|
||||
//
|
||||
//nolint:gosec // G101: marker for detecting a pasted secret key, not a credential
|
||||
const secretKeyPrefix = "AGE-SECRET-KEY-"
|
||||
|
||||
// Defaults and validation bounds for tunable settings.
|
||||
const (
|
||||
defaultBlobSizeLimit = Size(10 * 1024 * 1024 * 1024) // 10GB
|
||||
@@ -45,19 +37,13 @@ var (
|
||||
errNoConfigPath = errors.New("config path not provided")
|
||||
errNoAgeRecipients = errors.New(
|
||||
"at least one age_recipient is required (generate with: age-keygen)")
|
||||
errRecipientIsSecretKey = errors.New(
|
||||
"an age secret key was given where a public key (age1...) belongs")
|
||||
errRecipientNotX25519 = errors.New(
|
||||
"not a valid recipient; only X25519 age1... public keys are supported")
|
||||
errNoSnapshots = errors.New(
|
||||
"at least one snapshot must be configured (see config.example.yml)")
|
||||
errSnapshotNoPaths = errors.New("snapshot must have at least one path")
|
||||
errChunkSizeTooSmall = errors.New("chunk_size must be at least 1MB")
|
||||
errBlobSizeTooSmall = errors.New(
|
||||
"blob_size_limit must be at least the largest chunk the chunker can " +
|
||||
"emit (chunk_size times the FastCDC size spread)")
|
||||
errBadCompression = errors.New("compression_level must be between 1 and 19")
|
||||
errBadStorageScheme = errors.New(
|
||||
errBlobSizeTooSmall = errors.New("blob_size_limit must be at least chunk_size")
|
||||
errBadCompression = errors.New("compression_level must be between 1 and 19")
|
||||
errBadStorageScheme = errors.New(
|
||||
"storage_url must start with s3://, file://, or rclone://")
|
||||
errStorageNotConfigured = errors.New(
|
||||
"storage not configured; set storage_url or provide s3.endpoint + " +
|
||||
@@ -135,26 +121,6 @@ func (c *Config) SnapshotNames() []string {
|
||||
return names
|
||||
}
|
||||
|
||||
// Names of the two places the age secret key can be configured, used by
|
||||
// AgeSecretKeySourceName for error messages that must not echo the value.
|
||||
//
|
||||
//nolint:gosec // G101: these are the names of the config sources, not a key
|
||||
const (
|
||||
ageSecretKeySourceEnv = "VAULTIK_AGE_SECRET_KEY"
|
||||
ageSecretKeySourceConfig = "age_secret_key"
|
||||
)
|
||||
|
||||
// AgeSecretKeySourceName returns the human name of where AgeSecretKey was
|
||||
// configured. A Config built directly (as in tests) has no recorded
|
||||
// source, so it reports the config-file field name.
|
||||
func (c *Config) AgeSecretKeySourceName() string {
|
||||
if c.AgeSecretKeySource != "" {
|
||||
return c.AgeSecretKeySource
|
||||
}
|
||||
|
||||
return ageSecretKeySourceConfig
|
||||
}
|
||||
|
||||
// Config represents the application configuration for Vaultik.
|
||||
// It defines all settings for backup operations, including source directories,
|
||||
// encryption recipients, storage configuration, and performance tuning parameters.
|
||||
@@ -164,13 +130,8 @@ func (c *Config) AgeSecretKeySourceName() string {
|
||||
type Config struct {
|
||||
AgeRecipients []string `yaml:"age_recipients"`
|
||||
AgeSecretKey string `yaml:"age_secret_key"`
|
||||
// AgeSecretKeySource names where AgeSecretKey was configured
|
||||
// ("VAULTIK_AGE_SECRET_KEY" or "age_secret_key") so a later parse
|
||||
// failure can name the source without echoing the secret value. It is
|
||||
// set by Load and never read from or written to the config file.
|
||||
AgeSecretKeySource string `yaml:"-"`
|
||||
BlobSizeLimit Size `yaml:"blob_size_limit"`
|
||||
ChunkSize Size `yaml:"chunk_size"`
|
||||
BlobSizeLimit Size `yaml:"blob_size_limit"`
|
||||
ChunkSize Size `yaml:"chunk_size"`
|
||||
// Exclude holds global excludes applied to all snapshots.
|
||||
Exclude []string `yaml:"exclude"`
|
||||
Hostname string `yaml:"hostname"`
|
||||
@@ -201,10 +162,8 @@ type S3Config struct {
|
||||
AccessKeyID string `yaml:"access_key_id"`
|
||||
SecretAccessKey string `yaml:"secret_access_key"`
|
||||
Region string `yaml:"region"`
|
||||
// UseSSL selects HTTPS for a scheme-less endpoint. Omitted (nil) means
|
||||
// the default, TLS; set it to false only to force plain HTTP.
|
||||
UseSSL *bool `yaml:"use_ssl"`
|
||||
PartSize Size `yaml:"part_size"`
|
||||
UseSSL bool `yaml:"use_ssl"`
|
||||
PartSize Size `yaml:"part_size"`
|
||||
}
|
||||
|
||||
// Path wraps the config file path for fx dependency injection.
|
||||
@@ -279,7 +238,10 @@ func Load(path string) (*Config, error) {
|
||||
cfg.IndexPath = expandTilde(envIndexPath)
|
||||
}
|
||||
|
||||
cfg.setAgeSecretKey()
|
||||
// Check for environment variable override for AgeSecretKey
|
||||
if envAgeSecretKey := os.Getenv("VAULTIK_AGE_SECRET_KEY"); envAgeSecretKey != "" {
|
||||
cfg.AgeSecretKey = extractAgeSecretKey(envAgeSecretKey)
|
||||
}
|
||||
|
||||
// Get hostname if not set
|
||||
if cfg.Hostname == "" {
|
||||
@@ -323,30 +285,18 @@ func Load(path string) (*Config, error) {
|
||||
|
||||
// Validate checks if the configuration is valid and complete.
|
||||
// It ensures all required fields are present and have valid values:
|
||||
// - At least one age recipient must be specified, and every recipient must
|
||||
// parse as an X25519 age1... public key (so a bad entry fails at load, not
|
||||
// mid-backup); errors name the position, never the value
|
||||
// - At least one snapshot must be configured with at least one path
|
||||
// - Storage must be configured (either storage_url or s3.* fields)
|
||||
// - Chunk size must be at least 1MB
|
||||
// - Blob size limit must be at least the largest chunk the chunker can emit
|
||||
// (chunk_size times chunker.ChunkSizeSpread), so a single-chunk blob never
|
||||
// exceeds the configured limit
|
||||
// - Compression level must be between 1 and 19
|
||||
//
|
||||
// - At least one age recipient must be specified
|
||||
// - At least one snapshot must be configured with at least one path
|
||||
// - Storage must be configured (either storage_url or s3.* fields)
|
||||
// - Chunk size must be at least 1MB
|
||||
// - Blob size limit must be at least the chunk size
|
||||
// - Compression level must be between 1 and 19
|
||||
// Returns an error describing the first validation failure encountered.
|
||||
func (c *Config) Validate() error {
|
||||
if len(c.AgeRecipients) == 0 {
|
||||
return errNoAgeRecipients
|
||||
}
|
||||
|
||||
for i, recipient := range c.AgeRecipients {
|
||||
err := validateAgeRecipient(recipient)
|
||||
if err != nil {
|
||||
return fmt.Errorf("age_recipients[%d]: %w", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(c.Snapshots) == 0 {
|
||||
return errNoSnapshots
|
||||
}
|
||||
@@ -367,13 +317,8 @@ func (c *Config) Validate() error {
|
||||
return errChunkSizeTooSmall
|
||||
}
|
||||
|
||||
// The chunker can emit chunks up to chunk_size * ChunkSizeSpread, and the
|
||||
// packer places a single such chunk into an otherwise empty blob. A limit
|
||||
// below that bound would let a blob exceed it, so reject it.
|
||||
largestChunk := c.ChunkSize.Int64() * chunker.ChunkSizeSpread
|
||||
if c.BlobSizeLimit.Int64() < largestChunk {
|
||||
return fmt.Errorf("%w: need at least %d bytes",
|
||||
errBlobSizeTooSmall, largestChunk)
|
||||
if c.BlobSizeLimit.Int64() < c.ChunkSize.Int64() {
|
||||
return errBlobSizeTooSmall
|
||||
}
|
||||
|
||||
if c.CompressionLevel < minCompressionLevel ||
|
||||
@@ -384,38 +329,6 @@ func (c *Config) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateAgeRecipient parses one age_recipients entry with the age library
|
||||
// and returns a value-free error on failure. A recipient string can be
|
||||
// sensitive (an operator may paste a secret key by mistake), so neither the
|
||||
// entry nor age's own error (which quotes its input) is ever included.
|
||||
func validateAgeRecipient(recipient string) error {
|
||||
if strings.HasPrefix(strings.ToUpper(recipient), secretKeyPrefix) {
|
||||
return errRecipientIsSecretKey
|
||||
}
|
||||
|
||||
_, err := age.ParseX25519Recipient(recipient)
|
||||
if err != nil {
|
||||
return errRecipientNotX25519
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setAgeSecretKey records the age secret key and where it came from. The
|
||||
// value is stored raw and parsed only where decryption happens
|
||||
// (internal/vaultik), so backup, list and prune keep working whatever the
|
||||
// field holds. The environment variable overrides the config-file field.
|
||||
func (c *Config) setAgeSecretKey() {
|
||||
if c.AgeSecretKey != "" {
|
||||
c.AgeSecretKeySource = ageSecretKeySourceConfig
|
||||
}
|
||||
|
||||
if env := os.Getenv("VAULTIK_AGE_SECRET_KEY"); env != "" {
|
||||
c.AgeSecretKey = env
|
||||
c.AgeSecretKeySource = ageSecretKeySourceEnv
|
||||
}
|
||||
}
|
||||
|
||||
// validateStorage validates storage configuration.
|
||||
// If StorageURL is set, it takes precedence. S3 URLs require credentials.
|
||||
// File URLs don't require any S3 configuration.
|
||||
@@ -472,6 +385,22 @@ func (c *Config) validateStorageURL() error {
|
||||
}
|
||||
}
|
||||
|
||||
// extractAgeSecretKey extracts the AGE-SECRET-KEY from the input using
|
||||
// the age library's parser, which handles comments and whitespace.
|
||||
func extractAgeSecretKey(input string) string {
|
||||
identities, err := age.ParseIdentities(strings.NewReader(input))
|
||||
if err != nil || len(identities) == 0 {
|
||||
// Fall back to trimmed input if parsing fails
|
||||
return strings.TrimSpace(input)
|
||||
}
|
||||
// Return the string representation of the first identity
|
||||
if id, ok := identities[0].(*age.X25519Identity); ok {
|
||||
return id.String()
|
||||
}
|
||||
|
||||
return strings.TrimSpace(input)
|
||||
}
|
||||
|
||||
// Module exports the config module for fx dependency injection.
|
||||
// It provides the Config type to other modules in the application.
|
||||
//
|
||||
|
||||
+39
-213
@@ -1,13 +1,9 @@
|
||||
package config //nolint:testpackage // exercises unexported source constants
|
||||
package config //nolint:testpackage // exercises unexported extractAgeSecretKey
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/vaultik/internal/chunker"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -87,48 +83,6 @@ func TestConfigLoad(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestExampleConfigIsScrubbedAndLoads checks that the shipped
|
||||
// config.example.yml carries only neutral placeholders (no real credentials,
|
||||
// private addresses, or internal host names) and still parses.
|
||||
func TestExampleConfigIsScrubbedAndLoads(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
examplePath := filepath.Join("..", "..", "config.example.yml")
|
||||
|
||||
cfg, err := Load(examplePath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load config.example.yml: %v", err)
|
||||
}
|
||||
|
||||
if cfg.StorageURL != "rclone://myremote/path/to/backups" {
|
||||
t.Errorf("Expected neutral storage_url, got '%s'", cfg.StorageURL)
|
||||
}
|
||||
|
||||
//nolint:gosec // G304: examplePath is a fixed in-repo path, not user input
|
||||
raw, err := os.ReadFile(examplePath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read config.example.yml: %v", err)
|
||||
}
|
||||
|
||||
text := string(raw)
|
||||
|
||||
wantSubstrings := []string{
|
||||
"YOUR_ACCESS_KEY",
|
||||
"YOUR_SECRET_KEY",
|
||||
"endpoint: https://",
|
||||
}
|
||||
for _, want := range wantSubstrings {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("Expected config.example.yml to contain %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
// A raw "http://" scheme would mean a plaintext, likely private endpoint.
|
||||
if strings.Contains(text, "http://") {
|
||||
t.Error("config.example.yml should not contain an http:// endpoint")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigFromEnv tests loading config path from environment variable
|
||||
func TestConfigFromEnv(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -147,57 +101,53 @@ func TestConfigFromEnv(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateBlobSizeLimit checks the blob_size_limit boundary: it must be at
|
||||
// least the largest chunk the chunker can emit (chunk_size times
|
||||
// chunker.ChunkSizeSpread), because the packer places a single such chunk into
|
||||
// an otherwise empty blob. A limit between chunk_size and that bound is rejected.
|
||||
func TestValidateBlobSizeLimit(t *testing.T) {
|
||||
// TestExtractAgeSecretKey tests extraction of AGE-SECRET-KEY from various inputs
|
||||
func TestExtractAgeSecretKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const chunkSize = Size(10 * 1024 * 1024) // 10MB
|
||||
|
||||
largestChunk := chunkSize.Int64() * chunker.ChunkSizeSpread
|
||||
|
||||
newConfig := func(blobLimit Size) *Config {
|
||||
return &Config{
|
||||
AgeRecipients: []string{testSneakAgePublicKey},
|
||||
Snapshots: map[string]SnapshotConfig{"test": {Paths: []string{"/tmp/src"}}},
|
||||
StorageURL: "file:///tmp/vaultik-test-store",
|
||||
ChunkSize: chunkSize,
|
||||
BlobSizeLimit: blobLimit,
|
||||
CompressionLevel: 3,
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
blobLimit Size
|
||||
wantErr bool
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "at chunk_size but below largest chunk is rejected",
|
||||
blobLimit: chunkSize,
|
||||
wantErr: true,
|
||||
name: "plain key",
|
||||
input: testIntegrationAgePrivateKey,
|
||||
expected: testIntegrationAgePrivateKey,
|
||||
},
|
||||
{
|
||||
name: "between chunk_size and largest chunk is rejected",
|
||||
blobLimit: Size(chunkSize.Int64() * 2),
|
||||
wantErr: true,
|
||||
name: "key with trailing newline",
|
||||
input: testIntegrationAgePrivateKey + "\n",
|
||||
expected: testIntegrationAgePrivateKey,
|
||||
},
|
||||
{
|
||||
name: "one byte below largest chunk is rejected",
|
||||
blobLimit: Size(largestChunk - 1),
|
||||
wantErr: true,
|
||||
name: "full age-keygen output",
|
||||
input: "# created: 2025-01-14T12:00:00Z\n" +
|
||||
"# public key: " + testIntegrationAgePublicKey + "\n" +
|
||||
testIntegrationAgePrivateKey + "\n",
|
||||
expected: testIntegrationAgePrivateKey,
|
||||
},
|
||||
{
|
||||
name: "exactly at largest chunk is accepted",
|
||||
blobLimit: Size(largestChunk),
|
||||
wantErr: false,
|
||||
name: "age-keygen output with extra blank lines",
|
||||
input: "# created: 2025-01-14T12:00:00Z\n" +
|
||||
"# public key: " + testIntegrationAgePublicKey + "\n\n" +
|
||||
testIntegrationAgePrivateKey + "\n\n",
|
||||
expected: testIntegrationAgePrivateKey,
|
||||
},
|
||||
{
|
||||
name: "above largest chunk is accepted",
|
||||
blobLimit: Size(largestChunk * 100),
|
||||
wantErr: false,
|
||||
name: "key with leading whitespace",
|
||||
input: " " + testIntegrationAgePrivateKey + " ",
|
||||
expected: testIntegrationAgePrivateKey,
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
input: "",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "only comments",
|
||||
input: "# this is a comment\n# another comment",
|
||||
expected: "# this is a comment\n# another comment",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -205,134 +155,10 @@ func TestValidateBlobSizeLimit(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := newConfig(tt.blobLimit).Validate()
|
||||
if tt.wantErr {
|
||||
if !errors.Is(err, errBlobSizeTooSmall) {
|
||||
t.Fatalf("Validate() error = %v, want errBlobSizeTooSmall", err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAgeRecipients checks that recipients are parsed at config load
|
||||
// (a bad entry fails immediately, not mid-backup) and that no invalid entry —
|
||||
// least of all a pasted secret key — is echoed in the error.
|
||||
func TestValidateAgeRecipients(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
baseConfig := func(recipients []string) *Config {
|
||||
return &Config{
|
||||
AgeRecipients: recipients,
|
||||
Snapshots: map[string]SnapshotConfig{"test": {Paths: []string{"/tmp/src"}}},
|
||||
StorageURL: "file:///tmp/vaultik-test-store",
|
||||
ChunkSize: Size(10 * 1024 * 1024),
|
||||
BlobSizeLimit: Size(10 * 1024 * 1024 * 1024),
|
||||
CompressionLevel: 3,
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
recipients []string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "config init placeholder is rejected",
|
||||
recipients: []string{"age1REPLACE_WITH_YOUR_PUBLIC_KEY"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "ssh-ed25519 recipient is rejected",
|
||||
recipients: []string{"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIexamplekeydata"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "truncated age1 string is rejected",
|
||||
recipients: []string{"age1short"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "secret key passed as recipient is rejected",
|
||||
recipients: []string{testIntegrationAgePrivateKey},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "two valid recipients are accepted",
|
||||
recipients: []string{testSneakAgePublicKey, testIntegrationAgePublicKey},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := baseConfig(tt.recipients).Validate()
|
||||
if !tt.wantErr {
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() unexpected error: %v", err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("Validate() returned nil, want error")
|
||||
}
|
||||
|
||||
// The entry itself must never appear in the error, since a
|
||||
// recipient string can be a secret key.
|
||||
for _, recipient := range tt.recipients {
|
||||
if strings.Contains(err.Error(), recipient) {
|
||||
t.Fatalf("Validate() error echoed the recipient value: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgeSecretKeySourceName checks the name reported for the configured
|
||||
// age secret key: the recorded source when Load set one, and the
|
||||
// config-file field name for a Config built directly (as in tests).
|
||||
func TestAgeSecretKeySourceName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "unset defaults to config field",
|
||||
source: "",
|
||||
want: ageSecretKeySourceConfig,
|
||||
},
|
||||
{
|
||||
name: "environment source",
|
||||
source: ageSecretKeySourceEnv,
|
||||
want: ageSecretKeySourceEnv,
|
||||
},
|
||||
{
|
||||
name: "config-file source",
|
||||
source: ageSecretKeySourceConfig,
|
||||
want: ageSecretKeySourceConfig,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := &Config{AgeSecretKeySource: tt.source}
|
||||
if got := cfg.AgeSecretKeySourceName(); got != tt.want {
|
||||
t.Errorf("AgeSecretKeySourceName() = %q, want %q", got, tt.want)
|
||||
result := extractAgeSecretKey(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("extractAgeSecretKey(%q) = %q, want %q",
|
||||
tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
// Package crypto provides thread-safe age encryption and decryption
|
||||
// helpers used to protect blob and metadata content.
|
||||
package crypto //nolint:revive,nolintlint // stdlib crypto unused; see #76
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
|
||||
"filippo.io/age"
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
// ErrNoRecipients is returned when an encryptor is created or updated
|
||||
// without any recipient public keys.
|
||||
var ErrNoRecipients = errors.New("at least one recipient is required")
|
||||
|
||||
// Encryptor provides thread-safe encryption using the age encryption library.
|
||||
// It supports encrypting data for multiple recipients simultaneously, allowing
|
||||
// any of the corresponding private keys to decrypt the data. This is useful
|
||||
// for backup scenarios where multiple parties should be able to decrypt the data.
|
||||
type Encryptor struct {
|
||||
recipients []age.Recipient
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewEncryptor creates a new encryptor with the given age public keys.
|
||||
// Each public key should be a valid age X25519 recipient string (e.g., "age1...")
|
||||
// At least one recipient must be provided. Returns an error if any of the
|
||||
// public keys are invalid or if no recipients are specified.
|
||||
func NewEncryptor(publicKeys []string) (*Encryptor, error) {
|
||||
if len(publicKeys) == 0 {
|
||||
return nil, ErrNoRecipients
|
||||
}
|
||||
|
||||
recipients := make([]age.Recipient, 0, len(publicKeys))
|
||||
for _, key := range publicKeys {
|
||||
recipient, err := age.ParseX25519Recipient(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing age recipient %s: %w", key, err)
|
||||
}
|
||||
|
||||
recipients = append(recipients, recipient)
|
||||
}
|
||||
|
||||
return &Encryptor{
|
||||
recipients: recipients,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Encrypt encrypts data using age encryption for all configured recipients.
|
||||
// The encrypted data can be decrypted by any of the corresponding private keys.
|
||||
// This method is suitable for small to medium amounts of data that fit in memory.
|
||||
// For large data streams, use EncryptStream or EncryptWriter instead.
|
||||
func (e *Encryptor) Encrypt(data []byte) ([]byte, error) {
|
||||
e.mu.RLock()
|
||||
recipients := e.recipients
|
||||
e.mu.RUnlock()
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
// Create encrypted writer for all recipients
|
||||
w, err := age.Encrypt(&buf, recipients...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating encrypted writer: %w", err)
|
||||
}
|
||||
|
||||
// Write data
|
||||
_, err = w.Write(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("writing encrypted data: %w", err)
|
||||
}
|
||||
|
||||
// Close to flush
|
||||
err = w.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("closing encrypted writer: %w", err)
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// EncryptStream encrypts data from reader to writer using age encryption.
|
||||
// This method is suitable for encrypting large files or streams as it processes
|
||||
// data in a streaming fashion without loading everything into memory.
|
||||
// The encrypted data is written directly to the destination writer.
|
||||
func (e *Encryptor) EncryptStream(dst io.Writer, src io.Reader) error {
|
||||
e.mu.RLock()
|
||||
recipients := e.recipients
|
||||
e.mu.RUnlock()
|
||||
|
||||
// Create encrypted writer for all recipients
|
||||
w, err := age.Encrypt(dst, recipients...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating encrypted writer: %w", err)
|
||||
}
|
||||
|
||||
// Copy data
|
||||
_, err = io.Copy(w, src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("copying encrypted data: %w", err)
|
||||
}
|
||||
|
||||
// Close to flush
|
||||
err = w.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("closing encrypted writer: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncryptWriter creates a writer that encrypts data written to it.
|
||||
// All data written to the returned WriteCloser will be encrypted and written
|
||||
// to the destination writer. The caller must call Close() on the returned
|
||||
// writer to ensure all encrypted data is properly flushed and finalized.
|
||||
// This is useful for integrating encryption into existing writer-based pipelines.
|
||||
func (e *Encryptor) EncryptWriter(dst io.Writer) (io.WriteCloser, error) {
|
||||
e.mu.RLock()
|
||||
recipients := e.recipients
|
||||
e.mu.RUnlock()
|
||||
|
||||
// Create encrypted writer for all recipients
|
||||
w, err := age.Encrypt(dst, recipients...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating encrypted writer: %w", err)
|
||||
}
|
||||
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// UpdateRecipients updates the recipients for future encryption operations.
|
||||
// This method is thread-safe and can be called while other encryption operations
|
||||
// are in progress. Existing encryption operations will continue with the old
|
||||
// recipients. At least one recipient must be provided. Returns an error if any
|
||||
// of the public keys are invalid or if no recipients are specified.
|
||||
func (e *Encryptor) UpdateRecipients(publicKeys []string) error {
|
||||
if len(publicKeys) == 0 {
|
||||
return ErrNoRecipients
|
||||
}
|
||||
|
||||
recipients := make([]age.Recipient, 0, len(publicKeys))
|
||||
for _, key := range publicKeys {
|
||||
recipient, err := age.ParseX25519Recipient(key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing age recipient %s: %w", key, err)
|
||||
}
|
||||
|
||||
recipients = append(recipients, recipient)
|
||||
}
|
||||
|
||||
e.mu.Lock()
|
||||
e.recipients = recipients
|
||||
e.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Decryptor provides thread-safe decryption using the age encryption library.
|
||||
// It uses a private key to decrypt data that was encrypted for the corresponding
|
||||
// public key.
|
||||
type Decryptor struct {
|
||||
identity age.Identity
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewDecryptor creates a new decryptor with the given age private key.
|
||||
// The private key should be a valid age X25519 identity string.
|
||||
// Returns an error if the private key is invalid.
|
||||
func NewDecryptor(privateKey string) (*Decryptor, error) {
|
||||
identity, err := age.ParseX25519Identity(privateKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing age identity: %w", err)
|
||||
}
|
||||
|
||||
return &Decryptor{
|
||||
identity: identity,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Decrypt decrypts data using age decryption.
|
||||
// This method is suitable for small to medium amounts of data that fit in memory.
|
||||
// For large data streams, use DecryptStream instead.
|
||||
func (d *Decryptor) Decrypt(data []byte) ([]byte, error) {
|
||||
d.mu.RLock()
|
||||
identity := d.identity
|
||||
d.mu.RUnlock()
|
||||
|
||||
r, err := age.Decrypt(bytes.NewReader(data), identity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating decrypted reader: %w", err)
|
||||
}
|
||||
|
||||
decrypted, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading decrypted data: %w", err)
|
||||
}
|
||||
|
||||
return decrypted, nil
|
||||
}
|
||||
|
||||
// DecryptStream returns a reader that decrypts data from the provided reader.
|
||||
// This method is suitable for decrypting large files or streams as it processes
|
||||
// data in a streaming fashion without loading everything into memory.
|
||||
// The caller should close the input reader when done.
|
||||
func (d *Decryptor) DecryptStream(src io.Reader) (io.Reader, error) {
|
||||
d.mu.RLock()
|
||||
identity := d.identity
|
||||
d.mu.RUnlock()
|
||||
|
||||
r, err := age.Decrypt(src, identity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating decrypted reader: %w", err)
|
||||
}
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Module exports the crypto module for fx dependency injection.
|
||||
//
|
||||
//nolint:gochecknoglobals // fx module definitions are package globals
|
||||
var Module = fx.Module("crypto")
|
||||
@@ -0,0 +1,178 @@
|
||||
package crypto_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"sneak.berlin/go/vaultik/internal/crypto"
|
||||
)
|
||||
|
||||
func TestEncryptor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Generate a test key pair
|
||||
identity, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate identity: %v", err)
|
||||
}
|
||||
|
||||
publicKey := identity.Recipient().String()
|
||||
|
||||
// Create encryptor
|
||||
enc, err := crypto.NewEncryptor([]string{publicKey})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create encryptor: %v", err)
|
||||
}
|
||||
|
||||
// Test data
|
||||
plaintext := []byte("Hello, World! This is a test message.")
|
||||
|
||||
// Encrypt
|
||||
ciphertext, err := enc.Encrypt(plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encrypt: %v", err)
|
||||
}
|
||||
|
||||
// Verify it's actually encrypted (should be larger and different)
|
||||
if bytes.Equal(plaintext, ciphertext) {
|
||||
t.Error("ciphertext equals plaintext")
|
||||
}
|
||||
|
||||
// Decrypt to verify
|
||||
r, err := age.Decrypt(bytes.NewReader(ciphertext), identity)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to decrypt: %v", err)
|
||||
}
|
||||
|
||||
var decrypted bytes.Buffer
|
||||
|
||||
_, err = decrypted.ReadFrom(r)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read decrypted data: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(plaintext, decrypted.Bytes()) {
|
||||
t.Error("decrypted data doesn't match original")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptorMultipleRecipients(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Generate three test key pairs
|
||||
identity1, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate identity1: %v", err)
|
||||
}
|
||||
|
||||
identity2, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate identity2: %v", err)
|
||||
}
|
||||
|
||||
identity3, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate identity3: %v", err)
|
||||
}
|
||||
|
||||
publicKeys := []string{
|
||||
identity1.Recipient().String(),
|
||||
identity2.Recipient().String(),
|
||||
identity3.Recipient().String(),
|
||||
}
|
||||
|
||||
// Create encryptor with multiple recipients
|
||||
enc, err := crypto.NewEncryptor(publicKeys)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create encryptor: %v", err)
|
||||
}
|
||||
|
||||
// Test data
|
||||
plaintext := []byte("Secret message for multiple recipients")
|
||||
|
||||
// Encrypt
|
||||
ciphertext, err := enc.Encrypt(plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encrypt: %v", err)
|
||||
}
|
||||
|
||||
// Verify each recipient can decrypt
|
||||
identities := []age.Identity{identity1, identity2, identity3}
|
||||
for i, identity := range identities {
|
||||
r, err := age.Decrypt(bytes.NewReader(ciphertext), identity)
|
||||
if err != nil {
|
||||
t.Fatalf("recipient %d failed to decrypt: %v", i+1, err)
|
||||
}
|
||||
|
||||
var decrypted bytes.Buffer
|
||||
|
||||
_, err = decrypted.ReadFrom(r)
|
||||
if err != nil {
|
||||
t.Fatalf("recipient %d failed to read decrypted data: %v", i+1, err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(plaintext, decrypted.Bytes()) {
|
||||
t.Errorf("recipient %d: decrypted data doesn't match original", i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptorUpdateRecipients(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Generate two identities
|
||||
identity1, _ := age.GenerateX25519Identity()
|
||||
identity2, _ := age.GenerateX25519Identity()
|
||||
|
||||
publicKey1 := identity1.Recipient().String()
|
||||
publicKey2 := identity2.Recipient().String()
|
||||
|
||||
// Create encryptor with first key
|
||||
enc, err := crypto.NewEncryptor([]string{publicKey1})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create encryptor: %v", err)
|
||||
}
|
||||
|
||||
// Encrypt with first key
|
||||
plaintext := []byte("test data")
|
||||
|
||||
ciphertext1, err := enc.Encrypt(plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encrypt: %v", err)
|
||||
}
|
||||
|
||||
// Update to second key
|
||||
err = enc.UpdateRecipients([]string{publicKey2})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to update recipients: %v", err)
|
||||
}
|
||||
|
||||
// Encrypt with second key
|
||||
ciphertext2, err := enc.Encrypt(plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encrypt: %v", err)
|
||||
}
|
||||
|
||||
// First ciphertext should only decrypt with first identity
|
||||
_, err = age.Decrypt(bytes.NewReader(ciphertext1), identity1)
|
||||
if err != nil {
|
||||
t.Error("failed to decrypt with identity1")
|
||||
}
|
||||
|
||||
_, err = age.Decrypt(bytes.NewReader(ciphertext1), identity2)
|
||||
if err == nil {
|
||||
t.Error("should not decrypt with identity2")
|
||||
}
|
||||
|
||||
// Second ciphertext should only decrypt with second identity
|
||||
_, err = age.Decrypt(bytes.NewReader(ciphertext2), identity2)
|
||||
if err != nil {
|
||||
t.Error("failed to decrypt with identity2")
|
||||
}
|
||||
|
||||
_, err = age.Decrypt(bytes.NewReader(ciphertext2), identity1)
|
||||
if err == nil {
|
||||
t.Error("should not decrypt with identity1")
|
||||
}
|
||||
}
|
||||
@@ -208,30 +208,6 @@ func (r *BlobRepository) DeleteOrphaned(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteUnuploaded deletes blob rows whose upload never completed
|
||||
// (uploaded_ts IS NULL) and returns how many were removed. Their
|
||||
// blob_chunks rows are removed by the ON DELETE CASCADE foreign key.
|
||||
// A blob is only ever attached to a snapshot once its upload has been
|
||||
// recorded, so an un-uploaded blob is never referenced by a completed
|
||||
// snapshot: dropping it discards chunk rows that point at data which
|
||||
// was never stored remotely, so the affected content is re-chunked and
|
||||
// re-uploaded on the next run.
|
||||
func (r *BlobRepository) DeleteUnuploaded(ctx context.Context) (int64, error) {
|
||||
query := `DELETE FROM blobs WHERE uploaded_ts IS NULL`
|
||||
|
||||
result, err := r.db.ExecWithLog(ctx, query)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("deleting un-uploaded blobs: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, _ := result.RowsAffected()
|
||||
if rowsAffected > 0 {
|
||||
log.Debug("Deleted un-uploaded blobs", "count", rowsAffected)
|
||||
}
|
||||
|
||||
return rowsAffected, nil
|
||||
}
|
||||
|
||||
// getOne fetches a single blob row matched on the given column, or
|
||||
// (nil, nil) when no row matches.
|
||||
func (r *BlobRepository) getOne(
|
||||
|
||||
@@ -7,32 +7,12 @@ import (
|
||||
|
||||
// List returns every chunk in the index, ordered by chunk hash.
|
||||
func (r *ChunkRepository) List(ctx context.Context) ([]*Chunk, error) {
|
||||
return r.list(ctx, `
|
||||
query := `
|
||||
SELECT chunk_hash, size
|
||||
FROM chunks
|
||||
ORDER BY chunk_hash
|
||||
`)
|
||||
}
|
||||
`
|
||||
|
||||
// ListInUploadedBlobs returns the chunks that are stored in a blob whose
|
||||
// upload has completed (uploaded_ts set), ordered by chunk hash. These
|
||||
// are the only chunks a backup may safely deduplicate against: a chunk
|
||||
// recorded solely in a blob that was never uploaded refers to data that
|
||||
// is not in remote storage, so trusting it would silently drop that data
|
||||
// from later snapshots.
|
||||
func (r *ChunkRepository) ListInUploadedBlobs(ctx context.Context) ([]*Chunk, error) {
|
||||
return r.list(ctx, `
|
||||
SELECT DISTINCT c.chunk_hash, c.size
|
||||
FROM chunks c
|
||||
JOIN blob_chunks bc ON c.chunk_hash = bc.chunk_hash
|
||||
JOIN blobs b ON bc.blob_id = b.id
|
||||
WHERE b.uploaded_ts IS NOT NULL
|
||||
ORDER BY c.chunk_hash
|
||||
`)
|
||||
}
|
||||
|
||||
// list runs a chunk-selecting query and scans the (chunk_hash, size) rows.
|
||||
func (r *ChunkRepository) list(ctx context.Context, query string) ([]*Chunk, error) {
|
||||
rows, err := r.db.conn.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying chunks: %w", err)
|
||||
|
||||
@@ -3,10 +3,8 @@
|
||||
//
|
||||
// Blobs in Vaultik are the final storage units uploaded to S3. Each blob is a
|
||||
// large (up to 10GB) file containing many compressed and encrypted chunks from
|
||||
// multiple source files. Blobs are content-addressed: the filename in S3 is
|
||||
// hex(SHA256(SHA256(uncompressed blob contents))), computed from the chunk data
|
||||
// before compression and encryption (not from the stored bytes). See
|
||||
// blobgen.DoubleSHA256 and docs/REPOSTRUCTURE.md.
|
||||
// multiple source files. Blobs are content-addressed, meaning their filename
|
||||
// is derived from their SHA256 hash after compression and encryption.
|
||||
//
|
||||
// Schema is managed via numbered SQL migrations embedded in the schema/
|
||||
// directory. Migration 000.sql bootstraps the schema_migrations tracking
|
||||
@@ -19,7 +17,6 @@ import (
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -222,135 +219,6 @@ func openWithRecovery(ctx context.Context, path string) (*DB, error) {
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// errUntrustedSnapshotSchema is returned when a downloaded snapshot
|
||||
// database carries schema objects the real schema never defines, or is
|
||||
// missing a table the restore and deep-verify queries read.
|
||||
var errUntrustedSnapshotSchema = errors.New(
|
||||
"downloaded snapshot database has an untrusted schema")
|
||||
|
||||
// snapshotReadOnlyDSN builds the driver DSN that opens a materialized
|
||||
// snapshot database file read-only. mode=ro opens the file read-only at
|
||||
// the OS level, query_only rejects any write the engine is asked to make,
|
||||
// and trusted_schema=OFF refuses to run application code named in the
|
||||
// schema. The file: URI form is required for the driver to honour the
|
||||
// mode parameter.
|
||||
func snapshotReadOnlyDSN(path string) string {
|
||||
u := url.URL{
|
||||
Scheme: "file",
|
||||
Path: path,
|
||||
RawQuery: "mode=ro&_pragma=query_only(true)&_pragma=trusted_schema(false)",
|
||||
}
|
||||
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// OpenReadOnly opens an already-materialized SQLite file for read-only
|
||||
// querying of a snapshot database downloaded from the store, used by
|
||||
// restore and deep verify. Unlike New it never applies schema migrations
|
||||
// and never writes: the connection is opened read-only with query_only
|
||||
// and trusted_schema=OFF. It refuses any file whose schema carries a
|
||||
// trigger, view or virtual table, or lacks an expected table, so a forged
|
||||
// file cannot redefine what the restore queries return. The caller owns
|
||||
// the file and must remove it.
|
||||
func OpenReadOnly(ctx context.Context, path string) (*DB, error) {
|
||||
conn, err := sql.Open("sqlite", snapshotReadOnlyDSN(path))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening read-only database: %w", err)
|
||||
}
|
||||
|
||||
configureConnPool(conn)
|
||||
|
||||
err = conn.PingContext(ctx)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
|
||||
return nil, fmt.Errorf("opening read-only database: %w", err)
|
||||
}
|
||||
|
||||
err = verifySnapshotSchema(ctx, conn)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &DB{conn: conn, path: path}, nil
|
||||
}
|
||||
|
||||
// verifySnapshotSchema rejects a downloaded database whose schema is not
|
||||
// the plain table set the real schema defines. Any trigger, view or
|
||||
// virtual table, or a missing expected table, fails the open.
|
||||
func verifySnapshotSchema(ctx context.Context, conn *sql.DB) error {
|
||||
// expectedSnapshotTables are the tables the restore and deep-verify
|
||||
// queries read. A downloaded database missing any of them is not a
|
||||
// genuine snapshot database and is refused.
|
||||
expectedSnapshotTables := []string{
|
||||
"blob_chunks",
|
||||
"blobs",
|
||||
"chunks",
|
||||
"file_chunks",
|
||||
"files",
|
||||
}
|
||||
|
||||
rows, err := conn.QueryContext(
|
||||
ctx, "SELECT type, name, sql FROM sqlite_master")
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading snapshot schema: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
present := make(map[string]struct{})
|
||||
|
||||
for rows.Next() {
|
||||
var objType, name string
|
||||
|
||||
var objSQL sql.NullString
|
||||
|
||||
err = rows.Scan(&objType, &name, &objSQL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading snapshot schema: %w", err)
|
||||
}
|
||||
|
||||
switch objType {
|
||||
case "trigger", "view":
|
||||
return fmt.Errorf(
|
||||
"%w: unexpected %s %q", errUntrustedSnapshotSchema, objType, name)
|
||||
case "table":
|
||||
if isVirtualTableSQL(objSQL.String) {
|
||||
return fmt.Errorf(
|
||||
"%w: unexpected virtual table %q",
|
||||
errUntrustedSnapshotSchema, name)
|
||||
}
|
||||
|
||||
present[name] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
err = rows.Err()
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading snapshot schema: %w", err)
|
||||
}
|
||||
|
||||
for _, table := range expectedSnapshotTables {
|
||||
if _, ok := present[table]; !ok {
|
||||
return fmt.Errorf(
|
||||
"%w: missing table %q", errUntrustedSnapshotSchema, table)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isVirtualTableSQL reports whether a sqlite_master row's SQL defines a
|
||||
// virtual table. Virtual tables are recorded with type 'table' but a
|
||||
// "CREATE VIRTUAL TABLE" definition and can run module code, so they are
|
||||
// refused alongside triggers and views.
|
||||
func isVirtualTableSQL(createSQL string) bool {
|
||||
return strings.HasPrefix(
|
||||
strings.ToUpper(strings.TrimSpace(createSQL)), "CREATE VIRTUAL TABLE")
|
||||
}
|
||||
|
||||
// NewTestDB creates an in-memory SQLite database for testing purposes.
|
||||
// The database is automatically initialized with the schema and is ready
|
||||
// for use. Each call creates a new independent database instance.
|
||||
|
||||
@@ -51,15 +51,15 @@ type Chunk struct {
|
||||
// Blob represents a blob record in the database.
|
||||
// A blob is Vaultik's final storage unit - a large file (up to 10GB) containing
|
||||
// many compressed and encrypted chunks from multiple source files.
|
||||
// Blobs are content-addressed: the filename in S3 is
|
||||
// hex(SHA256(SHA256(uncompressed blob contents))), computed from the chunk data
|
||||
// before compression and encryption (not from the stored bytes). See
|
||||
// blobgen.DoubleSHA256 and docs/REPOSTRUCTURE.md.
|
||||
// Blobs are content-addressed, meaning their filename in S3 is derived from
|
||||
// the SHA256 hash of their compressed and encrypted content.
|
||||
// The blob creation process is: chunks are accumulated -> compressed with zstd
|
||||
// -> encrypted with age -> hashed -> uploaded to S3 with the hash as filename.
|
||||
type Blob struct {
|
||||
ID types.BlobID // UUID assigned when blob creation starts
|
||||
|
||||
// Hash is hex(SHA256(SHA256(uncompressed blob contents)))
|
||||
// (empty until finalized); see the type comment above.
|
||||
// Hash is the SHA256 of the final compressed+encrypted content
|
||||
// (empty until finalized).
|
||||
Hash types.BlobHash
|
||||
CreatedTS time.Time // When blob creation started
|
||||
FinishedTS *time.Time // When blob was finalized (nil if still packing)
|
||||
|
||||
@@ -2,7 +2,6 @@ package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -16,44 +15,6 @@ import (
|
||||
// the index describes the backed-up file tree and must stay private.
|
||||
const indexDirPerm = 0o700
|
||||
|
||||
// indexFilePerm restricts the index file to the owning user; it lists every
|
||||
// backed-up path and chunk hash and must stay private.
|
||||
const indexFilePerm = 0o600
|
||||
|
||||
// ensureIndexFileMode makes the index file owner-only before the SQLite
|
||||
// driver opens it: it creates the file 0600 if absent, or chmods an existing
|
||||
// one to 0600. Doing this first matters because SQLite creates its -wal and
|
||||
// -shm side files with the mode of the main database file, so a private main
|
||||
// file yields private side files. The driver treats a zero-byte file as an
|
||||
// empty database, so pre-creating it here is safe.
|
||||
func ensureIndexFileMode(path string) error {
|
||||
info, err := os.Stat(path)
|
||||
|
||||
switch {
|
||||
case err == nil:
|
||||
if info.Mode().Perm() == indexFilePerm {
|
||||
return nil
|
||||
}
|
||||
|
||||
err = os.Chmod(path, indexFilePerm)
|
||||
if err != nil {
|
||||
return fmt.Errorf("restricting index file permissions: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
case errors.Is(err, os.ErrNotExist):
|
||||
//nolint:gosec // G304: the index path is operator-configured by design
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, indexFilePerm)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating index file: %w", err)
|
||||
}
|
||||
|
||||
return f.Close()
|
||||
default:
|
||||
return fmt.Errorf("checking index file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Module provides database dependencies
|
||||
//
|
||||
//nolint:gochecknoglobals // fx module definitions are package globals by convention
|
||||
@@ -73,11 +34,6 @@ func provideDatabase(lc fx.Lifecycle, cfg *config.Config) (*DB, error) {
|
||||
return nil, fmt.Errorf("creating index directory: %w", err)
|
||||
}
|
||||
|
||||
err = ensureIndexFileMode(cfg.IndexPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db, err := New(context.Background(), cfg.IndexPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening database: %w", err)
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/fx/fxtest"
|
||||
"sneak.berlin/go/vaultik/internal/config"
|
||||
)
|
||||
|
||||
// TestProvideDatabaseFreshIndexMode verifies that provideDatabase creates a
|
||||
// missing index file owner-only (0600), even under a lenient 022 umask that
|
||||
// would otherwise leave a freshly created file world-readable.
|
||||
//
|
||||
//nolint:paralleltest // syscall.Umask is process-global; parallel tests would clash
|
||||
func TestProvideDatabaseFreshIndexMode(t *testing.T) {
|
||||
restore := syscall.Umask(0o022)
|
||||
defer syscall.Umask(restore)
|
||||
|
||||
indexPath := filepath.Join(t.TempDir(), "index.sqlite")
|
||||
|
||||
openIndex(t, indexPath)
|
||||
assertPerm(t, indexPath, 0o600)
|
||||
}
|
||||
|
||||
// TestProvideDatabaseExistingIndexMode verifies that provideDatabase tightens
|
||||
// an existing world-readable index (0644) in a group/other-readable directory
|
||||
// down to owner-only (0600).
|
||||
//
|
||||
//nolint:paralleltest // syscall.Umask is process-global; parallel tests would clash
|
||||
func TestProvideDatabaseExistingIndexMode(t *testing.T) {
|
||||
restore := syscall.Umask(0o022)
|
||||
defer syscall.Umask(restore)
|
||||
|
||||
dir := filepath.Join(t.TempDir(), "data")
|
||||
|
||||
//nolint:gosec // G301: the test intentionally uses a 0755 directory
|
||||
err := os.MkdirAll(dir, 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("creating index directory: %v", err)
|
||||
}
|
||||
|
||||
//nolint:gosec // G302: the test intentionally uses a 0755 directory
|
||||
err = os.Chmod(dir, 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("relaxing index directory permissions: %v", err)
|
||||
}
|
||||
|
||||
indexPath := filepath.Join(dir, "index.sqlite")
|
||||
|
||||
//nolint:gosec // G306: the test intentionally starts from a 0644 index
|
||||
err = os.WriteFile(indexPath, nil, 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("creating pre-existing index: %v", err)
|
||||
}
|
||||
|
||||
//nolint:gosec // G302: the test intentionally starts from a 0644 index
|
||||
err = os.Chmod(indexPath, 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("relaxing pre-existing index permissions: %v", err)
|
||||
}
|
||||
|
||||
openIndex(t, indexPath)
|
||||
assertPerm(t, indexPath, 0o600)
|
||||
}
|
||||
|
||||
// openIndex runs provideDatabase against indexPath and closes the resulting
|
||||
// database before returning.
|
||||
func openIndex(t *testing.T, indexPath string) {
|
||||
t.Helper()
|
||||
|
||||
cfg := &config.Config{IndexPath: indexPath}
|
||||
|
||||
db, err := provideDatabase(fxtest.NewLifecycle(t), cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("provideDatabase: %v", err)
|
||||
}
|
||||
|
||||
err = db.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("closing database: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// assertPerm fails the test unless path has exactly the given permission bits.
|
||||
func assertPerm(t *testing.T, path string, want os.FileMode) {
|
||||
t.Helper()
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat %s: %v", path, err)
|
||||
}
|
||||
|
||||
got := info.Mode().Perm()
|
||||
if got != want {
|
||||
t.Fatalf("permissions of %s = %#o, want %#o", path, got, want)
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
//nolint:testpackage // exercises unexported read-only open internals
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// genuineSnapshotDB writes a real snapshot database (the full schema
|
||||
// applied) to a fresh file and returns its path.
|
||||
func genuineSnapshotDB(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "snapshot.db")
|
||||
|
||||
db, err := New(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatalf("creating snapshot database: %v", err)
|
||||
}
|
||||
|
||||
err = db.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("closing snapshot database: %v", err)
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
// forgedDB creates an empty database file and runs the given statements
|
||||
// against it read-write, so a test can plant schema objects the real
|
||||
// schema never defines.
|
||||
func forgedDB(t *testing.T, stmts ...string) string {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "forged.db")
|
||||
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatalf("opening forged database: %v", err)
|
||||
}
|
||||
|
||||
for _, stmt := range stmts {
|
||||
_, err = db.ExecContext(context.Background(), stmt)
|
||||
if err != nil {
|
||||
t.Fatalf("executing %q: %v", stmt, err)
|
||||
}
|
||||
}
|
||||
|
||||
err = db.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("closing forged database: %v", err)
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
func TestOpenReadOnlyAcceptsGenuineSnapshot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, err := OpenReadOnly(context.Background(), genuineSnapshotDB(t))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenReadOnly refused a genuine snapshot database: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
}
|
||||
|
||||
func TestOpenReadOnlyRefusesWrites(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, err := OpenReadOnly(context.Background(), genuineSnapshotDB(t))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenReadOnly: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
// A schema write depends on no table columns, so the only reason it
|
||||
// can fail is that the database is open read-only.
|
||||
_, err = db.Conn().ExecContext(context.Background(),
|
||||
"CREATE TABLE probe_readonly (x)")
|
||||
if err == nil {
|
||||
t.Fatal("expected a write to a read-only snapshot database to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenReadOnlyRejectsView(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := forgedDB(t, "CREATE VIEW files AS SELECT 1 AS path")
|
||||
|
||||
_, err := OpenReadOnly(context.Background(), path)
|
||||
if !errors.Is(err, errUntrustedSnapshotSchema) {
|
||||
t.Fatalf("expected a view named files to be refused, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenReadOnlyRejectsTrigger(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := forgedDB(t,
|
||||
"CREATE TABLE files (path TEXT)",
|
||||
"CREATE TRIGGER t AFTER INSERT ON files BEGIN SELECT 1; END")
|
||||
|
||||
_, err := OpenReadOnly(context.Background(), path)
|
||||
if !errors.Is(err, errUntrustedSnapshotSchema) {
|
||||
t.Fatalf("expected a trigger to be refused, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenReadOnlyRejectsMissingTable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Only one of the expected tables is present.
|
||||
path := forgedDB(t, "CREATE TABLE files (path TEXT)")
|
||||
|
||||
_, err := OpenReadOnly(context.Background(), path)
|
||||
if !errors.Is(err, errUntrustedSnapshotSchema) {
|
||||
t.Fatalf("expected a missing expected table to be refused, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsVirtualTableSQL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
sql string
|
||||
want bool
|
||||
}{
|
||||
{"CREATE VIRTUAL TABLE t USING fts5(x)", true},
|
||||
{" create virtual table t using fts5(x)", true},
|
||||
{"CREATE TABLE t (x)", false},
|
||||
{"CREATE VIEW t AS SELECT 1", false},
|
||||
{"", false},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
if got := isVirtualTableSQL(c.sql); got != c.want {
|
||||
t.Errorf("isVirtualTableSQL(%q) = %v, want %v", c.sql, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,18 +11,6 @@ import (
|
||||
"sneak.berlin/go/vaultik/internal/types"
|
||||
)
|
||||
|
||||
// Sentinel errors for the single-snapshot invariant that an exported
|
||||
// per-snapshot metadata database must satisfy.
|
||||
var (
|
||||
// ErrNoSnapshotInDatabase means the metadata database has no snapshot
|
||||
// row at all.
|
||||
ErrNoSnapshotInDatabase = errors.New("database contains no snapshot")
|
||||
// ErrMultipleSnapshotsInDatabase means the metadata database holds
|
||||
// more than the single snapshot an export is supposed to contain.
|
||||
ErrMultipleSnapshotsInDatabase = errors.New(
|
||||
"database contains more than one snapshot")
|
||||
)
|
||||
|
||||
// SnapshotRepository provides access to the snapshots table and its
|
||||
// snapshot_files / snapshot_blobs association tables.
|
||||
type SnapshotRepository struct {
|
||||
@@ -218,48 +206,6 @@ func (r *SnapshotRepository) GetByID(
|
||||
return &snapshot, nil
|
||||
}
|
||||
|
||||
// GetOnlySnapshot returns the sole snapshot in an exported per-snapshot
|
||||
// metadata database. The backup path writes each snapshot's database with
|
||||
// exactly one snapshot row (see cleanSnapshotDB), so restore and deep
|
||||
// verify expect exactly one. Zero rows return ErrNoSnapshotInDatabase and
|
||||
// more than one returns ErrMultipleSnapshotsInDatabase; callers treat
|
||||
// either as a failed identity check on the downloaded database.
|
||||
func (r *SnapshotRepository) GetOnlySnapshot(ctx context.Context) (*Snapshot, error) {
|
||||
query := `
|
||||
SELECT id, hostname, vaultik_version, vaultik_git_revision,
|
||||
started_at, completed_at, file_count, chunk_count, blob_count,
|
||||
total_size, blob_size, compression_ratio
|
||||
FROM snapshots
|
||||
LIMIT 2
|
||||
`
|
||||
|
||||
rows, err := r.db.conn.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying snapshots: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := rows.Close()
|
||||
if err != nil {
|
||||
Fatalf("failed to close rows: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
snapshots, err := r.scanSnapshotRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch len(snapshots) {
|
||||
case 1:
|
||||
return snapshots[0], nil
|
||||
case 0:
|
||||
return nil, ErrNoSnapshotInDatabase
|
||||
default:
|
||||
return nil, ErrMultipleSnapshotsInDatabase
|
||||
}
|
||||
}
|
||||
|
||||
// ListRecent returns up to limit snapshots, most recently started first.
|
||||
func (r *SnapshotRepository) ListRecent(
|
||||
ctx context.Context, limit int,
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
package log_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
)
|
||||
|
||||
// TestTTYHandlerEscapesControlCharacters logs a message and an attribute
|
||||
// value that each carry an ESC and a newline — the shape a crafted path or
|
||||
// storage error from the destination would take — and checks neither raw
|
||||
// byte reaches the output. The handler's own colour codes (ESC ... m) are
|
||||
// stripped first; any ESC left after that came from the untrusted value.
|
||||
func TestTTYHandlerEscapesControlCharacters(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
logger := slog.New(log.NewTTYHandler(&buf, debugHandlerOptions()))
|
||||
logger.Info("start\x1b[31mZAP\nend", "target", "a\x1b[31mZAP\nb")
|
||||
|
||||
out := buf.String()
|
||||
|
||||
// The only newline is the line terminator; the injected ones were escaped.
|
||||
require.Equal(t, 1, strings.Count(out, "\n"),
|
||||
"a newline in the message or a value must be escaped, not emitted raw")
|
||||
|
||||
// After the handler's own colour codes are removed, no ESC survives.
|
||||
stripped := ansiEscape.ReplaceAllString(out, "")
|
||||
require.NotContains(t, stripped, "\x1b",
|
||||
"a raw ESC from the message or a value must not reach the terminal")
|
||||
|
||||
// The escaped form is what appears instead.
|
||||
require.Contains(t, out, `\x1b`)
|
||||
}
|
||||
@@ -5,11 +5,9 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// groupSeparator joins an open group path to an attribute key. This
|
||||
@@ -118,14 +116,11 @@ func (h *TTYHandler) Handle(_ context.Context, r slog.Record) error {
|
||||
levelColor = colorReset
|
||||
}
|
||||
|
||||
// Print main message. The message is escaped before the colour codes
|
||||
// are written around it: it can carry text from an untrusted source
|
||||
// (a storage error, for one), and a raw control character would
|
||||
// otherwise reach the terminal.
|
||||
// Print main message
|
||||
_, _ = fmt.Fprintf(h.out, "%s%s%s %s%s%s %s%s%s",
|
||||
colorGray, timestamp, colorReset,
|
||||
levelColor, level, colorReset,
|
||||
colorBold, sanitize(r.Message), colorReset)
|
||||
colorBold, r.Message, colorReset)
|
||||
|
||||
// Attributes carried by the handler come first, then the record's
|
||||
// own. Handler attributes were qualified when they were added; the
|
||||
@@ -265,29 +260,9 @@ func (h *TTYHandler) writeAttr(a slog.Attr) {
|
||||
// Future kinds also use the plain string form.
|
||||
}
|
||||
|
||||
// Escape the key and value before the colour codes are written around
|
||||
// them. Both can carry text from an untrusted source — a manifest
|
||||
// timestamp, a storage error, a path or symlink target read back from
|
||||
// the snapshot database — so a control character in one of them must
|
||||
// be rendered as an escape sequence rather than reaching the terminal,
|
||||
// where it could move the cursor or inject its own colours.
|
||||
_, _ = fmt.Fprintf(h.out, " %s%s%s=%s%s%s",
|
||||
colorCyan, sanitize(a.Key), colorReset,
|
||||
colorBlue, sanitize(value), colorReset)
|
||||
}
|
||||
|
||||
// sanitize returns s unchanged when every rune in it is printable, and a
|
||||
// double-quoted, backslash-escaped form (\n, \x1b, …) otherwise. It is
|
||||
// applied to untrusted text before any colour code is written, so a
|
||||
// control character can never reach the terminal raw.
|
||||
func sanitize(s string) string {
|
||||
for _, r := range s {
|
||||
if !unicode.IsPrint(r) {
|
||||
return strconv.Quote(s)
|
||||
}
|
||||
}
|
||||
|
||||
return s
|
||||
colorCyan, a.Key, colorReset,
|
||||
colorBlue, value, colorReset)
|
||||
}
|
||||
|
||||
// formatDuration formats a duration in a human-readable way
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
//nolint:testpackage // exercises the unexported copyFile helper
|
||||
package snapshot
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
// TestCopyFileExportCopyMode verifies that the exported snapshot database
|
||||
// copy is created owner-only (0600), even under a lenient 022 umask that
|
||||
// would otherwise leave a fresh file world-readable.
|
||||
//
|
||||
//nolint:paralleltest // syscall.Umask is process-global; parallel tests would clash
|
||||
func TestCopyFileExportCopyMode(t *testing.T) {
|
||||
restore := syscall.Umask(0o022)
|
||||
defer syscall.Umask(restore)
|
||||
|
||||
dir := t.TempDir()
|
||||
|
||||
src := filepath.Join(dir, "index.sqlite")
|
||||
|
||||
err := os.WriteFile(src, []byte("index data"), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("creating source index: %v", err)
|
||||
}
|
||||
|
||||
dst := filepath.Join(dir, "snapshot.db")
|
||||
|
||||
sm := &SnapshotManager{fs: afero.NewOsFs()}
|
||||
|
||||
err = sm.copyFile(src, dst)
|
||||
if err != nil {
|
||||
t.Fatalf("copyFile: %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(dst)
|
||||
if err != nil {
|
||||
t.Fatalf("stat export copy: %v", err)
|
||||
}
|
||||
|
||||
got := info.Mode().Perm()
|
||||
if got != 0o600 {
|
||||
t.Fatalf("export copy permissions = %#o, want %#o", got, 0o600)
|
||||
}
|
||||
}
|
||||
@@ -7,19 +7,6 @@ import (
|
||||
"io"
|
||||
|
||||
"github.com/klauspost/compress/zstd"
|
||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||
)
|
||||
|
||||
// Manifest size bounds. A manifest lists one small entry per blob, and
|
||||
// blobs are large (the default target is 10 GB), so even a manifest for a
|
||||
// petabyte-scale backup is a few megabytes. These caps are far above any
|
||||
// manifest the writer can emit, yet stop a crafted, highly compressible
|
||||
// manifest from expanding without limit when decoded: the manifest is
|
||||
// fetched from the store, which is not trusted, and json.Decode buffers
|
||||
// the whole value in memory.
|
||||
const (
|
||||
manifestMaxCompressed = 256 * 1024 * 1024 // 256 MiB
|
||||
manifestMaxDecompressed = 1024 * 1024 * 1024 // 1 GiB
|
||||
)
|
||||
|
||||
// Manifest represents the structure of a snapshot's blob manifest
|
||||
@@ -41,31 +28,19 @@ type BlobInfo struct {
|
||||
CompressedSize int64 `json:"compressed_size"`
|
||||
}
|
||||
|
||||
// DecodeManifest decodes a manifest from a reader containing compressed
|
||||
// JSON, reading through byte limits on both the compressed input and the
|
||||
// decompressed output so an untrusted manifest cannot exhaust memory.
|
||||
// DecodeManifest decodes a manifest from a reader containing compressed JSON
|
||||
func DecodeManifest(r io.Reader) (*Manifest, error) {
|
||||
return decodeManifest(r, manifestMaxCompressed, manifestMaxDecompressed)
|
||||
}
|
||||
|
||||
// decodeManifest is DecodeManifest with explicit limits, so tests can drive
|
||||
// the bounds with small inputs instead of gigabyte-scale ones.
|
||||
func decodeManifest(
|
||||
r io.Reader, maxCompressed, maxDecompressed int64,
|
||||
) (*Manifest, error) {
|
||||
// Decompress using zstd, bounding how many compressed bytes are read.
|
||||
zr, err := zstd.NewReader(blobgen.LimitReader(r, maxCompressed))
|
||||
// Decompress using zstd
|
||||
zr, err := zstd.NewReader(r)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating zstd reader: %w", err)
|
||||
}
|
||||
defer zr.Close()
|
||||
|
||||
// Decode JSON manifest, bounding how far the compressed input may
|
||||
// expand: json.Decode buffers the whole value, so without this a
|
||||
// small, highly compressible manifest could expand to gigabytes.
|
||||
// Decode JSON manifest
|
||||
var manifest Manifest
|
||||
|
||||
err = json.NewDecoder(blobgen.LimitReader(zr, maxDecompressed)).Decode(&manifest)
|
||||
err = json.NewDecoder(zr).Decode(&manifest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decoding manifest: %w", err)
|
||||
}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
//nolint:testpackage // exercises the unexported decodeManifest bounds
|
||||
package snapshot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||
)
|
||||
|
||||
// testSnapshotID is a stand-in snapshot ID reused across the bound cases.
|
||||
const testSnapshotID = "host_home_2026-01-01T00:00:00Z"
|
||||
|
||||
// TestDecodeManifestRoundTrip is the baseline: with generous bounds a
|
||||
// manifest the writer produced decodes back unchanged.
|
||||
func TestDecodeManifestRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
want := &Manifest{
|
||||
SnapshotID: testSnapshotID,
|
||||
Timestamp: "2026-01-01T00:00:00Z",
|
||||
BlobCount: 2,
|
||||
TotalCompressedSize: 42,
|
||||
Blobs: []BlobInfo{
|
||||
{Hash: "aa", CompressedSize: 21},
|
||||
{Hash: "bb", CompressedSize: 21},
|
||||
},
|
||||
}
|
||||
|
||||
compressed, err := EncodeManifest(want, 3)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := decodeManifest(
|
||||
bytes.NewReader(compressed), manifestMaxCompressed, manifestMaxDecompressed)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, want, got)
|
||||
}
|
||||
|
||||
// TestDecodeManifestBoundsDecompressedOutput feeds a valid but highly
|
||||
// compressible manifest — one whose timestamp is a megabyte of the same
|
||||
// character — through a small decompressed bound. The compressed form is
|
||||
// tiny, so only the decompressed bound stops it; decoding must fail within
|
||||
// that bound rather than expanding the value in memory.
|
||||
func TestDecodeManifestBoundsDecompressedOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
bomb := &Manifest{
|
||||
SnapshotID: testSnapshotID,
|
||||
Timestamp: strings.Repeat("a", 1<<20),
|
||||
}
|
||||
|
||||
compressed, err := EncodeManifest(bomb, 3)
|
||||
require.NoError(t, err)
|
||||
require.Less(t, len(compressed), 4096,
|
||||
"the compressible manifest must be small compressed")
|
||||
|
||||
_, err = decodeManifest(bytes.NewReader(compressed), 1<<20, 4096)
|
||||
require.ErrorIs(t, err, blobgen.ErrOutputTooLarge)
|
||||
}
|
||||
|
||||
// TestDecodeManifestBoundsCompressedInput checks the compressed-input
|
||||
// bound fires independently: a valid manifest with a generous decompressed
|
||||
// bound but a tiny compressed bound still fails.
|
||||
func TestDecodeManifestBoundsCompressedInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manifest := &Manifest{
|
||||
SnapshotID: testSnapshotID,
|
||||
Timestamp: strings.Repeat("a", 4096),
|
||||
}
|
||||
|
||||
compressed, err := EncodeManifest(manifest, 3)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = decodeManifest(bytes.NewReader(compressed), 8, manifestMaxDecompressed)
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
//nolint:testpackage // exercises the unexported generateBlobManifest
|
||||
package snapshot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/config"
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/types"
|
||||
)
|
||||
|
||||
// TestGenerateBlobManifest_MissingBlobFails is the regression guard for
|
||||
// issue #157: a blob the snapshot references but that is absent from the
|
||||
// blobs table used to be logged and skipped, yielding a manifest with
|
||||
// fewer blobs than the snapshot needs. Since prune trusts the manifest
|
||||
// alone, that omitted blob would be deleted at the next prune. Manifest
|
||||
// generation must fail instead.
|
||||
func TestGenerateBlobManifest_MissingBlobFails(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
dbPath := filepath.Join(t.TempDir(), "snapshot.db")
|
||||
|
||||
db, err := database.New(ctx, dbPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
|
||||
// A real blob row satisfies the snapshot_blobs foreign key on
|
||||
// blob_id; the snapshot then references a different, absent hash.
|
||||
presentBlob := &database.Blob{
|
||||
ID: types.NewBlobID(),
|
||||
Hash: types.BlobHash("present-blob-hash"),
|
||||
CreatedTS: time.Now().Truncate(time.Second),
|
||||
}
|
||||
require.NoError(t, repos.Blobs.Create(ctx, nil, presentBlob))
|
||||
|
||||
snap := &database.Snapshot{
|
||||
ID: "testhost_home_2026-05-01T00:00:00Z",
|
||||
Hostname: "testhost",
|
||||
}
|
||||
require.NoError(t, repos.Snapshots.Create(ctx, nil, snap))
|
||||
require.NoError(t, repos.Snapshots.AddBlob(ctx, nil,
|
||||
snap.ID.String(), presentBlob.ID, types.BlobHash("absent-blob-hash")))
|
||||
|
||||
require.NoError(t, db.Close())
|
||||
|
||||
sm := &SnapshotManager{
|
||||
config: &config.Config{CompressionLevel: 3},
|
||||
fs: afero.NewOsFs(),
|
||||
}
|
||||
|
||||
_, err = sm.generateBlobManifest(ctx, dbPath, snap.ID.String())
|
||||
require.Error(t, err, "manifest generation must fail on a missing blob")
|
||||
assert.Contains(t, err.Error(), "absent-blob-hash")
|
||||
}
|
||||
@@ -63,9 +63,7 @@ type Scanner struct {
|
||||
exclude []string // Glob patterns for files/directories to exclude
|
||||
compiledExclude []compiledPattern // Compiled glob patterns
|
||||
progress *ProgressReporter
|
||||
// skipErrors skips files that cannot be opened or read (logged loudly);
|
||||
// packer, database, encryption, and upload errors still abort the run.
|
||||
skipErrors bool
|
||||
skipErrors bool // Skip file read errors (log loudly but continue)
|
||||
// ui is the user-facing output; never nil (defaults to a discarding writer).
|
||||
ui *ui.Writer
|
||||
|
||||
@@ -119,13 +117,11 @@ type ScannerConfig struct {
|
||||
Storage storage.Storer
|
||||
MaxBlobSize int64
|
||||
CompressionLevel int
|
||||
AgeRecipients []string // required; output is always encrypted
|
||||
AgeRecipients []string // Optional, empty means no encryption
|
||||
EnableProgress bool // Enable the live progress reporter (ETAs, throughput)
|
||||
UI *ui.Writer // Where user-facing scanner messages go; nil = discard
|
||||
Exclude []string // Glob patterns for files/directories to exclude
|
||||
// SkipErrors skips files that cannot be opened or read (log loudly but
|
||||
// continue); packer, database, encryption, and upload errors still abort.
|
||||
SkipErrors bool
|
||||
SkipErrors bool // Skip file read errors (log loudly but continue)
|
||||
}
|
||||
|
||||
// ScanResult contains the results of a scan operation
|
||||
@@ -224,14 +220,7 @@ func (s *Scanner) Scan(
|
||||
defer s.progress.Stop()
|
||||
}
|
||||
|
||||
// Phase 0: Repair any state left by an interrupted previous run, then
|
||||
// load known files and chunks from the database into memory for fast
|
||||
// lookup.
|
||||
err := s.repairInterruptedBlobs(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Phase 0: Load known files and chunks from database into memory for fast lookup
|
||||
knownFiles, err := s.loadDatabaseState(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -328,38 +317,6 @@ func (s *Scanner) loadDatabaseState(
|
||||
return knownFiles, nil
|
||||
}
|
||||
|
||||
// repairInterruptedBlobs discards blob rows left by a previous run whose
|
||||
// upload never completed. Such a blob has its chunks, blob_chunks, and
|
||||
// blobs rows committed to the local index before the upload is attempted,
|
||||
// so a crash or dropped connection mid-upload leaves them behind while the
|
||||
// data never reaches remote storage. Deduplicating against those chunks on
|
||||
// a later run would produce a snapshot that reports success but cannot be
|
||||
// restored. Dropping the un-uploaded blobs (their blob_chunks cascade) and
|
||||
// then any chunks left unreferenced forces the affected data to be
|
||||
// re-chunked and re-uploaded this run. A blob is attached to a snapshot
|
||||
// only once its upload is recorded, so this never touches a completed
|
||||
// snapshot's data.
|
||||
func (s *Scanner) repairInterruptedBlobs(ctx context.Context) error {
|
||||
removed, err := s.repos.Blobs.DeleteUnuploaded(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("removing un-uploaded blob records: %w", err)
|
||||
}
|
||||
|
||||
if removed == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Warn("Discarded blob records from an interrupted previous run; "+
|
||||
"their data will be re-uploaded", "blobs", removed)
|
||||
|
||||
err = s.repos.Chunks.DeleteOrphaned(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("removing orphaned chunks: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// summarizeScanPhase calculates total size to process, updates progress tracking,
|
||||
// and prints the scan phase summary with file counts and sizes
|
||||
func (s *Scanner) summarizeScanPhase(
|
||||
@@ -435,14 +392,11 @@ func (s *Scanner) loadKnownFiles(
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// loadKnownChunks loads the chunk hashes safe to deduplicate against into
|
||||
// an in-memory map for fast lookup, avoiding per-chunk database queries
|
||||
// during file processing. Only chunks held by a blob whose upload
|
||||
// completed are loaded: a chunk left behind by an interrupted upload
|
||||
// refers to data that never reached remote storage, and deduplicating
|
||||
// against it would silently produce an unrestorable snapshot.
|
||||
// loadKnownChunks loads all known chunk hashes from the database into a
|
||||
// map for fast lookup. This avoids per-chunk database queries during file
|
||||
// processing.
|
||||
func (s *Scanner) loadKnownChunks(ctx context.Context) error {
|
||||
chunks, err := s.repos.Chunks.ListInUploadedBlobs(ctx)
|
||||
chunks, err := s.repos.Chunks.List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing chunks: %w", err)
|
||||
}
|
||||
@@ -1340,15 +1294,6 @@ func (s *Scanner) processFileWithErrorHandling(
|
||||
) (bool, error) {
|
||||
err := s.processFileStreaming(ctx, fileToProcess, result)
|
||||
if err != nil {
|
||||
// A packer/database/encryption/upload failure means the chunk's data
|
||||
// may not have been stored. Skipping the file would let the snapshot
|
||||
// record a file whose chunk is in no blob and cannot be restored, so
|
||||
// abort the run even under --skip-errors. Only open and read errors
|
||||
// are skipped below.
|
||||
var pErr *packerError
|
||||
if errors.As(err, &pErr) {
|
||||
return false, fmt.Errorf("processing file %s: %w", fileToProcess.Path, err)
|
||||
}
|
||||
// Handle files that were deleted between scan and process phases
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
log.Warn("File was deleted during backup, skipping",
|
||||
@@ -1358,7 +1303,7 @@ func (s *Scanner) processFileWithErrorHandling(
|
||||
|
||||
return true, nil
|
||||
}
|
||||
// Skip open/read errors if --skip-errors is enabled
|
||||
// Skip file read errors if --skip-errors is enabled
|
||||
if s.skipErrors {
|
||||
log.Error("Failed to process file (skipping due to --skip-errors)",
|
||||
"path", fileToProcess.Path, "error", err)
|
||||
@@ -1456,17 +1401,7 @@ func (s *Scanner) finalizeProcessPhase(ctx context.Context, result *ScanResult)
|
||||
return fmt.Errorf("parsing blob ID: %w", err)
|
||||
}
|
||||
|
||||
// With no remote backend the blob's lifecycle ends here, so
|
||||
// mark it uploaded in the same transaction that attaches it to
|
||||
// the snapshot. This keeps the invariant that any blob a
|
||||
// snapshot references has uploaded_ts set, so deduplication and
|
||||
// interrupted-run repair treat these blobs as trustworthy.
|
||||
err = s.repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
||||
err := s.repos.Blobs.UpdateUploaded(ctx, tx, b.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marking blob uploaded: %w", err)
|
||||
}
|
||||
|
||||
return s.repos.Snapshots.AddBlob(ctx, tx, s.snapshotID, blobID,
|
||||
types.BlobHash(b.Hash))
|
||||
})
|
||||
@@ -1725,20 +1660,6 @@ type streamingChunkInfo struct {
|
||||
size int64
|
||||
}
|
||||
|
||||
// packerError marks an error that came from adding a chunk to the packer
|
||||
// (packing, database, encryption, or upload). Such an error means the chunk's
|
||||
// data may not have been stored, so the run must abort even under --skip-errors:
|
||||
// skipping the file would leave the chunk recorded as backed up while it lives
|
||||
// in no blob, and a later snapshot could record a file that cannot be restored.
|
||||
// Only open and read errors are safe to skip.
|
||||
type packerError struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *packerError) Error() string { return e.err.Error() }
|
||||
|
||||
func (e *packerError) Unwrap() error { return e.err }
|
||||
|
||||
// processFileStreaming processes a file by streaming chunks directly to the packer
|
||||
func (s *Scanner) processFileStreaming(
|
||||
ctx context.Context, fileToProcess *FileToProcess, result *ScanResult,
|
||||
@@ -1789,11 +1710,7 @@ func (s *Scanner) processFileStreaming(
|
||||
if !chunkExists {
|
||||
err := s.addChunkToPacker(ctx, chunk)
|
||||
if err != nil {
|
||||
// Mark as a packer error so --skip-errors cannot swallow it:
|
||||
// the chunk was registered as pending before packing, so a
|
||||
// skipped file here would be recorded as backed up while its
|
||||
// data was never stored.
|
||||
return &packerError{err: err}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
package snapshot_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
)
|
||||
|
||||
// errSimTempFail is the one-time temp-file creation failure blobTempFailFs
|
||||
// injects, mirroring a full temp filesystem.
|
||||
var errSimTempFail = errors.New("simulated temp-file creation failure")
|
||||
|
||||
// errSimRead is the read failure readFailFile injects for a file that opens
|
||||
// but cannot be read.
|
||||
var errSimRead = errors.New("simulated read failure")
|
||||
|
||||
// blobTempFailFs fails the first temp-file creation for a packer blob, then
|
||||
// behaves normally, simulating a one-time failure to start a new blob.
|
||||
type blobTempFailFs struct {
|
||||
afero.Fs
|
||||
|
||||
mu sync.Mutex
|
||||
failed bool
|
||||
}
|
||||
|
||||
//nolint:ireturn // afero.Fs.OpenFile is defined to return the interface.
|
||||
func (f *blobTempFailFs) OpenFile(
|
||||
name string, flag int, perm os.FileMode,
|
||||
) (afero.File, error) {
|
||||
if strings.Contains(name, "vaultik-blob-") {
|
||||
f.mu.Lock()
|
||||
firstTime := !f.failed
|
||||
f.failed = true
|
||||
f.mu.Unlock()
|
||||
|
||||
if firstTime {
|
||||
return nil, errSimTempFail
|
||||
}
|
||||
}
|
||||
|
||||
return f.Fs.OpenFile(name, flag, perm)
|
||||
}
|
||||
|
||||
// readFailFile wraps an afero.File whose Read always fails.
|
||||
type readFailFile struct {
|
||||
afero.File
|
||||
}
|
||||
|
||||
func (readFailFile) Read([]byte) (int, error) {
|
||||
return 0, errSimRead
|
||||
}
|
||||
|
||||
// readFailFs fails reads of one target path after a successful open.
|
||||
type readFailFs struct {
|
||||
afero.Fs
|
||||
|
||||
target string
|
||||
}
|
||||
|
||||
//nolint:ireturn // afero.Fs.Open is defined to return the interface.
|
||||
func (f *readFailFs) Open(name string) (afero.File, error) {
|
||||
file, err := f.Fs.Open(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if name == f.target {
|
||||
return readFailFile{File: file}, nil
|
||||
}
|
||||
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// writeSkipErrorTestFile writes one file into fs with a fixed mtime.
|
||||
func writeSkipErrorTestFile(t *testing.T, fs afero.Fs, path, content string) {
|
||||
t.Helper()
|
||||
|
||||
err := fs.MkdirAll(filepath.Dir(path), 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
|
||||
err = afero.WriteFile(fs, path, []byte(content), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
|
||||
when := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
err = fs.Chtimes(path, when, when)
|
||||
if err != nil {
|
||||
t.Fatalf("chtimes %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// runSkipErrorScan scans /source on fs with the given skip-errors setting and
|
||||
// returns the repositories (for inspection) and the scan error.
|
||||
func runSkipErrorScan(
|
||||
t *testing.T, fs afero.Fs, skipErrors bool,
|
||||
) (*database.Repositories, error) {
|
||||
t.Helper()
|
||||
|
||||
db, err := database.NewTestDB()
|
||||
if err != nil {
|
||||
t.Fatalf("create test db: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
cerr := db.Close()
|
||||
if cerr != nil {
|
||||
t.Errorf("close db: %v", cerr)
|
||||
}
|
||||
})
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
|
||||
scanner := snapshot.NewScanner(snapshot.ScannerConfig{
|
||||
FS: fs,
|
||||
ChunkSize: int64(1024 * 16),
|
||||
Repositories: repos,
|
||||
MaxBlobSize: int64(1024 * 1024),
|
||||
CompressionLevel: 3,
|
||||
AgeRecipients: []string{testAgePublicKey},
|
||||
SkipErrors: skipErrors,
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
snapshotID := "test-snapshot-skip-errors"
|
||||
createTestSnapshotRecord(ctx, t, repos, snapshotID)
|
||||
|
||||
_, err = scanner.Scan(ctx, "/source", snapshotID)
|
||||
|
||||
return repos, err
|
||||
}
|
||||
|
||||
// TestScannerPackingFailureAbortsUnderSkipErrors checks that a failure to start
|
||||
// a new blob aborts the run even with --skip-errors. Otherwise the file would
|
||||
// be skipped while its chunk had already been registered as pending, letting a
|
||||
// later blob record that chunk in the chunks table with no blob to back it —
|
||||
// a snapshot that completes with a file that cannot be restored.
|
||||
func TestScannerPackingFailureAbortsUnderSkipErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Two files with distinct content so each yields a distinct chunk: the
|
||||
// first fails to start a blob, and without the fix the second's blob would
|
||||
// commit the first's orphaned chunk row.
|
||||
fs := &blobTempFailFs{Fs: afero.NewMemMapFs()}
|
||||
writeSkipErrorTestFile(t, fs, "/source/file1.txt", "first file content")
|
||||
writeSkipErrorTestFile(t, fs, "/source/file2.txt", "second file content")
|
||||
|
||||
repos, err := runSkipErrorScan(t, fs, true)
|
||||
if err == nil {
|
||||
t.Fatal("expected scan to abort on the packer error, got nil")
|
||||
}
|
||||
|
||||
// ListUnpacked returns chunks recorded with no blob_chunks row: exactly the
|
||||
// unrestorable state this fix prevents.
|
||||
unpacked, err := repos.Chunks.ListUnpacked(context.Background(), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("listing unpacked chunks: %v", err)
|
||||
}
|
||||
|
||||
if len(unpacked) != 0 {
|
||||
t.Fatalf("expected no chunk recorded without a blob, got %d", len(unpacked))
|
||||
}
|
||||
}
|
||||
|
||||
// TestScannerReadErrorAbortsWithoutSkipErrors checks that a file read error
|
||||
// aborts the run when --skip-errors is not set.
|
||||
func TestScannerReadErrorAbortsWithoutSkipErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const target = "/source/unreadable.txt"
|
||||
|
||||
fs := &readFailFs{Fs: afero.NewMemMapFs(), target: target}
|
||||
writeSkipErrorTestFile(t, fs, target, "content that cannot be read")
|
||||
|
||||
_, err := runSkipErrorScan(t, fs, false)
|
||||
if err == nil {
|
||||
t.Fatal("expected scan to fail on the read error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestScannerReadErrorSkippedWithSkipErrors checks that a file read error is
|
||||
// skipped and the run completes when --skip-errors is set.
|
||||
func TestScannerReadErrorSkippedWithSkipErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const target = "/source/unreadable.txt"
|
||||
|
||||
fs := &readFailFs{Fs: afero.NewMemMapFs(), target: target}
|
||||
writeSkipErrorTestFile(t, fs, target, "content that cannot be read")
|
||||
|
||||
repos, err := runSkipErrorScan(t, fs, true)
|
||||
if err != nil {
|
||||
t.Fatalf("expected scan to complete with --skip-errors, got %v", err)
|
||||
}
|
||||
|
||||
chunks, err := repos.FileChunks.GetByFile(context.Background(), target)
|
||||
if err != nil {
|
||||
t.Fatalf("getting file chunks: %v", err)
|
||||
}
|
||||
|
||||
if len(chunks) != 0 {
|
||||
t.Fatalf("expected unreadable file skipped, got %d chunks", len(chunks))
|
||||
}
|
||||
}
|
||||
+132
-90
@@ -24,7 +24,7 @@ package snapshot
|
||||
// 7. Close the temporary database
|
||||
// 8. VACUUM the database to remove deleted data and compact (security critical)
|
||||
// 9. Compress the binary database with zstd
|
||||
// 10. Encrypt the compressed database with age (always; recipients are required)
|
||||
// 10. Encrypt the compressed database with age (if encryption is enabled)
|
||||
// 11. Upload to S3 as: metadata/{snapshot-id}/db.zst.age
|
||||
// 12. Reopen the main database
|
||||
//
|
||||
@@ -44,7 +44,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -201,14 +200,11 @@ func (sm *SnapshotManager) UpdateSnapshotStatsExtended(
|
||||
})
|
||||
}
|
||||
|
||||
// PopulateSnapshotBlobs ensures snapshot_blobs holds an entry for every
|
||||
// blob that stores a chunk referenced by the snapshot's files, including
|
||||
// blobs deduplicated from earlier snapshots. Without it, a fully
|
||||
// deduplicated snapshot would record no blobs and be unrestorable.
|
||||
//
|
||||
// This must run before ExportSnapshotMetadata: the blob manifest and the
|
||||
// trimmed metadata database are both built from snapshot_blobs.
|
||||
func (sm *SnapshotManager) PopulateSnapshotBlobs(
|
||||
// CompleteSnapshot marks a snapshot as completed and ensures snapshot_blobs
|
||||
// is populated with every blob holding any chunk referenced by the
|
||||
// snapshot's files (including deduplicated blobs uploaded by prior
|
||||
// snapshots). Without this, fully-deduplicated snapshots are unrestorable.
|
||||
func (sm *SnapshotManager) CompleteSnapshot(
|
||||
ctx context.Context, snapshotID string,
|
||||
) error {
|
||||
err := sm.repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
||||
@@ -222,25 +218,6 @@ func (sm *SnapshotManager) PopulateSnapshotBlobs(
|
||||
"snapshot_id", snapshotID, "added", added)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("populating snapshot blobs: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkSnapshotComplete records the snapshot's completion timestamp. On the
|
||||
// backup path this runs only after ExportSnapshotMetadata has succeeded, so
|
||||
// the local index never marks a snapshot complete while the destination
|
||||
// holds no manifest or database for it. A crash before this point leaves the
|
||||
// snapshot incomplete, and the next run's PruneDatabase drops it. See
|
||||
// https://git.eeqj.de/sneak/vaultik/issues/177.
|
||||
func (sm *SnapshotManager) MarkSnapshotComplete(
|
||||
ctx context.Context, snapshotID string,
|
||||
) error {
|
||||
err := sm.repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
||||
return sm.repos.Snapshots.MarkComplete(ctx, tx, snapshotID)
|
||||
})
|
||||
if err != nil {
|
||||
@@ -252,22 +229,6 @@ func (sm *SnapshotManager) MarkSnapshotComplete(
|
||||
return nil
|
||||
}
|
||||
|
||||
// CompleteSnapshot populates snapshot_blobs and then marks the snapshot
|
||||
// complete. The backup path (finalizeSnapshotMetadata) instead calls the two
|
||||
// halves separately, with the metadata export between them, so completion is
|
||||
// recorded only after a successful export. This convenience is for callers
|
||||
// that do not interleave an export.
|
||||
func (sm *SnapshotManager) CompleteSnapshot(
|
||||
ctx context.Context, snapshotID string,
|
||||
) error {
|
||||
err := sm.PopulateSnapshotBlobs(ctx, snapshotID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return sm.MarkSnapshotComplete(ctx, snapshotID)
|
||||
}
|
||||
|
||||
// ExportSnapshotMetadata exports snapshot metadata to S3
|
||||
//
|
||||
// This method executes the complete snapshot metadata export process:
|
||||
@@ -276,12 +237,14 @@ func (sm *SnapshotManager) CompleteSnapshot(
|
||||
// 3. Cleans the copy to contain only current snapshot data
|
||||
// 4. Dumps the cleaned database to SQL
|
||||
// 5. Compresses the SQL dump with zstd
|
||||
// 6. Encrypts the compressed data with age (always; recipients are required)
|
||||
// 6. Encrypts the compressed data (if encryption is enabled)
|
||||
// 7. Uploads to S3 at: snapshots/{snapshot-id}.sql.zst[.age]
|
||||
//
|
||||
// The only caller (finalizeSnapshotMetadata) does not close the main database
|
||||
// before calling this method: the index is copied at dbPath while it is still
|
||||
// open, and every step here operates on that copy, never on the live index.
|
||||
// The caller is responsible for:
|
||||
// - Ensuring the main database is closed before calling this method
|
||||
// - Reopening the main database after this method returns
|
||||
//
|
||||
// This ensures database consistency during the copy operation.
|
||||
func (sm *SnapshotManager) ExportSnapshotMetadata(
|
||||
ctx context.Context, dbPath string, snapshotID string,
|
||||
) error {
|
||||
@@ -331,6 +294,68 @@ func (sm *SnapshotManager) ExportSnapshotMetadata(
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupIncompleteSnapshots removes incomplete snapshots that don't have
|
||||
// metadata in S3. This is critical for data safety: incomplete snapshots
|
||||
// can cause deduplication to skip files that were never successfully
|
||||
// backed up, resulting in data loss.
|
||||
func (sm *SnapshotManager) CleanupIncompleteSnapshots(
|
||||
ctx context.Context, hostname string,
|
||||
) error {
|
||||
log.Info("Checking for incomplete snapshots", "hostname", hostname)
|
||||
|
||||
// Get all incomplete snapshots for this hostname
|
||||
incompleteSnapshots, err := sm.repos.Snapshots.GetIncompleteByHostname(ctx, hostname)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting incomplete snapshots: %w", err)
|
||||
}
|
||||
|
||||
if len(incompleteSnapshots) == 0 {
|
||||
log.Debug("No incomplete snapshots found")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Info("Found incomplete snapshots", "count", len(incompleteSnapshots))
|
||||
|
||||
// Check each incomplete snapshot for metadata in storage
|
||||
for _, snapshot := range incompleteSnapshots {
|
||||
// Check if metadata exists in storage (paths use the hashed
|
||||
// remote key so we don't leak host info to the listing).
|
||||
metadataKey := fmt.Sprintf("metadata/%s/db.zst",
|
||||
RemoteSnapshotKey(snapshot.ID.String()))
|
||||
|
||||
_, err := sm.storage.Stat(ctx, metadataKey)
|
||||
if err != nil {
|
||||
// Metadata doesn't exist in S3 - this is an incomplete snapshot
|
||||
log.Info("Cleaning up incomplete snapshot record",
|
||||
"snapshot_id", snapshot.ID, "started_at", snapshot.StartedAt)
|
||||
|
||||
// Delete the snapshot and all its associations
|
||||
err := sm.deleteSnapshot(ctx, snapshot.ID.String())
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting incomplete snapshot %s: %w",
|
||||
snapshot.ID, err)
|
||||
}
|
||||
|
||||
log.Info("Deleted incomplete snapshot record and associated data",
|
||||
"snapshot_id", snapshot.ID)
|
||||
} else {
|
||||
// Metadata exists - this snapshot was completed but database wasn't updated
|
||||
// This shouldn't happen in normal operation, but mark it complete
|
||||
log.Warn("Found snapshot with remote metadata but incomplete in database",
|
||||
"snapshot_id", snapshot.ID)
|
||||
|
||||
err := sm.repos.Snapshots.MarkComplete(ctx, nil, snapshot.ID.String())
|
||||
if err != nil {
|
||||
log.Error("Failed to mark snapshot as complete in database",
|
||||
"snapshot_id", snapshot.ID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupOrphanedData removes files, chunks, and blobs that are no longer
|
||||
// referenced by any snapshot. This should be called periodically to clean
|
||||
// up data from deleted or incomplete snapshots.
|
||||
@@ -451,11 +476,9 @@ func (sm *SnapshotManager) prepareExportDB(
|
||||
// uploadSnapshotArtifacts uploads the database backup and blob manifest
|
||||
// to remote storage at metadata/<remote-key>/, where remote-key is the
|
||||
// double-SHA256 derivation of the snapshot ID (see RemoteSnapshotKey).
|
||||
// The human-readable snapshot ID is never written into an unencrypted part
|
||||
// of remote storage, so a plain listing shows only the hashed key, not the
|
||||
// hostname or snapshot name. The hash uses no secret, so a guessed hostname
|
||||
// and snapshot name can still be confirmed against a listing, and the backup
|
||||
// time is public: the manifest carries a plaintext timestamp.
|
||||
// We never write the human-readable snapshot ID into any unencrypted
|
||||
// part of remote storage so a listing of the destination bucket leaks
|
||||
// no host, configuration, or scheduling information.
|
||||
func (sm *SnapshotManager) uploadSnapshotArtifacts(
|
||||
ctx context.Context, snapshotID string, dbData, manifestData []byte,
|
||||
) error {
|
||||
@@ -735,18 +758,12 @@ func (sm *SnapshotManager) compressFile(inputPath, outputPath string) error {
|
||||
|
||||
writerClosed = true
|
||||
|
||||
log.Debug("Compression complete", "hash", hex.EncodeToString(writer.ContentID()))
|
||||
log.Debug("Compression complete", "hash", hex.EncodeToString(writer.Sum256()))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// exportCopyPerm restricts the exported snapshot database copy to the owning
|
||||
// user; it holds the same private index data as the local index file.
|
||||
const exportCopyPerm = 0o600
|
||||
|
||||
// copyFile copies a file from src to dst. The destination is the exported
|
||||
// snapshot database, so it is created owner-only rather than with the
|
||||
// umask-dependent default.
|
||||
// copyFile copies a file from src to dst
|
||||
func (sm *SnapshotManager) copyFile(src, dst string) error {
|
||||
log.Debug("Opening source file for copy", "path", src)
|
||||
|
||||
@@ -766,9 +783,7 @@ func (sm *SnapshotManager) copyFile(src, dst string) error {
|
||||
|
||||
log.Debug("Creating destination file", "path", dst)
|
||||
|
||||
destFile, err := sm.fs.OpenFile(
|
||||
dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, exportCopyPerm,
|
||||
)
|
||||
destFile, err := sm.fs.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -794,11 +809,6 @@ func (sm *SnapshotManager) copyFile(src, dst string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// errBlobMissingFromDatabase means a snapshot references a blob that is
|
||||
// absent from the blobs table, so a complete manifest cannot be built.
|
||||
var errBlobMissingFromDatabase = errors.New(
|
||||
"blob referenced by snapshot is not in the database")
|
||||
|
||||
// generateBlobManifest creates a compressed JSON list of all blobs in the snapshot
|
||||
func (sm *SnapshotManager) generateBlobManifest(
|
||||
ctx context.Context, dbPath string, snapshotID string,
|
||||
@@ -829,34 +839,27 @@ func (sm *SnapshotManager) generateBlobManifest(
|
||||
totalCompressedSize := int64(0)
|
||||
|
||||
for _, hash := range blobHashes {
|
||||
// Every blob the snapshot references must appear in the manifest.
|
||||
// Prune consults only the manifest to decide what is still in use,
|
||||
// so silently dropping a blob here would let a later prune delete
|
||||
// it while this snapshot still needs it. A lookup failure or a
|
||||
// missing blob row therefore fails manifest generation.
|
||||
blob, err := repos.Blobs.GetByHash(ctx, hash)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting blob details for %s: %w", hash, err)
|
||||
log.Warn("Failed to get blob details", "hash", hash, "error", err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if blob == nil {
|
||||
return nil, fmt.Errorf("%w: blob %s, snapshot %s",
|
||||
errBlobMissingFromDatabase, hash, snapshotID)
|
||||
if blob != nil {
|
||||
blobs = append(blobs, BlobInfo{
|
||||
Hash: hash,
|
||||
CompressedSize: blob.CompressedSize,
|
||||
})
|
||||
totalCompressedSize += blob.CompressedSize
|
||||
}
|
||||
|
||||
blobs = append(blobs, BlobInfo{
|
||||
Hash: hash,
|
||||
CompressedSize: blob.CompressedSize,
|
||||
})
|
||||
totalCompressedSize += blob.CompressedSize
|
||||
}
|
||||
|
||||
// Create manifest. SnapshotID in the unencrypted manifest is the
|
||||
// double-SHA256 remote key (see RemoteSnapshotKey), not the human ID, so
|
||||
// neither this field nor the directory name spells out the hostname or
|
||||
// snapshot name — but the key uses no secret, so a guessed hostname and
|
||||
// snapshot name can be confirmed. Timestamp below is written in the clear,
|
||||
// so the backup time is observable to anyone who can read the manifest.
|
||||
// double-SHA256 remote key (see RemoteSnapshotKey), not the human ID,
|
||||
// so neither this field nor the directory name reveals the hostname or
|
||||
// snapshot name. Timestamp below is written in the clear, so the backup
|
||||
// time is observable to anyone who can read the manifest.
|
||||
manifest := &Manifest{
|
||||
SnapshotID: RemoteSnapshotKey(snapshotID),
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
@@ -910,6 +913,45 @@ type ExtendedBackupStats struct {
|
||||
UploadDurationMs int64 // Total milliseconds spent uploading to S3
|
||||
}
|
||||
|
||||
// deleteSnapshot removes a snapshot and all its associations from the database
|
||||
func (sm *SnapshotManager) deleteSnapshot(
|
||||
ctx context.Context, snapshotID string,
|
||||
) error {
|
||||
// Delete snapshot_files entries
|
||||
err := sm.repos.Snapshots.DeleteSnapshotFiles(ctx, snapshotID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting snapshot files: %w", err)
|
||||
}
|
||||
|
||||
// Delete snapshot_blobs entries
|
||||
err = sm.repos.Snapshots.DeleteSnapshotBlobs(ctx, snapshotID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting snapshot blobs: %w", err)
|
||||
}
|
||||
|
||||
// Delete uploads entries (has foreign key to snapshots without CASCADE)
|
||||
err = sm.repos.Snapshots.DeleteSnapshotUploads(ctx, snapshotID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting snapshot uploads: %w", err)
|
||||
}
|
||||
|
||||
// Delete the snapshot itself
|
||||
err = sm.repos.Snapshots.Delete(ctx, snapshotID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting snapshot: %w", err)
|
||||
}
|
||||
|
||||
// Clean up orphaned data
|
||||
log.Debug("Cleaning up orphaned records in main database")
|
||||
|
||||
err = sm.CleanupOrphanedData(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cleaning up orphaned data: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteOtherSnapshots deletes all snapshots except the current one
|
||||
func (sm *SnapshotManager) deleteOtherSnapshots(
|
||||
ctx context.Context, tx *sql.Tx, currentSnapshotID string,
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
// Package faultstore provides a storage.Storer wrapper that injects
|
||||
// faults on demand, so tests can reproduce the failure modes a real
|
||||
// backend exhibits: an upload that fails partway, a backend that reports
|
||||
// success while storing nothing, and reads that return corrupt or
|
||||
// truncated bytes. It is the seam called for by the fault-injection
|
||||
// tests (sneak/vaultik issue 72) and is meant to be reused by future
|
||||
// tests rather than re-implemented per case.
|
||||
//
|
||||
// The wrapper delegates every method to the inner Storer. Two hooks
|
||||
// change that: OnPut decides the fate of each write, and OnGet decides
|
||||
// how each read's bytes are returned. Both are keyed by the object key,
|
||||
// so a test can fault only blobs, only metadata, or a single object.
|
||||
package faultstore
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
)
|
||||
|
||||
// ErrInjectedUpload is returned by a Put the OnPut hook chose to fail.
|
||||
var ErrInjectedUpload = errors.New("faultstore: injected upload failure")
|
||||
|
||||
// PutAction is the disposition OnPut assigns to a write.
|
||||
type PutAction int
|
||||
|
||||
const (
|
||||
// PutNormal writes through to the inner Storer.
|
||||
PutNormal PutAction = iota
|
||||
// PutFail reads part of the stream, then fails without storing the
|
||||
// object — a network upload that dies partway through.
|
||||
PutFail
|
||||
// PutSwallow reports success but stores nothing — a backend that
|
||||
// lies about durability.
|
||||
PutSwallow
|
||||
)
|
||||
|
||||
// GetFault is how OnGet chooses to damage a read.
|
||||
type GetFault int
|
||||
|
||||
const (
|
||||
// GetNormal returns the stored bytes unchanged.
|
||||
GetNormal GetFault = iota
|
||||
// GetCorrupt flips a byte so the returned object no longer matches
|
||||
// what was stored.
|
||||
GetCorrupt
|
||||
// GetTruncate returns a short read: the object's bytes cut off
|
||||
// before the end.
|
||||
GetTruncate
|
||||
)
|
||||
|
||||
// Storer wraps an inner storage.Storer with fault-injection hooks. A
|
||||
// zero-valued hook means "no fault": construct with New and set only the
|
||||
// hook a test needs.
|
||||
type Storer struct {
|
||||
inner storage.Storer
|
||||
|
||||
// OnPut, when set, is consulted before every Put and
|
||||
// PutWithProgress with the object key.
|
||||
OnPut func(key string) PutAction
|
||||
|
||||
// OnGet, when set, is consulted for every Get with the object key
|
||||
// and damages the returned bytes accordingly.
|
||||
OnGet func(key string) GetFault
|
||||
}
|
||||
|
||||
// New wraps inner. inner must be non-nil.
|
||||
func New(inner storage.Storer) *Storer {
|
||||
return &Storer{inner: inner}
|
||||
}
|
||||
|
||||
// midStreamBytes is how far a PutFail reads before failing, enough to be
|
||||
// past the start of any real blob without depending on the blob's size.
|
||||
const midStreamBytes = 512
|
||||
|
||||
// Put stores data unless OnPut faults the write.
|
||||
func (f *Storer) Put(ctx context.Context, key string, data io.Reader) error {
|
||||
handled, err := f.injectPut(key, data)
|
||||
if handled {
|
||||
return err
|
||||
}
|
||||
|
||||
return f.inner.Put(ctx, key, data)
|
||||
}
|
||||
|
||||
// PutWithProgress stores data unless OnPut faults the write.
|
||||
func (f *Storer) PutWithProgress(
|
||||
ctx context.Context, key string, data io.Reader,
|
||||
size int64, progress storage.ProgressCallback,
|
||||
) error {
|
||||
handled, err := f.injectPut(key, data)
|
||||
if handled {
|
||||
return err
|
||||
}
|
||||
|
||||
return f.inner.PutWithProgress(ctx, key, data, size, progress)
|
||||
}
|
||||
|
||||
// Get retrieves data, damaging it if OnGet faults the read.
|
||||
func (f *Storer) Get(ctx context.Context, key string) (io.ReadCloser, error) {
|
||||
rc, err := f.inner.Get(ctx, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fault := GetNormal
|
||||
if f.OnGet != nil {
|
||||
fault = f.OnGet(key)
|
||||
}
|
||||
|
||||
if fault == GetNormal {
|
||||
return rc, nil
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(rc)
|
||||
_ = rc.Close()
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return io.NopCloser(bytes.NewReader(damage(fault, data))), nil
|
||||
}
|
||||
|
||||
// damage returns a faulted copy of the stored bytes. GetCorrupt flips a
|
||||
// byte in the middle so decryption authentication fails; GetTruncate
|
||||
// drops the final byte so the read ends short. Both are no-ops on empty
|
||||
// input, which cannot be damaged into something distinguishable.
|
||||
func damage(fault GetFault, data []byte) []byte {
|
||||
out := make([]byte, len(data))
|
||||
copy(out, data)
|
||||
|
||||
if len(out) == 0 {
|
||||
return out
|
||||
}
|
||||
|
||||
switch fault {
|
||||
case GetCorrupt:
|
||||
out[len(out)/2] ^= 0xff
|
||||
case GetTruncate:
|
||||
out = out[:len(out)-1]
|
||||
case GetNormal:
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// Stat delegates unchanged.
|
||||
func (f *Storer) Stat(ctx context.Context, key string) (*storage.ObjectInfo, error) {
|
||||
return f.inner.Stat(ctx, key)
|
||||
}
|
||||
|
||||
// Delete delegates unchanged.
|
||||
func (f *Storer) Delete(ctx context.Context, key string) error {
|
||||
return f.inner.Delete(ctx, key)
|
||||
}
|
||||
|
||||
// List delegates unchanged.
|
||||
func (f *Storer) List(ctx context.Context, prefix string) ([]string, error) {
|
||||
return f.inner.List(ctx, prefix)
|
||||
}
|
||||
|
||||
// ListStream delegates unchanged.
|
||||
func (f *Storer) ListStream(
|
||||
ctx context.Context, prefix string,
|
||||
) <-chan storage.ObjectInfo {
|
||||
return f.inner.ListStream(ctx, prefix)
|
||||
}
|
||||
|
||||
// Info delegates unchanged.
|
||||
func (f *Storer) Info() storage.Info {
|
||||
return f.inner.Info()
|
||||
}
|
||||
|
||||
func (f *Storer) putAction(key string) PutAction {
|
||||
if f.OnPut == nil {
|
||||
return PutNormal
|
||||
}
|
||||
|
||||
return f.OnPut(key)
|
||||
}
|
||||
|
||||
// injectPut handles the non-normal write dispositions. It reports
|
||||
// whether it handled the write and, if so, with what error.
|
||||
func (f *Storer) injectPut(key string, data io.Reader) (bool, error) {
|
||||
switch f.putAction(key) {
|
||||
case PutFail:
|
||||
// Consume part of the stream so the failure lands mid-transfer,
|
||||
// the way a dropped connection would, then error without
|
||||
// storing anything.
|
||||
_, _ = io.CopyN(io.Discard, data, midStreamBytes)
|
||||
|
||||
return true, fmt.Errorf("%w for %q", ErrInjectedUpload, key)
|
||||
case PutSwallow:
|
||||
// A lying backend still drains the request body, then keeps
|
||||
// nothing.
|
||||
_, _ = io.Copy(io.Discard, data)
|
||||
|
||||
return true, nil
|
||||
case PutNormal:
|
||||
return false, nil
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
@@ -111,11 +111,10 @@ func storerFromParsedS3URL(parsed *URL, cfg *config.Config) (Storer, error) {
|
||||
func storerFromLegacyS3Config(cfg *config.Config) (Storer, error) {
|
||||
endpoint := cfg.S3.Endpoint
|
||||
|
||||
// Ensure protocol is present. Absent an explicit use_ssl, default to TLS;
|
||||
// plain HTTP only when use_ssl is written as false.
|
||||
// Ensure protocol is present
|
||||
if !strings.HasPrefix(endpoint, "http://") &&
|
||||
!strings.HasPrefix(endpoint, "https://") {
|
||||
if cfg.S3.UseSSL == nil || *cfg.S3.UseSSL {
|
||||
if cfg.S3.UseSSL {
|
||||
endpoint = "https://" + endpoint
|
||||
} else {
|
||||
endpoint = "http://" + endpoint
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
package storage_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/vaultik/internal/config"
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
)
|
||||
|
||||
// legacyS3Config returns a minimal s3.* (no storage_url) configuration with a
|
||||
// scheme-less endpoint. useSSL mirrors the config file: nil means the key is
|
||||
// omitted, a pointer means it was written explicitly.
|
||||
func legacyS3Config(useSSL *bool) *config.Config {
|
||||
return &config.Config{
|
||||
S3: config.S3Config{
|
||||
Endpoint: "s3.example.com",
|
||||
Bucket: "bucket",
|
||||
AccessKeyID: "key",
|
||||
SecretAccessKey: "secret",
|
||||
Region: "us-east-1",
|
||||
UseSSL: useSSL,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// endpointScheme builds the storer from cfg and returns the scheme its
|
||||
// resolved endpoint carries (Info().Location is "endpoint/bucket").
|
||||
func endpointScheme(t *testing.T, cfg *config.Config) string {
|
||||
t.Helper()
|
||||
|
||||
storer, err := storage.NewStorer(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStorer: %v", err)
|
||||
}
|
||||
|
||||
location := storer.Info().Location
|
||||
switch {
|
||||
case strings.HasPrefix(location, "https://"):
|
||||
return "https"
|
||||
case strings.HasPrefix(location, "http://"):
|
||||
return "http"
|
||||
default:
|
||||
t.Fatalf("endpoint has no http(s) scheme: %q", location)
|
||||
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyS3SchemelessEndpointDefaultsToTLS(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := endpointScheme(t, legacyS3Config(nil)); got != "https" {
|
||||
t.Errorf("use_ssl omitted: got %q scheme, want https", got)
|
||||
}
|
||||
|
||||
no := false
|
||||
if got := endpointScheme(t, legacyS3Config(&no)); got != "http" {
|
||||
t.Errorf("use_ssl: false: got %q scheme, want http", got)
|
||||
}
|
||||
}
|
||||
+46
-101
@@ -4,7 +4,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -24,10 +23,6 @@ var (
|
||||
ErrUnsupportedScheme = errors.New(
|
||||
"unsupported URL scheme: must start with s3://, file://, or rclone://")
|
||||
ErrUnsupportedStorage = errors.New("unsupported storage scheme")
|
||||
ErrURLCredentials = errors.New(
|
||||
"storage URL must not carry credentials; " +
|
||||
"set s3.access_key_id and s3.secret_access_key in the config instead")
|
||||
ErrURLUnknownParam = errors.New("unknown query parameter in storage URL")
|
||||
)
|
||||
|
||||
// URL represents a parsed storage URL.
|
||||
@@ -64,111 +59,61 @@ func ParseStorageURL(rawURL string) (*URL, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Handle s3:// URLs
|
||||
if strings.HasPrefix(rawURL, "s3://") {
|
||||
return parseS3URL(rawURL)
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
bucket := u.Host
|
||||
if bucket == "" {
|
||||
return nil, ErrMissingBucket
|
||||
}
|
||||
|
||||
prefix := strings.TrimPrefix(u.Path, "/")
|
||||
|
||||
query := u.Query()
|
||||
|
||||
useSSL := true
|
||||
if query.Get("ssl") == "false" {
|
||||
useSSL = false
|
||||
}
|
||||
|
||||
return &URL{
|
||||
Scheme: schemeS3,
|
||||
Bucket: bucket,
|
||||
Prefix: prefix,
|
||||
Endpoint: query.Get("endpoint"),
|
||||
Region: query.Get("region"),
|
||||
UseSSL: useSSL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Handle rclone:// URLs
|
||||
if strings.HasPrefix(rawURL, "rclone://") {
|
||||
return parseRcloneURL(rawURL)
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
remote := u.Host
|
||||
if remote == "" {
|
||||
return nil, ErrMissingRemote
|
||||
}
|
||||
|
||||
path := strings.TrimPrefix(u.Path, "/")
|
||||
|
||||
return &URL{
|
||||
Scheme: schemeRclone,
|
||||
Prefix: path,
|
||||
RcloneRemote: remote,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, ErrUnsupportedScheme
|
||||
}
|
||||
|
||||
// parseS3URL parses an s3://bucket/prefix URL. It rejects credentials in
|
||||
// the userinfo and any query parameter other than endpoint, region and
|
||||
// ssl, so a credential-bearing URL is never stored or echoed.
|
||||
func parseS3URL(rawURL string) (*URL, error) {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, wrapParseError(err)
|
||||
}
|
||||
|
||||
if u.User != nil {
|
||||
return nil, ErrURLCredentials
|
||||
}
|
||||
|
||||
bucket := u.Host
|
||||
if bucket == "" {
|
||||
return nil, ErrMissingBucket
|
||||
}
|
||||
|
||||
query := u.Query()
|
||||
|
||||
err = rejectUnknownParams(query, "endpoint", "region", "ssl")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &URL{
|
||||
Scheme: schemeS3,
|
||||
Bucket: bucket,
|
||||
Prefix: strings.TrimPrefix(u.Path, "/"),
|
||||
Endpoint: query.Get("endpoint"),
|
||||
Region: query.Get("region"),
|
||||
UseSSL: query.Get("ssl") != "false",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseRcloneURL parses an rclone://remote/path URL. rclone:// takes no
|
||||
// query parameters, so credentials in the userinfo and any parameter at
|
||||
// all are rejected rather than silently ignored.
|
||||
func parseRcloneURL(rawURL string) (*URL, error) {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, wrapParseError(err)
|
||||
}
|
||||
|
||||
if u.User != nil {
|
||||
return nil, ErrURLCredentials
|
||||
}
|
||||
|
||||
remote := u.Host
|
||||
if remote == "" {
|
||||
return nil, ErrMissingRemote
|
||||
}
|
||||
|
||||
err = rejectUnknownParams(u.Query())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &URL{
|
||||
Scheme: schemeRclone,
|
||||
Prefix: strings.TrimPrefix(u.Path, "/"),
|
||||
RcloneRemote: remote,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// rejectUnknownParams returns an error naming the first query parameter
|
||||
// not in allowed. The parameter's name is included (so a misspelt
|
||||
// endpoint= is caught), but never its value, which could be a secret,
|
||||
// and never the whole URL.
|
||||
func rejectUnknownParams(query url.Values, allowed ...string) error {
|
||||
for name := range query {
|
||||
if !slices.Contains(allowed, name) {
|
||||
return fmt.Errorf(
|
||||
"%w: %q; put credentials in s3.access_key_id and "+
|
||||
"s3.secret_access_key, not the URL",
|
||||
ErrURLUnknownParam, name)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// wrapParseError wraps only the inner cause of a url.Parse failure. The
|
||||
// *url.Error that url.Parse returns embeds the raw URL in its message, so
|
||||
// wrapping it directly would echo a credential-bearing URL into logs.
|
||||
func wrapParseError(err error) error {
|
||||
var uerr *url.Error
|
||||
if errors.As(err, &uerr) {
|
||||
return fmt.Errorf("invalid URL: %w", uerr.Err)
|
||||
}
|
||||
|
||||
return fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
// String returns a human-readable representation of the storage URL.
|
||||
func (u *URL) String() string {
|
||||
switch u.Scheme {
|
||||
|
||||
@@ -3,7 +3,6 @@ package storage_test
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
@@ -109,100 +108,3 @@ func TestParseStorageURLErrors(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseStorageURLRejectsCredentials checks that a URL carrying
|
||||
// credentials in its userinfo or in an unknown query parameter is
|
||||
// rejected, and that the error never echoes the secret-bearing URL back
|
||||
// into logs or output.
|
||||
func TestParseStorageURLRejectsCredentials(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Split so the literals never form a "user:pass@" URL pattern that
|
||||
// tooling would flag as a real hardcoded credential.
|
||||
const (
|
||||
key = "AKIAKEY"
|
||||
secret = "topsecret"
|
||||
)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantErr error
|
||||
secrets []string // must not appear in the error message
|
||||
}{
|
||||
{
|
||||
name: "s3 userinfo",
|
||||
raw: "s3://" + key + ":" + secret + "@mybucket/prefix",
|
||||
wantErr: storage.ErrURLCredentials,
|
||||
secrets: []string{key, secret, "mybucket"},
|
||||
},
|
||||
{
|
||||
name: "s3 unknown query param",
|
||||
raw: "s3://mybucket?access_key=" + key + "&secret=" + secret,
|
||||
wantErr: storage.ErrURLUnknownParam,
|
||||
secrets: []string{key, secret},
|
||||
},
|
||||
{
|
||||
name: "s3 misspelt endpoint",
|
||||
raw: "s3://mybucket?endpiont=minio.example.com",
|
||||
wantErr: storage.ErrURLUnknownParam,
|
||||
secrets: nil,
|
||||
},
|
||||
{
|
||||
name: "rclone userinfo",
|
||||
raw: "rclone://user:" + secret + "@gdrive/backups",
|
||||
wantErr: storage.ErrURLCredentials,
|
||||
secrets: []string{secret},
|
||||
},
|
||||
{
|
||||
name: "rclone query param",
|
||||
raw: "rclone://gdrive/backups?token=" + secret,
|
||||
wantErr: storage.ErrURLUnknownParam,
|
||||
secrets: []string{secret},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := storage.ParseStorageURL(tc.raw)
|
||||
if !errors.Is(err, tc.wantErr) {
|
||||
t.Fatalf("ParseStorageURL(%q) error = %v, want %v",
|
||||
tc.raw, err, tc.wantErr)
|
||||
}
|
||||
|
||||
// The rejection must name the proper config keys so the
|
||||
// operator knows where credentials belong.
|
||||
for _, key := range []string{"s3.access_key_id", "s3.secret_access_key"} {
|
||||
if !strings.Contains(err.Error(), key) {
|
||||
t.Errorf("error %q does not name %q", err.Error(), key)
|
||||
}
|
||||
}
|
||||
|
||||
for _, secret := range tc.secrets {
|
||||
if strings.Contains(err.Error(), secret) {
|
||||
t.Errorf("error message leaked %q: %v", secret, err.Error())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseStorageURLParseFailureHidesURL checks that when url.Parse
|
||||
// itself fails, the wrapped error carries only the inner cause, not the
|
||||
// *url.Error whose text embeds the raw (possibly credential-bearing) URL.
|
||||
func TestParseStorageURLParseFailureHidesURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const raw = "s3://mybucket/%zz"
|
||||
|
||||
_, err := storage.ParseStorageURL(raw)
|
||||
if err == nil {
|
||||
t.Fatalf("ParseStorageURL(%q) returned no error", raw)
|
||||
}
|
||||
|
||||
if strings.Contains(err.Error(), "mybucket") {
|
||||
t.Errorf("error message echoed the raw URL: %v", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
+61
-18
@@ -1,7 +1,7 @@
|
||||
// Package types provides custom types for better type safety across the
|
||||
// vaultik codebase. Using distinct types for IDs, hashes, and paths prevents
|
||||
// accidental mixing of semantically different values that happen to share the
|
||||
// same underlying type.
|
||||
// vaultik codebase. Using distinct types for IDs, hashes, paths, and
|
||||
// credentials prevents accidental mixing of semantically different values
|
||||
// that happen to share the same underlying type.
|
||||
package types //nolint:revive,nolintlint // rename decision tracked in #76
|
||||
|
||||
import (
|
||||
@@ -146,10 +146,8 @@ type SnapshotID string
|
||||
// Used for content-addressing and deduplication of file chunks.
|
||||
type ChunkHash string
|
||||
|
||||
// BlobHash is hex(SHA256(SHA256(uncompressed blob contents))), computed before
|
||||
// compression and encryption (see blobgen.DoubleSHA256 and
|
||||
// docs/REPOSTRUCTURE.md). It is used as the filename in S3 storage for
|
||||
// content-addressed retrieval.
|
||||
// BlobHash is the SHA256 hash of a blob's compressed and encrypted content.
|
||||
// This is used as the filename in S3 storage for content-addressed retrieval.
|
||||
type BlobHash string
|
||||
|
||||
// FilePath represents an absolute path to a file or directory.
|
||||
@@ -159,6 +157,34 @@ type FilePath string
|
||||
// Used during restore to strip the source prefix from paths.
|
||||
type SourcePath string
|
||||
|
||||
// AgeRecipient is an age public key used for encryption.
|
||||
// Format: age1... (Bech32-encoded X25519 public key)
|
||||
type AgeRecipient string
|
||||
|
||||
// AgeSecretKey is an age private key used for decryption.
|
||||
// Format: AGE-SECRET-KEY-... (Bech32-encoded X25519 private key)
|
||||
// This type should never be logged or serialized in plaintext.
|
||||
type AgeSecretKey string
|
||||
|
||||
// S3Endpoint is the URL of an S3-compatible storage endpoint.
|
||||
type S3Endpoint string
|
||||
|
||||
// BucketName is the name of an S3 bucket.
|
||||
type BucketName string
|
||||
|
||||
// S3Prefix is the path prefix within an S3 bucket.
|
||||
type S3Prefix string
|
||||
|
||||
// AWSRegion is an AWS region identifier (e.g., "us-east-1").
|
||||
type AWSRegion string
|
||||
|
||||
// AWSAccessKeyID is an AWS access key ID for authentication.
|
||||
type AWSAccessKeyID string
|
||||
|
||||
// AWSSecretAccessKey is an AWS secret access key for authentication.
|
||||
// This type should never be logged or serialized in plaintext.
|
||||
type AWSSecretAccessKey string
|
||||
|
||||
// Hostname identifies a host machine.
|
||||
type Hostname string
|
||||
|
||||
@@ -173,14 +199,31 @@ type GlobPattern string
|
||||
|
||||
// String methods for Stringer interface
|
||||
|
||||
func (id FileID) String() string { return uuid.UUID(id).String() }
|
||||
func (id BlobID) String() string { return uuid.UUID(id).String() }
|
||||
func (id SnapshotID) String() string { return string(id) }
|
||||
func (h ChunkHash) String() string { return string(h) }
|
||||
func (h BlobHash) String() string { return string(h) }
|
||||
func (p FilePath) String() string { return string(p) }
|
||||
func (p SourcePath) String() string { return string(p) }
|
||||
func (h Hostname) String() string { return string(h) }
|
||||
func (v Version) String() string { return string(v) }
|
||||
func (r GitRevision) String() string { return string(r) }
|
||||
func (p GlobPattern) String() string { return string(p) }
|
||||
func (id FileID) String() string { return uuid.UUID(id).String() }
|
||||
func (id BlobID) String() string { return uuid.UUID(id).String() }
|
||||
func (id SnapshotID) String() string { return string(id) }
|
||||
func (h ChunkHash) String() string { return string(h) }
|
||||
func (h BlobHash) String() string { return string(h) }
|
||||
func (p FilePath) String() string { return string(p) }
|
||||
func (p SourcePath) String() string { return string(p) }
|
||||
func (r AgeRecipient) String() string { return string(r) }
|
||||
func (e S3Endpoint) String() string { return string(e) }
|
||||
func (b BucketName) String() string { return string(b) }
|
||||
func (p S3Prefix) String() string { return string(p) }
|
||||
func (r AWSRegion) String() string { return string(r) }
|
||||
func (k AWSAccessKeyID) String() string { return string(k) }
|
||||
func (h Hostname) String() string { return string(h) }
|
||||
func (v Version) String() string { return string(v) }
|
||||
func (r GitRevision) String() string { return string(r) }
|
||||
func (p GlobPattern) String() string { return string(p) }
|
||||
|
||||
// Redacted String methods for sensitive types - prevents accidental logging
|
||||
|
||||
func (k AgeSecretKey) String() string { return "[REDACTED]" }
|
||||
func (k AWSSecretAccessKey) String() string { return "[REDACTED]" }
|
||||
|
||||
// Raw returns the actual value for sensitive types when explicitly needed.
|
||||
func (k AgeSecretKey) Raw() string { return string(k) }
|
||||
|
||||
// Raw returns the actual value for sensitive types when explicitly needed.
|
||||
func (k AWSSecretAccessKey) Raw() string { return string(k) }
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
package types_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/types"
|
||||
)
|
||||
|
||||
// scannableID is the shared behaviour of the UUID-backed id types. A pointer
|
||||
// to FileID or BlobID satisfies it, so both are tested through one set of
|
||||
// cases.
|
||||
type scannableID interface {
|
||||
driver.Valuer
|
||||
sql.Scanner
|
||||
fmt.Stringer
|
||||
IsZero() bool
|
||||
}
|
||||
|
||||
// idKind adapts one id type to the generic tests below.
|
||||
type idKind struct {
|
||||
name string
|
||||
newZero func() scannableID
|
||||
newRandom func() scannableID
|
||||
parse func(string) (scannableID, error)
|
||||
}
|
||||
|
||||
func idKinds() []idKind {
|
||||
return []idKind{
|
||||
{
|
||||
name: "FileID",
|
||||
newZero: func() scannableID { return &types.FileID{} },
|
||||
newRandom: func() scannableID {
|
||||
id := types.NewFileID()
|
||||
|
||||
return &id
|
||||
},
|
||||
parse: func(s string) (scannableID, error) {
|
||||
id, err := types.ParseFileID(s)
|
||||
|
||||
return &id, err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "BlobID",
|
||||
newZero: func() scannableID { return &types.BlobID{} },
|
||||
newRandom: func() scannableID {
|
||||
id := types.NewBlobID()
|
||||
|
||||
return &id
|
||||
},
|
||||
parse: func(s string) (scannableID, error) {
|
||||
id, err := types.ParseBlobID(s)
|
||||
|
||||
return &id, err
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestIDValueScan checks that Value then Scan round trips from both a string
|
||||
// and a []byte, that a NULL scans to the zero id, and that a non-string type
|
||||
// and malformed text are rejected.
|
||||
func TestIDValueScan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, k := range idKinds() {
|
||||
t.Run(k.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
orig := k.newRandom()
|
||||
v, err := orig.Value()
|
||||
require.NoError(t, err)
|
||||
|
||||
s, ok := v.(string)
|
||||
require.True(t, ok, "Value must yield a string")
|
||||
|
||||
fromString := k.newZero()
|
||||
require.NoError(t, fromString.Scan(s))
|
||||
assert.Equal(t, orig.String(), fromString.String())
|
||||
assert.False(t, fromString.IsZero())
|
||||
|
||||
fromBytes := k.newZero()
|
||||
require.NoError(t, fromBytes.Scan([]byte(s)))
|
||||
assert.Equal(t, orig.String(), fromBytes.String())
|
||||
|
||||
nulled := k.newRandom()
|
||||
require.NoError(t, nulled.Scan(nil))
|
||||
assert.True(t, nulled.IsZero(), "NULL scans to the zero id")
|
||||
|
||||
require.Error(t, k.newZero().Scan(42),
|
||||
"a non-string type must be rejected")
|
||||
assert.Error(t, k.newZero().Scan("not-a-uuid"),
|
||||
"malformed text must be rejected")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIDParse checks that the Parse function accepts a canonical id and
|
||||
// rejects malformed text.
|
||||
func TestIDParse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, k := range idKinds() {
|
||||
t.Run(k.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
canonical := k.newRandom().String()
|
||||
|
||||
parsed, err := k.parse(canonical)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, canonical, parsed.String())
|
||||
|
||||
_, err = k.parse("not-a-uuid")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIDIsZero checks IsZero on the zero and on a freshly generated id.
|
||||
func TestIDIsZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, k := range idKinds() {
|
||||
t.Run(k.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.True(t, k.newZero().IsZero())
|
||||
assert.False(t, k.newRandom().IsZero())
|
||||
})
|
||||
}
|
||||
}
|
||||
+3
-22
@@ -23,9 +23,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
"golang.org/x/term"
|
||||
@@ -227,17 +225,17 @@ func (w *Writer) Hex(s string) string {
|
||||
short = s[:hexAbbrevLen] + "..."
|
||||
}
|
||||
|
||||
return w.paint(ansiCyan, sanitize(short))
|
||||
return w.paint(ansiCyan, short)
|
||||
}
|
||||
|
||||
// Snapshot colorizes a snapshot ID (full, no abbreviation).
|
||||
func (w *Writer) Snapshot(id string) string {
|
||||
return w.paint(ansiCyan+ansiBold, sanitize(id))
|
||||
return w.paint(ansiCyan+ansiBold, id)
|
||||
}
|
||||
|
||||
// Path colorizes a filesystem path.
|
||||
func (w *Writer) Path(p string) string {
|
||||
return w.paint(ansiBlue, sanitize(p))
|
||||
return w.paint(ansiBlue, p)
|
||||
}
|
||||
|
||||
// Size colorizes a byte count using humanize.Bytes.
|
||||
@@ -312,23 +310,6 @@ func (w *Writer) paint(color, s string) string {
|
||||
return color + s + ansiReset
|
||||
}
|
||||
|
||||
// sanitize returns s unchanged when every rune in it is printable, and a
|
||||
// double-quoted, backslash-escaped form (\n, \x1b, …) otherwise. The
|
||||
// string value formatters escape their argument through this before
|
||||
// painting: identifiers, paths and symlink targets they render come from
|
||||
// the snapshot database, which is not trusted, and escaping must happen
|
||||
// before colour is applied — the painted result already contains the
|
||||
// escape codes the raw text would otherwise be indistinguishable from.
|
||||
func sanitize(s string) string {
|
||||
for _, r := range s {
|
||||
if !unicode.IsPrint(r) {
|
||||
return strconv.Quote(s)
|
||||
}
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// emit writes "<prefix> <body>\n" with the prefix painted in prefixColor
|
||||
// and the body optionally painted in bodyColor (empty = no body color).
|
||||
func (w *Writer) emit(prefixColor, prefix, bodyColor, format string, args []any) {
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
package ui_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestValueFormattersEscapeControlCharacters checks that a path carrying an
|
||||
// ESC and a newline — the shape a symlink target read back from the
|
||||
// snapshot database could take — is escaped before it reaches the output.
|
||||
// Colour is off here, so the only way a control byte could appear is from
|
||||
// the value itself.
|
||||
func TestValueFormattersEscapeControlCharacters(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w, buf := newTestWriter(false)
|
||||
w.Infof("restoring %s", w.Path("a\x1b[31mZAP\nb"))
|
||||
|
||||
out := buf.String()
|
||||
|
||||
if strings.ContainsRune(out, '\x1b') {
|
||||
t.Fatalf("raw ESC from a value survived in output: %q", out)
|
||||
}
|
||||
|
||||
if strings.Count(out, "\n") != 1 {
|
||||
t.Fatalf("a newline in a value must be escaped, not emitted raw: %q", out)
|
||||
}
|
||||
|
||||
if !strings.Contains(out, `\x1b`) {
|
||||
t.Fatalf("expected the escaped form of ESC in output: %q", out)
|
||||
}
|
||||
}
|
||||
@@ -2,40 +2,35 @@ package vaultik
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
)
|
||||
|
||||
// errBlobHashMismatch is returned when a fetched blob's content hash does
|
||||
// not match the expected double-SHA-256 hash.
|
||||
var errBlobHashMismatch = errors.New("blob hash mismatch")
|
||||
|
||||
// errBlobNotFullyRead is returned when the verifying reader is closed
|
||||
// before its plaintext reached EOF. The hash can only be checked once
|
||||
// the whole stream has been read, so an early or short-read close must
|
||||
// fail rather than silently skip verification.
|
||||
var errBlobNotFullyRead = errors.New(
|
||||
"blob closed before fully read; hash not verified")
|
||||
|
||||
// hashVerifyReader wraps a blobgen.Reader and verifies the double-SHA-256 hash
|
||||
// of decrypted plaintext when Close is called. It reuses the hash that
|
||||
// blobgen.Reader already computes internally via its TeeReader, avoiding
|
||||
// redundant SHA-256 computation.
|
||||
type hashVerifyReader struct {
|
||||
reader *blobgen.Reader // underlying decrypted blob reader (has internal hasher)
|
||||
limited io.Reader // reader bounded to the blob's recorded plaintext size
|
||||
fetcher io.ReadCloser // raw fetched stream (closed on Close)
|
||||
blobHash string // expected double-SHA-256 hex
|
||||
done bool // EOF reached
|
||||
}
|
||||
|
||||
func (h *hashVerifyReader) Read(p []byte) (int, error) {
|
||||
n, err := h.limited.Read(p)
|
||||
n, err := h.reader.Read(p)
|
||||
if errors.Is(err, io.EOF) {
|
||||
h.done = true
|
||||
}
|
||||
@@ -43,22 +38,21 @@ func (h *hashVerifyReader) Read(p []byte) (int, error) {
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Close closes the underlying readers and verifies the blob hash. The
|
||||
// hash check cannot be skipped: closing before the plaintext reached
|
||||
// EOF (a short read or an early close) is an error, so a caller can
|
||||
// never obtain unverified blob bytes.
|
||||
// Close verifies the hash (if the stream was fully read) and closes underlying readers.
|
||||
func (h *hashVerifyReader) Close() error {
|
||||
readerErr := h.reader.Close()
|
||||
fetcherErr := h.fetcher.Close()
|
||||
|
||||
if !h.done {
|
||||
return errBlobNotFullyRead
|
||||
}
|
||||
if h.done {
|
||||
firstHash := h.reader.Sum256()
|
||||
secondHasher := sha256.New()
|
||||
secondHasher.Write(firstHash)
|
||||
|
||||
actualHashHex := hex.EncodeToString(blobgen.DoubleSHA256(h.reader.Sum256()))
|
||||
if actualHashHex != h.blobHash {
|
||||
return fmt.Errorf("%w: expected %s, got %s",
|
||||
errBlobHashMismatch, shortHash(h.blobHash), shortHash(actualHashHex))
|
||||
actualHashHex := hex.EncodeToString(secondHasher.Sum(nil))
|
||||
if actualHashHex != h.blobHash {
|
||||
return fmt.Errorf("%w: expected %s, got %s",
|
||||
errBlobHashMismatch, h.blobHash[:16], actualHashHex[:16])
|
||||
}
|
||||
}
|
||||
|
||||
if readerErr != nil {
|
||||
@@ -72,22 +66,15 @@ func (h *hashVerifyReader) Close() error {
|
||||
// returns a streaming reader that computes the double-SHA-256 hash on the fly.
|
||||
// The hash is verified when the returned reader is closed (after fully reading).
|
||||
// This avoids buffering the entire blob in memory.
|
||||
//
|
||||
// maxPlaintextSize is the blob's uncompressed_size as recorded in the
|
||||
// snapshot database. Decompression stops with blobgen.ErrOutputTooLarge
|
||||
// once the plaintext exceeds it, so a tampered blob cannot expand without
|
||||
// limit — using the recorded size, not the restoring host's
|
||||
// blob_size_limit, since that config may differ from the backup host's.
|
||||
func (v *Vaultik) FetchAndDecryptBlob(
|
||||
ctx context.Context, blobHash string, maxPlaintextSize int64,
|
||||
identities ...age.Identity,
|
||||
ctx context.Context, blobHash string, expectedSize int64, identity age.Identity,
|
||||
) (io.ReadCloser, error) {
|
||||
rc, err := v.FetchBlob(ctx, blobHash)
|
||||
rc, _, err := v.FetchBlob(ctx, blobHash, expectedSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reader, err := blobgen.NewReader(rc, identities...)
|
||||
reader, err := blobgen.NewReader(rc, identity)
|
||||
if err != nil {
|
||||
_ = rc.Close()
|
||||
|
||||
@@ -96,29 +83,45 @@ func (v *Vaultik) FetchAndDecryptBlob(
|
||||
|
||||
return &hashVerifyReader{
|
||||
reader: reader,
|
||||
limited: blobgen.LimitReader(reader, maxPlaintextSize),
|
||||
fetcher: rc,
|
||||
blobHash: blobHash,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// FetchBlob downloads a blob and returns a reader for the encrypted data.
|
||||
// Times the Storage.Get and Storage.Stat round-trips separately at
|
||||
// debug level so we can see whether the size-only Stat (which is an
|
||||
// extra request on every fetch) is hurting throughput.
|
||||
func (v *Vaultik) FetchBlob(
|
||||
ctx context.Context, blobHash string,
|
||||
) (io.ReadCloser, error) {
|
||||
// blobHash reaches here from the snapshot database, which is not
|
||||
// trusted. Reject a malformed hash before it is spliced into a storage
|
||||
// path (blobHash[:2]/blobHash[2:4]) or a fetch is attempted.
|
||||
if !isBlobHash(blobHash) {
|
||||
return nil, fmt.Errorf("%w: %s", errInvalidBlobHash, shortHash(blobHash))
|
||||
}
|
||||
|
||||
ctx context.Context, blobHash string, expectedSize int64,
|
||||
) (io.ReadCloser, int64, error) {
|
||||
blobPath := fmt.Sprintf("blobs/%s/%s/%s", blobHash[:2], blobHash[2:4], blobHash)
|
||||
|
||||
t0 := time.Now()
|
||||
rc, err := v.Storage.Get(ctx, blobPath)
|
||||
getDur := time.Since(t0)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("downloading blob %s: %w", shortHash(blobHash), err)
|
||||
return nil, 0, fmt.Errorf("downloading blob %s: %w", blobHash[:16], err)
|
||||
}
|
||||
|
||||
return rc, nil
|
||||
t0 = time.Now()
|
||||
info, err := v.Storage.Stat(ctx, blobPath)
|
||||
statDur := time.Since(t0)
|
||||
|
||||
if err != nil {
|
||||
_ = rc.Close()
|
||||
|
||||
return nil, 0, fmt.Errorf("stat blob %s: %w", blobHash[:16], err)
|
||||
}
|
||||
|
||||
log.Debug("FetchBlob round-trips",
|
||||
"hash", blobHash[:16],
|
||||
"ms_storage_get", getDur.Milliseconds(),
|
||||
"ms_storage_stat", statDur.Milliseconds(),
|
||||
"expected_size", expectedSize,
|
||||
"stat_size", info.Size,
|
||||
)
|
||||
|
||||
return rc, info.Size, nil
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
|
||||
// TestFetchAndDecryptBlobBoundsPlaintext feeds a small, highly
|
||||
// compressible blob (256 KiB of zeros) whose decompressed size far exceeds
|
||||
// the plaintext bound passed to FetchAndDecryptBlob. Decompression must
|
||||
// stop with blobgen.ErrOutputTooLarge within the bound rather than
|
||||
// expanding the whole blob into the restore cache.
|
||||
func TestFetchAndDecryptBlobBoundsPlaintext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
identity, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
|
||||
plaintext := make([]byte, 256*1024)
|
||||
encryptedData, correctHash := buildHashTestBlob(t, identity, plaintext)
|
||||
|
||||
mockStorage := NewMockStorer()
|
||||
blobPath := "blobs/" + correctHash[:2] + "/" +
|
||||
correctHash[2:4] + "/" + correctHash
|
||||
|
||||
mockStorage.mu.Lock()
|
||||
mockStorage.data[blobPath] = encryptedData
|
||||
mockStorage.mu.Unlock()
|
||||
|
||||
tv := vaultik.NewForTesting(mockStorage)
|
||||
|
||||
const maxPlaintext = 1024
|
||||
|
||||
rc, err := tv.FetchAndDecryptBlob(
|
||||
context.Background(), correctHash, maxPlaintext, identity)
|
||||
require.NoError(t, err)
|
||||
|
||||
n, copyErr := io.Copy(io.Discard, rc)
|
||||
_ = rc.Close()
|
||||
|
||||
require.ErrorIs(t, copyErr, blobgen.ErrOutputTooLarge)
|
||||
require.LessOrEqual(t, n, int64(maxPlaintext)+1,
|
||||
"decompression must stop within the recorded plaintext bound")
|
||||
}
|
||||
@@ -40,13 +40,13 @@ func buildHashTestBlob(
|
||||
}
|
||||
|
||||
// Compute the double-SHA-256 hash of the plaintext (matches
|
||||
// blobgen.Writer.ContentID).
|
||||
// blobgen.Writer.Sum256).
|
||||
firstHash := sha256.Sum256(plaintext)
|
||||
secondHash := sha256.Sum256(firstHash[:])
|
||||
correctHash := hex.EncodeToString(secondHash[:])
|
||||
|
||||
// Verify our hash matches what blobgen.Writer produces
|
||||
writerHash := hex.EncodeToString(writer.ContentID())
|
||||
writerHash := hex.EncodeToString(writer.Sum256())
|
||||
if correctHash != writerHash {
|
||||
t.Fatalf("hash computation mismatch: manual=%s, writer=%s",
|
||||
correctHash, writerHash)
|
||||
@@ -55,35 +55,6 @@ func buildHashTestBlob(
|
||||
return encBuf.Bytes(), correctHash
|
||||
}
|
||||
|
||||
// TestFetchBlobRejectsMalformedHash verifies FetchBlob refuses a blob hash
|
||||
// that is not 64 lowercase hex characters before it builds a storage path
|
||||
// or issues any request. The hash reaches FetchBlob from the snapshot
|
||||
// database, which is not trusted, so a value such as one containing "/.."
|
||||
// must never reach the store.
|
||||
func TestFetchBlobRejectsMalformedHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mockStorage := NewMockStorer()
|
||||
tv := vaultik.NewForTesting(mockStorage)
|
||||
ctx := context.Background()
|
||||
|
||||
for _, bad := range []string{
|
||||
"aa/../../../home/u/.profile",
|
||||
"abc",
|
||||
strings.Repeat("A", 64), // uppercase hex is not accepted
|
||||
strings.Repeat("g", 64), // not hex
|
||||
} {
|
||||
_, err := tv.FetchBlob(ctx, bad)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for malformed hash %q, got nil", bad)
|
||||
}
|
||||
}
|
||||
|
||||
if calls := mockStorage.GetCalls(); len(calls) != 0 {
|
||||
t.Fatalf("storage was accessed for a malformed hash: %v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchAndDecryptBlobVerifiesHash verifies that FetchAndDecryptBlob checks
|
||||
// the double-SHA-256 hash of the decrypted plaintext against the expected blob hash.
|
||||
func TestFetchAndDecryptBlobVerifiesHash(t *testing.T) {
|
||||
@@ -113,7 +84,7 @@ func TestFetchAndDecryptBlobVerifiesHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rc, err := tv.FetchAndDecryptBlob(
|
||||
ctx, correctHash, int64(len(plaintext)), identity)
|
||||
ctx, correctHash, int64(len(encryptedData)), identity)
|
||||
if err != nil {
|
||||
t.Fatalf("expected success, got error: %v", err)
|
||||
}
|
||||
@@ -145,7 +116,7 @@ func TestFetchAndDecryptBlobVerifiesHash(t *testing.T) {
|
||||
mockStorage.mu.Unlock()
|
||||
|
||||
rc, err := tv.FetchAndDecryptBlob(
|
||||
ctx, fakeHash, int64(len(plaintext)), identity)
|
||||
ctx, fakeHash, int64(len(encryptedData)), identity)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error opening stream: %v", err)
|
||||
}
|
||||
@@ -162,51 +133,3 @@ func TestFetchAndDecryptBlobVerifiesHash(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestFetchAndDecryptBlobCloseBeforeEOFFails verifies the hash check
|
||||
// cannot be skipped: a caller that reads only part of the blob and then
|
||||
// closes gets an error rather than silently unverified bytes.
|
||||
func TestFetchAndDecryptBlobCloseBeforeEOFFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
identity, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
t.Fatalf("generating identity: %v", err)
|
||||
}
|
||||
|
||||
plaintext := []byte("hello world test data for blob hash verification")
|
||||
encryptedData, correctHash := buildHashTestBlob(t, identity, plaintext)
|
||||
|
||||
mockStorage := NewMockStorer()
|
||||
blobPath := "blobs/" + correctHash[:2] + "/" +
|
||||
correctHash[2:4] + "/" + correctHash
|
||||
|
||||
mockStorage.mu.Lock()
|
||||
mockStorage.data[blobPath] = encryptedData
|
||||
mockStorage.mu.Unlock()
|
||||
|
||||
tv := vaultik.NewForTesting(mockStorage)
|
||||
|
||||
rc, err := tv.FetchAndDecryptBlob(
|
||||
context.Background(), correctHash, int64(len(plaintext)), identity)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error opening stream: %v", err)
|
||||
}
|
||||
|
||||
// Read one byte, far short of the plaintext length, then close.
|
||||
buf := make([]byte, 1)
|
||||
|
||||
_, err = rc.Read(buf)
|
||||
if err != nil {
|
||||
t.Fatalf("reading first byte: %v", err)
|
||||
}
|
||||
|
||||
err = rc.Close()
|
||||
if err == nil {
|
||||
t.Fatal("expected error closing before EOF, got nil")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "hash not verified") {
|
||||
t.Fatalf("expected not-verified error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,17 +6,13 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Sentinel errors for blob cache lookups.
|
||||
var (
|
||||
errCacheKeyMissing = errors.New("key not in cache")
|
||||
errCacheReadBeyondBlob = errors.New("read beyond blob size")
|
||||
errCacheKeyHasSeparator = errors.New(
|
||||
"cache key contains a path separator")
|
||||
errCacheNegativeRead = errors.New("negative offset or length")
|
||||
errCacheKeyMissing = errors.New("key not in cache")
|
||||
errCacheReadBeyondBlob = errors.New("read beyond blob size")
|
||||
)
|
||||
|
||||
// blobCacheFileMode is the permission mode for cached blob files.
|
||||
@@ -78,11 +74,6 @@ func newBlobDiskCache(maxBytes int64) (*blobDiskCache, error) {
|
||||
// Put writes blob data to disk cache. Entries larger than maxBytes are
|
||||
// silently skipped.
|
||||
func (c *blobDiskCache) Put(key string, data []byte) error {
|
||||
p, err := c.path(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entrySize := int64(len(data))
|
||||
|
||||
c.mu.Lock()
|
||||
@@ -96,12 +87,11 @@ func (c *blobDiskCache) Put(key string, data []byte) error {
|
||||
if e, ok := c.items[key]; ok {
|
||||
c.unlink(e)
|
||||
c.curBytes -= e.size
|
||||
_ = os.Remove(p)
|
||||
|
||||
_ = os.Remove(c.path(key))
|
||||
delete(c.items, key)
|
||||
}
|
||||
|
||||
err = os.WriteFile(p, data, blobCacheFileMode)
|
||||
err := os.WriteFile(c.path(key), data, blobCacheFileMode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("writing blob to cache: %w", err)
|
||||
}
|
||||
@@ -129,26 +119,19 @@ func (c *blobDiskCache) Put(key string, data []byte) error {
|
||||
// disk without buffering its entire plaintext (which may be tens of GB)
|
||||
// in RAM.
|
||||
func (c *blobDiskCache) PutFromReader(key string, r io.Reader) (int64, error) {
|
||||
p, err := c.path(key)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
// Remove any prior entry first; we'll re-link after the file is
|
||||
// written successfully.
|
||||
if e, ok := c.items[key]; ok {
|
||||
c.unlink(e)
|
||||
c.curBytes -= e.size
|
||||
_ = os.Remove(p)
|
||||
|
||||
_ = os.Remove(c.path(key))
|
||||
delete(c.items, key)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
//nolint:gosec // G304: path() rejects keys with a separator
|
||||
f, err := os.OpenFile(
|
||||
p, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, blobCacheFileMode)
|
||||
c.path(key), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, blobCacheFileMode)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("creating cache file: %w", err)
|
||||
}
|
||||
@@ -157,13 +140,13 @@ func (c *blobDiskCache) PutFromReader(key string, r io.Reader) (int64, error) {
|
||||
closeErr := f.Close()
|
||||
|
||||
if copyErr != nil {
|
||||
_ = os.Remove(p)
|
||||
_ = os.Remove(c.path(key))
|
||||
|
||||
return written, fmt.Errorf("streaming to cache file: %w", copyErr)
|
||||
}
|
||||
|
||||
if closeErr != nil {
|
||||
_ = os.Remove(p)
|
||||
_ = os.Remove(c.path(key))
|
||||
|
||||
return written, fmt.Errorf("closing cache file: %w", closeErr)
|
||||
}
|
||||
@@ -175,7 +158,7 @@ func (c *blobDiskCache) PutFromReader(key string, r io.Reader) (int64, error) {
|
||||
// floor — but the restore path passes math.MaxInt64 as maxBytes
|
||||
// so this branch is effectively unreachable there.
|
||||
if written > c.maxBytes {
|
||||
_ = os.Remove(p)
|
||||
_ = os.Remove(c.path(key))
|
||||
|
||||
return written, nil
|
||||
}
|
||||
@@ -198,11 +181,6 @@ func (c *blobDiskCache) PutFromReader(key string, r io.Reader) (int64, error) {
|
||||
|
||||
// Get reads a cached blob from disk. Returns data and true on hit.
|
||||
func (c *blobDiskCache) Get(key string) ([]byte, bool) {
|
||||
p, err := c.path(key)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.getCalls++
|
||||
|
||||
@@ -217,8 +195,7 @@ func (c *blobDiskCache) Get(key string) ([]byte, bool) {
|
||||
c.pushFront(e)
|
||||
c.mu.Unlock()
|
||||
|
||||
//nolint:gosec // G304: path() rejects keys with a separator
|
||||
data, err := os.ReadFile(p)
|
||||
data, err := os.ReadFile(c.path(key))
|
||||
if err != nil {
|
||||
c.mu.Lock()
|
||||
if e2, ok2 := c.items[key]; ok2 && e2 == e {
|
||||
@@ -236,20 +213,6 @@ func (c *blobDiskCache) Get(key string) ([]byte, bool) {
|
||||
|
||||
// ReadAt reads a slice of a cached blob without loading the entire blob into memory.
|
||||
func (c *blobDiskCache) ReadAt(key string, offset, length int64) ([]byte, error) {
|
||||
p, err := c.path(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// offset and length come from a blob_chunks row read back from the
|
||||
// destination. A negative value must be rejected outright; the upper
|
||||
// bound is checked as length > size-offset (a subtraction) so a huge
|
||||
// offset+length cannot overflow int64 and slip past the check.
|
||||
if offset < 0 || length < 0 {
|
||||
return nil, fmt.Errorf("%w: offset=%d length=%d",
|
||||
errCacheNegativeRead, offset, length)
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.readAtCalls++
|
||||
|
||||
@@ -260,7 +223,7 @@ func (c *blobDiskCache) ReadAt(key string, offset, length int64) ([]byte, error)
|
||||
return nil, fmt.Errorf("%w: %q", errCacheKeyMissing, key)
|
||||
}
|
||||
|
||||
if length > e.size-offset {
|
||||
if offset+length > e.size {
|
||||
c.mu.Unlock()
|
||||
|
||||
return nil, fmt.Errorf("%w: offset=%d length=%d size=%d",
|
||||
@@ -271,7 +234,7 @@ func (c *blobDiskCache) ReadAt(key string, offset, length int64) ([]byte, error)
|
||||
c.pushFront(e)
|
||||
c.mu.Unlock()
|
||||
|
||||
f, err := os.Open(p) //nolint:gosec // G304: path() rejects keys with a separator
|
||||
f, err := os.Open(c.path(key))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -313,13 +276,7 @@ func (c *blobDiskCache) Delete(key string) {
|
||||
c.unlink(e)
|
||||
delete(c.items, key)
|
||||
c.curBytes -= e.size
|
||||
|
||||
// The key is already in the map, so it passed path() when it was
|
||||
// inserted; the error cannot occur here.
|
||||
p, err := c.path(key)
|
||||
if err == nil {
|
||||
_ = os.Remove(p)
|
||||
}
|
||||
_ = os.Remove(c.path(key))
|
||||
}
|
||||
|
||||
// Keys returns a snapshot of all cached keys. Safe for iteration without
|
||||
@@ -390,18 +347,8 @@ func (c *blobDiskCache) Close() error {
|
||||
return os.RemoveAll(c.dir)
|
||||
}
|
||||
|
||||
// path returns the on-disk location of the cache file for key. The key is
|
||||
// a blob hash read back from the destination and is not trusted: a value
|
||||
// such as "aa/../../../home/u/.profile" would otherwise make filepath.Join
|
||||
// escape the cache directory, so a key containing a path separator is
|
||||
// refused rather than joined.
|
||||
func (c *blobDiskCache) path(key string) (string, error) {
|
||||
if strings.ContainsRune(key, '/') ||
|
||||
strings.ContainsRune(key, filepath.Separator) {
|
||||
return "", fmt.Errorf("%w: %q", errCacheKeyHasSeparator, key)
|
||||
}
|
||||
|
||||
return filepath.Join(c.dir, key), nil
|
||||
func (c *blobDiskCache) path(key string) string {
|
||||
return filepath.Join(c.dir, key)
|
||||
}
|
||||
|
||||
func (c *blobDiskCache) unlink(e *blobDiskCacheEntry) {
|
||||
@@ -444,10 +391,5 @@ func (c *blobDiskCache) evictLRU() {
|
||||
c.unlink(victim)
|
||||
delete(c.items, victim.key)
|
||||
c.curBytes -= victim.size
|
||||
|
||||
// victim.key was validated by path() on insertion, so this cannot err.
|
||||
p, err := c.path(victim.key)
|
||||
if err == nil {
|
||||
_ = os.Remove(p)
|
||||
}
|
||||
_ = os.Remove(c.path(victim.key))
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
package vaultik
|
||||
|
||||
import "errors"
|
||||
|
||||
// blobHashHexLen is the length of a blob hash written as lowercase hex: a
|
||||
// SHA-256 digest is 32 bytes, so 64 characters. Remote snapshot keys are
|
||||
// SHA-256 hashes too and share this exact form.
|
||||
const blobHashHexLen = 64
|
||||
|
||||
// shortHashLen is how many leading characters of a hash appear in log and
|
||||
// error text.
|
||||
const shortHashLen = 16
|
||||
|
||||
// errInvalidBlobHash reports a value used as a blob hash that is not
|
||||
// exactly 64 lowercase hex characters. Restore, verify and prune read
|
||||
// these values back from the destination, which is not trusted, so each
|
||||
// one is checked before it is used to build a path or drive a read.
|
||||
var errInvalidBlobHash = errors.New(
|
||||
"blob hash is not 64 lowercase hex characters")
|
||||
|
||||
// isBlobHash reports whether s is exactly 64 lowercase hex characters.
|
||||
// Every real blob hash and remote snapshot key has this form.
|
||||
//
|
||||
// The check is a plain function, not a method on types.BlobHash: the
|
||||
// packer stores "temp-placeholder-{uuid}" as the hash of an unfinished
|
||||
// blob in the local index, so the type itself must keep accepting values
|
||||
// that are not hashes.
|
||||
func isBlobHash(s string) bool {
|
||||
if len(s) != blobHashHexLen {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, r := range s {
|
||||
if (r < '0' || r > '9') && (r < 'a' || r > 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// shortHash returns the leading part of a hash for log and error text. It
|
||||
// never panics: a string shorter than the prefix is returned whole. A hash
|
||||
// read from the destination may be malformed, and formatting one for a
|
||||
// message must not crash the command.
|
||||
func shortHash(s string) string {
|
||||
if len(s) <= shortHashLen {
|
||||
return s
|
||||
}
|
||||
|
||||
return s[:shortHashLen]
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
package vaultik //nolint:testpackage // drives unexported input validation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
"sneak.berlin/go/vaultik/internal/types"
|
||||
)
|
||||
|
||||
// These tests treat every hash, offset and length read back from the
|
||||
// destination as hostile. A blob hash comes from the downloaded snapshot
|
||||
// database or the store listing, neither of which is authenticated (see
|
||||
// https://git.eeqj.de/sneak/vaultik/issues/155), so each is validated
|
||||
// before it is used to build a path or size an allocation.
|
||||
|
||||
// TestBlobCacheRejectsKeyWithSeparator proves the arbitrary-file-write
|
||||
// hole is closed: a blob hash that climbs out of the cache directory is
|
||||
// refused and nothing is written outside it. This is the exact write the
|
||||
// restore path performs, keyed by the hash from the snapshot database.
|
||||
func TestBlobCacheRejectsKeyWithSeparator(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache, err := newBlobDiskCache(1 << 20)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { _ = cache.Close() }()
|
||||
|
||||
target := filepath.Join(t.TempDir(), "pwned")
|
||||
|
||||
// A hash whose relative form escapes the cache directory to target.
|
||||
key, err := filepath.Rel(cache.dir, target)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, key, "..")
|
||||
|
||||
err = cache.Put(key, []byte("secret"))
|
||||
require.ErrorIs(t, err, errCacheKeyHasSeparator)
|
||||
|
||||
_, err = cache.PutFromReader(key, strings.NewReader("secret"))
|
||||
require.ErrorIs(t, err, errCacheKeyHasSeparator)
|
||||
|
||||
_, statErr := os.Stat(target)
|
||||
require.Truef(t, os.IsNotExist(statErr),
|
||||
"cache wrote outside its directory at %s", target)
|
||||
}
|
||||
|
||||
// TestBuildBlobIndexesRejectsHostileHash proves restore refuses a snapshot
|
||||
// database whose blob_hash escapes the cache directory. buildBlobIndexes is
|
||||
// the first place restore reads these hashes back, and it fails there, before
|
||||
// any blob is fetched or written, so a hash containing /../ cannot steer a
|
||||
// later write outside the cache directory.
|
||||
func TestBuildBlobIndexesRejectsHostileHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
db, err := database.New(ctx, filepath.Join(t.TempDir(), "index.sqlite"))
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
// A blob cache and a target file just outside it. The hostile hash is
|
||||
// the relative path from the cache to that target, so an unguarded
|
||||
// restore keyed by this hash would write there.
|
||||
cache, err := newBlobDiskCache(1 << 20)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { _ = cache.Close() }()
|
||||
|
||||
target := filepath.Join(t.TempDir(), "pwned")
|
||||
hostile, err := filepath.Rel(cache.dir, target)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, hostile, "..")
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
require.NoError(t, repos.Blobs.Create(ctx, nil, &database.Blob{
|
||||
ID: types.NewBlobID(),
|
||||
Hash: types.BlobHash(hostile),
|
||||
CreatedTS: time.Now().UTC(),
|
||||
}))
|
||||
|
||||
v := NewForTesting(nil)
|
||||
v.SetContext(ctx)
|
||||
|
||||
_, _, err = v.buildBlobIndexes(repos)
|
||||
require.ErrorIs(t, err, errInvalidBlobHash)
|
||||
|
||||
_, statErr := os.Stat(target)
|
||||
require.Truef(t, os.IsNotExist(statErr),
|
||||
"restore wrote outside the cache directory at %s", target)
|
||||
}
|
||||
|
||||
// TestBlobCacheReadAtRejectsBadBounds proves a blob_chunks row cannot
|
||||
// drive an out-of-range or negative read. offset/length reach ReadAt
|
||||
// straight from the database.
|
||||
func TestBlobCacheReadAtRejectsBadBounds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache, err := newBlobDiskCache(1 << 20)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { _ = cache.Close() }()
|
||||
|
||||
require.NoError(t, cache.Put("blob", make([]byte, 100)))
|
||||
|
||||
_, err = cache.ReadAt("blob", -1, 10)
|
||||
require.ErrorIs(t, err, errCacheNegativeRead)
|
||||
|
||||
_, err = cache.ReadAt("blob", 0, -1)
|
||||
require.ErrorIs(t, err, errCacheNegativeRead)
|
||||
|
||||
// A length past the end is rejected via the subtraction bound, so a
|
||||
// huge offset+length cannot overflow past the check.
|
||||
_, err = cache.ReadAt("blob", 50, 60)
|
||||
require.ErrorIs(t, err, errCacheReadBeyondBlob)
|
||||
}
|
||||
|
||||
// TestListAllRemoteBlobsSkipsNonConformingName proves a bogus object name
|
||||
// under blobs/ (here a three-character name) is skipped rather than
|
||||
// entering the blob map, so prune's later hash[:2]/hash[2:4] path build
|
||||
// cannot panic on it.
|
||||
func TestListAllRemoteBlobsSkipsNonConformingName(t *testing.T) {
|
||||
// Initialize the global logger before t.Parallel() so the write lands
|
||||
// in the serial phase and cannot race other parallel tests reading it.
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
good := strings.Repeat("a", blobHashHexLen)
|
||||
store := &stubLister{objects: []storage.ObjectInfo{
|
||||
{Key: "blobs/" + good[:2] + "/" + good[2:4] + "/" + good, Size: 10},
|
||||
{Key: "blobs/a/b/c", Size: 3},
|
||||
}}
|
||||
|
||||
v := &Vaultik{Storage: store}
|
||||
v.SetContext(context.Background())
|
||||
|
||||
blobs, err := v.listAllRemoteBlobs()
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, blobs, good)
|
||||
require.NotContains(t, blobs, "c")
|
||||
require.Len(t, blobs, 1)
|
||||
}
|
||||
|
||||
// TestVerifyManifestBlobsRejectsShortHash proves a manifest (which is not
|
||||
// authenticated) with a short blob hash fails cleanly instead of panicking
|
||||
// on blob.Hash[:2].
|
||||
func TestVerifyManifestBlobsRejectsShortHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
v := &Vaultik{Stdout: io.Discard}
|
||||
manifest := &snapshot.Manifest{
|
||||
Blobs: []snapshot.BlobInfo{{Hash: "abc", CompressedSize: 1}},
|
||||
}
|
||||
|
||||
verified, missing, mismatched, missingSize, err :=
|
||||
v.verifyManifestBlobs(manifest, &VerifyOptions{JSON: true})
|
||||
require.ErrorIs(t, err, errInvalidBlobHash)
|
||||
require.Zero(t, verified)
|
||||
require.Zero(t, missing)
|
||||
require.Zero(t, mismatched)
|
||||
require.Zero(t, missingSize)
|
||||
}
|
||||
|
||||
// TestVerifyBlobChunksRejectsNegativeLength proves a blob_chunks row with a
|
||||
// negative length returns an error rather than reaching make([]byte,
|
||||
// length) or streaming an untrusted size.
|
||||
func TestVerifyBlobChunksRejectsNegativeLength(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
db, err := database.New(ctx, filepath.Join(t.TempDir(), "index.sqlite"))
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
blobHash := strings.Repeat("b", blobHashHexLen)
|
||||
blob := &database.Blob{
|
||||
ID: types.NewBlobID(),
|
||||
Hash: types.BlobHash(blobHash),
|
||||
CreatedTS: time.Now().UTC(),
|
||||
}
|
||||
require.NoError(t, repos.Blobs.Create(ctx, nil, blob))
|
||||
|
||||
chunkHash := strings.Repeat("c", blobHashHexLen)
|
||||
require.NoError(t, repos.Chunks.Create(ctx, nil,
|
||||
&database.Chunk{ChunkHash: types.ChunkHash(chunkHash), Size: 1024}))
|
||||
require.NoError(t, repos.BlobChunks.Create(ctx, nil, &database.BlobChunk{
|
||||
BlobID: blob.ID,
|
||||
ChunkHash: types.ChunkHash(chunkHash),
|
||||
Offset: 0,
|
||||
Length: -1,
|
||||
}))
|
||||
|
||||
v := NewForTesting(nil)
|
||||
|
||||
_, err = v.verifyBlobChunks(db.Conn(), blobHash, strings.NewReader(""))
|
||||
require.ErrorIs(t, err, errNegativeChunkLength)
|
||||
}
|
||||
|
||||
// errStubUnused marks a stubLister method a test never exercises.
|
||||
var errStubUnused = errors.New("stubLister method not used in test")
|
||||
|
||||
// stubLister is a storage.Storer whose ListStream yields a fixed set of
|
||||
// objects; every other method is unused by the tests here.
|
||||
type stubLister struct {
|
||||
objects []storage.ObjectInfo
|
||||
}
|
||||
|
||||
func (s *stubLister) ListStream(
|
||||
_ context.Context, prefix string,
|
||||
) <-chan storage.ObjectInfo {
|
||||
ch := make(chan storage.ObjectInfo, len(s.objects))
|
||||
for _, o := range s.objects {
|
||||
if strings.HasPrefix(o.Key, prefix) {
|
||||
ch <- o
|
||||
}
|
||||
}
|
||||
|
||||
close(ch)
|
||||
|
||||
return ch
|
||||
}
|
||||
|
||||
func (s *stubLister) Put(_ context.Context, _ string, _ io.Reader) error {
|
||||
return errStubUnused
|
||||
}
|
||||
|
||||
func (s *stubLister) PutWithProgress(
|
||||
_ context.Context, _ string, _ io.Reader, _ int64, _ storage.ProgressCallback,
|
||||
) error {
|
||||
return errStubUnused
|
||||
}
|
||||
|
||||
func (s *stubLister) Get(_ context.Context, _ string) (io.ReadCloser, error) {
|
||||
return nil, errStubUnused
|
||||
}
|
||||
|
||||
func (s *stubLister) Stat(_ context.Context, _ string) (*storage.ObjectInfo, error) {
|
||||
return nil, errStubUnused
|
||||
}
|
||||
|
||||
func (s *stubLister) Delete(_ context.Context, _ string) error {
|
||||
return errStubUnused
|
||||
}
|
||||
|
||||
func (s *stubLister) List(_ context.Context, _ string) ([]string, error) {
|
||||
return nil, errStubUnused
|
||||
}
|
||||
|
||||
func (s *stubLister) Info() storage.Info {
|
||||
return storage.Info{}
|
||||
}
|
||||
@@ -1,829 +0,0 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/config"
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
"sneak.berlin/go/vaultik/internal/globals"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
"sneak.berlin/go/vaultik/internal/storage/faultstore"
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
|
||||
// These tests cover the failure modes a backup tool must survive:
|
||||
// interrupted uploads, an interrupted metadata export, corrupt and
|
||||
// truncated reads, a full restore disk, and a backend that reports
|
||||
// success while storing nothing. Faults are injected through the
|
||||
// storage.Storer seam (internal/storage/faultstore), never by patching
|
||||
// production code. Each test asserts on the observable end state — what
|
||||
// is in the index, what is at the destination, what the user is told —
|
||||
// not merely that an error was returned. See
|
||||
// https://git.eeqj.de/sneak/vaultik/issues/72.
|
||||
//
|
||||
// Object-level write atomicity (no partial blob object left behind) is
|
||||
// covered by the file:// backend's atomic-write work
|
||||
// (https://git.eeqj.de/sneak/vaultik/issues/130) and is not re-tested
|
||||
// here; these tests target the layers above the backend.
|
||||
//
|
||||
// The tests run serially, not with t.Parallel: each calls
|
||||
// log.Initialize, which replaces the package-global logger, and a
|
||||
// backup or restore running concurrently reads that same logger. Under
|
||||
// -race the two collide. Running one at a time is the same choice
|
||||
// prune_count_test.go already makes for the same reason.
|
||||
|
||||
const (
|
||||
faultChunkSize = int64(64 * 1024)
|
||||
faultMaxBlobSize = int64(256 * 1024)
|
||||
)
|
||||
|
||||
// faultTestConfig returns the config shared by the fault-injection
|
||||
// tests: a real recipient/secret keypair so blobs are genuinely
|
||||
// encrypted, and a blob size limit the restore sweeper can divide.
|
||||
func faultTestConfig() *config.Config {
|
||||
return &config.Config{
|
||||
AgeRecipients: []string{testAgePublicKey},
|
||||
AgeSecretKey: testAgeSecretKey,
|
||||
CompressionLevel: 3,
|
||||
Hostname: testHostname,
|
||||
BlobSizeLimit: config.Size(faultMaxBlobSize),
|
||||
}
|
||||
}
|
||||
|
||||
// writeFaultSourceTree writes a spread of file sizes that forces several
|
||||
// chunks across more than one blob, so a fault landing on a single blob
|
||||
// still leaves other data intact. Returns the expected content by path.
|
||||
func writeFaultSourceTree(
|
||||
t *testing.T, fs afero.Fs, dataDir string,
|
||||
) map[string][]byte {
|
||||
t.Helper()
|
||||
|
||||
files := map[string][]byte{
|
||||
filepath.Join(dataDir, "small.txt"): []byte("hello vaultik"),
|
||||
filepath.Join(dataDir, "a.bin"): bytesPattern("a-", int(faultChunkSize*3)),
|
||||
filepath.Join(dataDir, "sub", "b.bin"): bytesPattern("b-", int(faultChunkSize*3)),
|
||||
filepath.Join(dataDir, "sub", "c.bin"): bytesPattern("c-", int(faultChunkSize*2)),
|
||||
}
|
||||
|
||||
for path, content := range files {
|
||||
require.NoError(t, fs.MkdirAll(filepath.Dir(path), 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, path, content, 0o644))
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
// newFaultScanner builds a scanner writing through the given storer.
|
||||
func newFaultScanner(
|
||||
fs afero.Fs, storer storage.Storer,
|
||||
cfg *config.Config, repos *database.Repositories,
|
||||
) *snapshot.Scanner {
|
||||
return snapshot.NewScanner(snapshot.ScannerConfig{
|
||||
FS: fs,
|
||||
Storage: storer,
|
||||
ChunkSize: faultChunkSize,
|
||||
MaxBlobSize: faultMaxBlobSize,
|
||||
CompressionLevel: cfg.CompressionLevel,
|
||||
AgeRecipients: cfg.AgeRecipients,
|
||||
Repositories: repos,
|
||||
})
|
||||
}
|
||||
|
||||
// newFaultSnapshotManager builds a snapshot manager writing through the
|
||||
// given storer.
|
||||
func newFaultSnapshotManager(
|
||||
fs afero.Fs, storer storage.Storer,
|
||||
cfg *config.Config, repos *database.Repositories,
|
||||
) *snapshot.SnapshotManager {
|
||||
sm := snapshot.NewSnapshotManager(snapshot.SnapshotManagerParams{
|
||||
Repos: repos,
|
||||
Storage: storer,
|
||||
Config: cfg,
|
||||
})
|
||||
sm.SetFilesystem(fs)
|
||||
|
||||
return sm
|
||||
}
|
||||
|
||||
// fullFaultBackup runs a complete backup (create, scan, complete,
|
||||
// export) through storer and returns the snapshot ID.
|
||||
func fullFaultBackup(
|
||||
ctx context.Context, t *testing.T, fs afero.Fs, storer storage.Storer,
|
||||
cfg *config.Config, repos *database.Repositories,
|
||||
dataDir, dbPath, name string,
|
||||
) string {
|
||||
t.Helper()
|
||||
|
||||
sm := newFaultSnapshotManager(fs, storer, cfg, repos)
|
||||
scanner := newFaultScanner(fs, storer, cfg, repos)
|
||||
|
||||
id, err := sm.CreateSnapshotWithName(ctx, cfg.Hostname, name, "v", "g")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = scanner.Scan(ctx, dataDir, id)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, sm.CompleteSnapshot(ctx, id))
|
||||
require.NoError(t, sm.ExportSnapshotMetadata(ctx, dbPath, id))
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
// newReaderVaultik builds a Vaultik that reads (restore/verify) through
|
||||
// storer, with the given repositories (nil is fine for restore/verify,
|
||||
// which read metadata from storage).
|
||||
func newReaderVaultik(
|
||||
ctx context.Context, cfg *config.Config, storer storage.Storer,
|
||||
repos *database.Repositories, fs afero.Fs,
|
||||
) *vaultik.Vaultik {
|
||||
v := &vaultik.Vaultik{
|
||||
Config: cfg,
|
||||
Storage: storer,
|
||||
Repositories: repos,
|
||||
Fs: fs,
|
||||
Stdout: io.Discard,
|
||||
Stderr: io.Discard,
|
||||
UI: ui.NewWithColor(io.Discard, false),
|
||||
}
|
||||
v.SetContext(ctx)
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
// Scenario 3: a stored blob's bytes are flipped before restore reads
|
||||
// them. Restore must fail loudly, and no file must be left on the
|
||||
// restore target holding corrupt content.
|
||||
//
|
||||
//nolint:paralleltest // installs the global logger via log.Initialize
|
||||
func TestRestoreRejectsCorruptBlob(t *testing.T) {
|
||||
assertRestoreRejectsDamagedBlob(t, faultstore.GetCorrupt, "corrupt")
|
||||
}
|
||||
|
||||
// Scenario 4: a stored blob is truncated before restore reads it. Same
|
||||
// contract as the corrupt case.
|
||||
//
|
||||
//nolint:paralleltest // installs the global logger via log.Initialize
|
||||
func TestRestoreRejectsTruncatedBlob(t *testing.T) {
|
||||
assertRestoreRejectsDamagedBlob(t, faultstore.GetTruncate, "truncated")
|
||||
}
|
||||
|
||||
// assertRestoreRejectsDamagedBlob backs up the source tree, then restores
|
||||
// through a store that damages every blob read with the given fault, and
|
||||
// asserts restore fails naming a blob and leaves no file on the target
|
||||
// holding wrong bytes. Metadata reads are returned intact so the failure
|
||||
// is isolated to the blob.
|
||||
func assertRestoreRejectsDamagedBlob(
|
||||
t *testing.T, fault faultstore.GetFault, name string,
|
||||
) {
|
||||
t.Helper()
|
||||
log.Initialize(log.Config{})
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "src")
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
ctx := context.Background()
|
||||
cfg := faultTestConfig()
|
||||
testFiles := writeFaultSourceTree(t, fs, dataDir)
|
||||
|
||||
inner, err := storage.NewFileStorer(storeDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
db, err := database.New(ctx, dbPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
|
||||
id := fullFaultBackup(ctx, t, fs, inner, cfg, repos, dataDir, dbPath, name)
|
||||
require.NoError(t, db.Close())
|
||||
|
||||
faultStore := faultstore.New(inner)
|
||||
faultStore.OnGet = func(key string) faultstore.GetFault {
|
||||
if strings.HasPrefix(key, "blobs/") {
|
||||
return fault
|
||||
}
|
||||
|
||||
return faultstore.GetNormal
|
||||
}
|
||||
|
||||
v := newReaderVaultik(ctx, cfg, faultStore, nil, fs)
|
||||
err = v.Restore(&vaultik.RestoreOptions{SnapshotID: id, TargetDir: restoreDir})
|
||||
|
||||
require.Error(t, err, "restore must fail on a damaged blob")
|
||||
assert.Contains(t, err.Error(), "blob",
|
||||
"error should name the blob that failed")
|
||||
assertNoCorruptFiles(t, fs, restoreDir, testFiles)
|
||||
}
|
||||
|
||||
// Scenario 6: the backend accepts blob uploads and reports success but
|
||||
// stores nothing. verify --deep must catch it.
|
||||
//
|
||||
//nolint:paralleltest // installs the global logger via log.Initialize
|
||||
func TestDeepVerifyCatchesLyingBackend(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "src")
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
ctx := context.Background()
|
||||
cfg := faultTestConfig()
|
||||
|
||||
writeFaultSourceTree(t, fs, dataDir)
|
||||
|
||||
inner, err := storage.NewFileStorer(storeDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Blob uploads are swallowed; metadata uploads land, so verify can
|
||||
// download the manifest and database and then discover the blobs are
|
||||
// absent.
|
||||
lying := faultstore.New(inner)
|
||||
lying.OnPut = func(key string) faultstore.PutAction {
|
||||
if strings.HasPrefix(key, "blobs/") {
|
||||
return faultstore.PutSwallow
|
||||
}
|
||||
|
||||
return faultstore.PutNormal
|
||||
}
|
||||
|
||||
db, err := database.New(ctx, dbPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
|
||||
id := fullFaultBackup(ctx, t, fs, lying, cfg, repos, dataDir, dbPath, "lying")
|
||||
require.NoError(t, db.Close())
|
||||
|
||||
// No blob objects were actually written.
|
||||
blobKeys, err := inner.List(ctx, "blobs/")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, blobKeys, "lying backend should have stored no blobs")
|
||||
|
||||
// Read back through the honest underlying store.
|
||||
v := newReaderVaultik(ctx, cfg, inner, nil, fs)
|
||||
err = v.VerifySnapshotWithOptions(id, &vaultik.VerifyOptions{Deep: true})
|
||||
require.Error(t, err, "deep verify must catch a backend that stored nothing")
|
||||
}
|
||||
|
||||
// Scenario 1a: a blob upload fails partway through. The interrupted run
|
||||
// must not record the blob as uploaded, must not reference it from the
|
||||
// snapshot, and must leave no blob object at the destination.
|
||||
//
|
||||
//nolint:paralleltest // installs the global logger via log.Initialize
|
||||
func TestInterruptedBlobUploadRecordsNoUploadedBlob(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "src")
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
ctx := context.Background()
|
||||
cfg := faultTestConfig()
|
||||
|
||||
writeFaultSourceTree(t, fs, dataDir)
|
||||
|
||||
inner, err := storage.NewFileStorer(storeDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
db, err := database.New(ctx, dbPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
|
||||
// Every blob upload fails partway through. The scan must surface it.
|
||||
fault := faultstore.New(inner)
|
||||
fault.OnPut = func(key string) faultstore.PutAction {
|
||||
if strings.HasPrefix(key, "blobs/") {
|
||||
return faultstore.PutFail
|
||||
}
|
||||
|
||||
return faultstore.PutNormal
|
||||
}
|
||||
|
||||
sm := newFaultSnapshotManager(fs, fault, cfg, repos)
|
||||
scanner := newFaultScanner(fs, fault, cfg, repos)
|
||||
|
||||
id, err := sm.CreateSnapshotWithName(ctx, cfg.Hostname, "interrupted", "v", "g")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = scanner.Scan(ctx, dataDir, id)
|
||||
require.Error(t, err, "scan must fail when a blob upload fails")
|
||||
|
||||
// No blob may claim to be uploaded.
|
||||
blobs, err := repos.Blobs.GetAll(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, b := range blobs {
|
||||
assert.Nilf(t, b.UploadedTS,
|
||||
"blob %s marked uploaded after a failed upload", b.Hash)
|
||||
}
|
||||
|
||||
// The snapshot may reference no blobs, and the destination holds none.
|
||||
hashes, err := repos.Snapshots.GetBlobHashes(ctx, id)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, hashes, "interrupted snapshot must reference no blobs")
|
||||
|
||||
blobKeys, err := inner.List(ctx, "blobs/")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, blobKeys, "no blob object may survive at the destination")
|
||||
}
|
||||
|
||||
// Scenario 1b: after an interrupted upload, a retry on the same local
|
||||
// index must produce a restorable snapshot. The interrupted run leaves
|
||||
// the blob's chunk rows in the index; the fix for
|
||||
// https://git.eeqj.de/sneak/vaultik/issues/148 discards those un-uploaded
|
||||
// blob rows at the start of the next scan and deduplicates only against
|
||||
// chunks in a blob that was actually uploaded, so the retry re-chunks and
|
||||
// re-uploads the affected data instead of silently referencing data that
|
||||
// never reached storage.
|
||||
//
|
||||
//nolint:paralleltest // installs the global logger via log.Initialize
|
||||
func TestBackupRetryAfterInterruptedUploadIsRestorable(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "src")
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
ctx := context.Background()
|
||||
cfg := faultTestConfig()
|
||||
testFiles := writeFaultSourceTree(t, fs, dataDir)
|
||||
|
||||
inner, err := storage.NewFileStorer(storeDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
db, err := database.New(ctx, dbPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
|
||||
// Attempt 1: every blob upload fails.
|
||||
fault := faultstore.New(inner)
|
||||
fault.OnPut = func(key string) faultstore.PutAction {
|
||||
if strings.HasPrefix(key, "blobs/") {
|
||||
return faultstore.PutFail
|
||||
}
|
||||
|
||||
return faultstore.PutNormal
|
||||
}
|
||||
|
||||
sm := newFaultSnapshotManager(fs, fault, cfg, repos)
|
||||
scanner := newFaultScanner(fs, fault, cfg, repos)
|
||||
|
||||
id1, err := sm.CreateSnapshotWithName(ctx, cfg.Hostname, "interrupted", "v", "g")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = scanner.Scan(ctx, dataDir, id1)
|
||||
require.Error(t, err)
|
||||
|
||||
// Retry on the same local index with a working backend.
|
||||
id2 := fullFaultBackup(ctx, t, fs, inner, cfg, repos, dataDir, dbPath, "retry")
|
||||
require.NoError(t, db.Close())
|
||||
|
||||
v := newReaderVaultik(ctx, cfg, inner, nil, fs)
|
||||
require.NoError(t, v.Restore(&vaultik.RestoreOptions{
|
||||
SnapshotID: id2,
|
||||
TargetDir: restoreDir,
|
||||
Verify: true,
|
||||
}), "retry after an interrupted upload must produce a restorable snapshot")
|
||||
|
||||
assertRestoredTree(t, fs, restoreDir, testFiles)
|
||||
}
|
||||
|
||||
// Scenario 2: the process dies during the metadata export, after the
|
||||
// database is uploaded but before the manifest. The destination is left
|
||||
// with blobs and a database but no manifest. verify and snapshot list
|
||||
// must report the damage honestly rather than crashing or passing.
|
||||
// Automatic detection and repair of this partial state on the next run is
|
||||
// covered by TestBackupCompletesOnlyAfterMetadataExport
|
||||
// (https://git.eeqj.de/sneak/vaultik/issues/177); this test exercises the
|
||||
// lower-level export path in isolation.
|
||||
//
|
||||
//nolint:paralleltest // installs the global logger via log.Initialize
|
||||
func TestBackupSurvivesMetadataExportInterruption(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "src")
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
ctx := context.Background()
|
||||
cfg := faultTestConfig()
|
||||
|
||||
writeFaultSourceTree(t, fs, dataDir)
|
||||
|
||||
inner, err := storage.NewFileStorer(storeDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
db, err := database.New(ctx, dbPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
|
||||
// Back up and complete with a working backend.
|
||||
sm := newFaultSnapshotManager(fs, inner, cfg, repos)
|
||||
scanner := newFaultScanner(fs, inner, cfg, repos)
|
||||
|
||||
id, err := sm.CreateSnapshotWithName(ctx, cfg.Hostname, "export", "v", "g")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = scanner.Scan(ctx, dataDir, id)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, sm.CompleteSnapshot(ctx, id))
|
||||
|
||||
// Export through a backend that fails only the manifest upload. The
|
||||
// database uploads first and lands; the manifest does not.
|
||||
fault := faultstore.New(inner)
|
||||
fault.OnPut = func(key string) faultstore.PutAction {
|
||||
if strings.HasSuffix(key, "manifest.json.zst") {
|
||||
return faultstore.PutFail
|
||||
}
|
||||
|
||||
return faultstore.PutNormal
|
||||
}
|
||||
|
||||
smFault := newFaultSnapshotManager(fs, fault, cfg, repos)
|
||||
|
||||
err = smFault.ExportSnapshotMetadata(ctx, dbPath, id)
|
||||
require.Error(t, err, "export must fail when the manifest upload fails")
|
||||
|
||||
// The destination is in the partial state the scenario describes.
|
||||
key := snapshot.RemoteSnapshotKey(id)
|
||||
|
||||
_, err = inner.Stat(ctx, "metadata/"+key+"/db.zst.age")
|
||||
require.NoError(t, err, "database should have been uploaded before the manifest")
|
||||
|
||||
_, err = inner.Stat(ctx, "metadata/"+key+"/manifest.json.zst")
|
||||
require.ErrorIs(t, err, storage.ErrNotFound, "manifest upload should not have landed")
|
||||
|
||||
// verify must fail loudly for this snapshot, in both modes.
|
||||
reader := newReaderVaultik(ctx, cfg, inner, repos, fs)
|
||||
|
||||
deepOpts := &vaultik.VerifyOptions{Deep: true}
|
||||
require.Error(t, reader.VerifySnapshotWithOptions(id, deepOpts),
|
||||
"deep verify must report the missing manifest")
|
||||
|
||||
shallowOpts := &vaultik.VerifyOptions{Deep: false}
|
||||
require.Error(t, reader.VerifySnapshotWithOptions(id, shallowOpts),
|
||||
"shallow verify must report the missing manifest")
|
||||
|
||||
// snapshot list must not crash on the partial snapshot.
|
||||
require.NoError(t, reader.ListSnapshots(false),
|
||||
"snapshot list must tolerate a partially-exported snapshot")
|
||||
}
|
||||
|
||||
// Scenario 2, repair: the process dies during the metadata export of a
|
||||
// full backup run. Because completion is recorded only after the export
|
||||
// succeeds (finalizeSnapshotMetadata), the interrupted snapshot is left
|
||||
// incomplete rather than silently marked complete without metadata at the
|
||||
// destination. Rerunning the backup must then prune the incomplete
|
||||
// snapshot, produce a snapshot whose destination metadata and local index
|
||||
// agree, and restore. See https://git.eeqj.de/sneak/vaultik/issues/177.
|
||||
//
|
||||
//nolint:paralleltest // installs the global logger via log.Initialize
|
||||
func TestBackupCompletesOnlyAfterMetadataExport(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "src")
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
ctx := context.Background()
|
||||
testFiles := writeFaultSourceTree(t, fs, dataDir)
|
||||
|
||||
// A full-backup config: the fault-test defaults plus the fields the
|
||||
// production create path reads (index location, chunk size, and the
|
||||
// named snapshot to back up).
|
||||
cfg := faultTestConfig()
|
||||
cfg.IndexPath = dbPath
|
||||
cfg.ChunkSize = config.Size(faultChunkSize)
|
||||
cfg.Snapshots = map[string]config.SnapshotConfig{
|
||||
"data": {Paths: []string{dataDir}},
|
||||
}
|
||||
|
||||
inner, err := storage.NewFileStorer(storeDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
db, err := database.New(ctx, dbPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
|
||||
// failManifest is on for the first backup and off for the retry, so the
|
||||
// manifest upload fails once — interrupting the export mid-way — then
|
||||
// succeeds.
|
||||
failManifest := true
|
||||
store := faultstore.New(inner)
|
||||
store.OnPut = func(key string) faultstore.PutAction {
|
||||
if failManifest && strings.HasSuffix(key, "manifest.json.zst") {
|
||||
return faultstore.PutFail
|
||||
}
|
||||
|
||||
return faultstore.PutNormal
|
||||
}
|
||||
|
||||
v := newBackupVaultik(ctx, cfg, store, repos, db, fs)
|
||||
opts := &vaultik.SnapshotCreateOptions{Cron: true}
|
||||
|
||||
// First run: the export fails at the manifest upload, so the whole
|
||||
// create fails and the snapshot is left incomplete.
|
||||
require.Error(t, v.CreateSnapshot(opts),
|
||||
"backup must fail when the metadata export is interrupted")
|
||||
|
||||
incompletes, err := repos.Snapshots.GetIncompleteSnapshots(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, incompletes, 1,
|
||||
"an interrupted export must leave exactly one incomplete snapshot")
|
||||
|
||||
afterFirst, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, s := range afterFirst {
|
||||
require.Nil(t, s.CompletedAt,
|
||||
"no snapshot may be marked complete before its metadata is exported")
|
||||
}
|
||||
|
||||
// Second run on the same index and destination: the retry succeeds.
|
||||
failManifest = false
|
||||
|
||||
require.NoError(t, v.CreateSnapshot(opts),
|
||||
"a retry after an interrupted export must succeed")
|
||||
|
||||
assertRetryConsistentAndRestorable(
|
||||
ctx, t, cfg, inner, repos, db, fs, restoreDir, testFiles)
|
||||
}
|
||||
|
||||
// assertRetryConsistentAndRestorable checks the end state after the retry
|
||||
// backup in TestBackupCompletesOnlyAfterMetadataExport: the interrupted
|
||||
// snapshot is pruned, exactly one completed snapshot remains, its metadata
|
||||
// is at the destination, and it restores to the original tree.
|
||||
func assertRetryConsistentAndRestorable(
|
||||
ctx context.Context, t *testing.T, cfg *config.Config,
|
||||
inner storage.Storer, repos *database.Repositories, db *database.DB,
|
||||
fs afero.Fs, restoreDir string, testFiles map[string][]byte,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
incompletes, err := repos.Snapshots.GetIncompleteSnapshots(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, incompletes,
|
||||
"the next run's prune must drop the interrupted snapshot")
|
||||
|
||||
local, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, local, 1, "exactly one snapshot must remain after the retry")
|
||||
|
||||
final := local[0]
|
||||
require.NotNil(t, final.CompletedAt, "the retry's snapshot must be complete")
|
||||
|
||||
// The destination and the local index agree: the completed snapshot has
|
||||
// both its metadata objects at the destination.
|
||||
key := snapshot.RemoteSnapshotKey(final.ID.String())
|
||||
_, err = inner.Stat(ctx, "metadata/"+key+"/manifest.json.zst")
|
||||
require.NoError(t, err, "the completed snapshot's manifest must be at the destination")
|
||||
_, err = inner.Stat(ctx, "metadata/"+key+"/db.zst.age")
|
||||
require.NoError(t, err, "the completed snapshot's database must be at the destination")
|
||||
|
||||
require.NoError(t, db.Close())
|
||||
|
||||
// The snapshot restores from the destination alone.
|
||||
reader := newReaderVaultik(ctx, cfg, inner, nil, fs)
|
||||
require.NoError(t, reader.Restore(&vaultik.RestoreOptions{
|
||||
SnapshotID: final.ID.String(),
|
||||
TargetDir: restoreDir,
|
||||
Verify: true,
|
||||
}), "the retry's snapshot must be restorable")
|
||||
|
||||
assertRestoredTree(t, fs, restoreDir, testFiles)
|
||||
}
|
||||
|
||||
// listRecentTestLimit is a generous cap for the handful of snapshots these
|
||||
// tests create when reading the local index directly.
|
||||
const listRecentTestLimit = 100
|
||||
|
||||
// newBackupVaultik builds a Vaultik that runs the full create path
|
||||
// (CreateSnapshot) writing through storer, wiring the same scanner factory
|
||||
// and snapshot manager the production dependency graph provides.
|
||||
func newBackupVaultik(
|
||||
ctx context.Context, cfg *config.Config, storer storage.Storer,
|
||||
repos *database.Repositories, db *database.DB, fs afero.Fs,
|
||||
) *vaultik.Vaultik {
|
||||
v := &vaultik.Vaultik{
|
||||
Globals: &globals.Globals{Version: "v", Commit: "g"},
|
||||
Config: cfg,
|
||||
DB: db,
|
||||
Repositories: repos,
|
||||
Storage: storer,
|
||||
SnapshotManager: newFaultSnapshotManager(fs, storer, cfg, repos),
|
||||
ScannerFactory: faultScannerFactory(cfg, repos, storer),
|
||||
Fs: fs,
|
||||
Stdout: io.Discard,
|
||||
Stderr: io.Discard,
|
||||
UI: ui.NewWithColor(io.Discard, false),
|
||||
}
|
||||
v.SetContext(ctx)
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
// faultScannerFactory mirrors the production provideScannerFactory, binding
|
||||
// the scanner to the given store, repositories, and config so a full
|
||||
// create-path backup writes through the fault-injecting store.
|
||||
func faultScannerFactory(
|
||||
cfg *config.Config, repos *database.Repositories, storer storage.Storer,
|
||||
) snapshot.ScannerFactory {
|
||||
return func(params snapshot.ScannerParams) *snapshot.Scanner {
|
||||
return snapshot.NewScanner(snapshot.ScannerConfig{
|
||||
FS: params.Fs,
|
||||
Storage: storer,
|
||||
ChunkSize: faultChunkSize,
|
||||
MaxBlobSize: faultMaxBlobSize,
|
||||
CompressionLevel: cfg.CompressionLevel,
|
||||
AgeRecipients: cfg.AgeRecipients,
|
||||
Repositories: repos,
|
||||
EnableProgress: params.EnableProgress,
|
||||
UI: params.UI,
|
||||
Exclude: params.Exclude,
|
||||
SkipErrors: params.SkipErrors,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario 5: the restore target runs out of space mid-file. Restore
|
||||
// must fail with an out-of-space error, and must not leave a truncated
|
||||
// file at the target path presenting as a complete restore. Restore
|
||||
// today writes each file straight to its final path and does not remove
|
||||
// it when a write fails, so the truncated file survives; deleting it is
|
||||
// tracked by https://git.eeqj.de/sneak/vaultik/issues/163. Skipped until
|
||||
// that lands, so the destination assertion below is recorded rather than
|
||||
// dropped.
|
||||
//
|
||||
//nolint:paralleltest // installs the global logger via log.Initialize
|
||||
func TestRestoreReportsDiskFull(t *testing.T) {
|
||||
t.Skip("blocked on https://git.eeqj.de/sneak/vaultik/issues/163: " +
|
||||
"a disk-full write leaves a truncated file at the target path " +
|
||||
"instead of removing it")
|
||||
log.Initialize(log.Config{})
|
||||
|
||||
osFS := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "src")
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
ctx := context.Background()
|
||||
cfg := faultTestConfig()
|
||||
|
||||
testFiles := writeFaultSourceTree(t, osFS, dataDir)
|
||||
|
||||
inner, err := storage.NewFileStorer(storeDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
db, err := database.New(ctx, dbPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
|
||||
id := fullFaultBackup(ctx, t, osFS, inner, cfg, repos, dataDir, dbPath, "diskfull")
|
||||
require.NoError(t, db.Close())
|
||||
|
||||
// Restore onto a filesystem that allows only a few bytes of file
|
||||
// content: enough to create files, far too little to hold them.
|
||||
budget := int64(8)
|
||||
quota := "aFS{Fs: osFS, remaining: &budget}
|
||||
|
||||
v := newReaderVaultik(ctx, cfg, inner, nil, quota)
|
||||
err = v.Restore(&vaultik.RestoreOptions{SnapshotID: id, TargetDir: restoreDir})
|
||||
|
||||
require.Error(t, err, "restore must fail when the target disk is full")
|
||||
assert.Contains(t, err.Error(), errNoSpace.Error(),
|
||||
"restore error should surface the out-of-space cause")
|
||||
|
||||
// The failure must not leave a truncated file behind presenting as a
|
||||
// complete restore: any file at the target must hold the original
|
||||
// bytes, or be absent.
|
||||
assertNoCorruptFiles(t, osFS, restoreDir, testFiles)
|
||||
}
|
||||
|
||||
// assertRestoredTree byte-compares every restored file against the
|
||||
// original.
|
||||
func assertRestoredTree(
|
||||
t *testing.T, fs afero.Fs, restoreDir string, testFiles map[string][]byte,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
for origPath, expected := range testFiles {
|
||||
restoredPath := filepath.Join(restoreDir, origPath)
|
||||
got, err := afero.ReadFile(fs, restoredPath)
|
||||
require.NoErrorf(t, err, "restored file missing: %s", origPath)
|
||||
require.Equalf(t, expected, got, "restored content mismatch for %s", origPath)
|
||||
}
|
||||
}
|
||||
|
||||
// errNoSpace is the out-of-space error quotaFS returns once its byte
|
||||
// budget is exhausted, mirroring a real ENOSPC.
|
||||
var errNoSpace = errors.New("no space left on device")
|
||||
|
||||
// quotaFS is an afero.Fs whose files may write only a fixed total number
|
||||
// of content bytes before failing, simulating a full restore target. It
|
||||
// wraps the interface so every method except Create delegates to the
|
||||
// real filesystem; only file writes are capped.
|
||||
type quotaFS struct {
|
||||
afero.Fs
|
||||
|
||||
remaining *int64
|
||||
}
|
||||
|
||||
//nolint:ireturn // afero.Fs.Create's signature requires returning afero.File.
|
||||
func (q *quotaFS) Create(name string) (afero.File, error) {
|
||||
f, err := q.Fs.Create(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return "aFile{File: f, remaining: q.remaining}, nil
|
||||
}
|
||||
|
||||
// quotaFile fails writes once the shared byte budget is exhausted.
|
||||
type quotaFile struct {
|
||||
afero.File
|
||||
|
||||
remaining *int64
|
||||
}
|
||||
|
||||
func (q *quotaFile) Write(p []byte) (int, error) {
|
||||
if *q.remaining <= 0 {
|
||||
return 0, errNoSpace
|
||||
}
|
||||
|
||||
allowed := min(int64(len(p)), *q.remaining)
|
||||
|
||||
n, err := q.File.Write(p[:allowed])
|
||||
*q.remaining -= int64(n)
|
||||
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
|
||||
if int64(n) < int64(len(p)) {
|
||||
return n, errNoSpace
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// assertNoCorruptFiles fails if any file that made it to the restore
|
||||
// target holds content that differs from the original: a failed restore
|
||||
// may leave a file absent, but must never leave wrong bytes presenting
|
||||
// as the real file.
|
||||
func assertNoCorruptFiles(
|
||||
t *testing.T, fs afero.Fs, restoreDir string, testFiles map[string][]byte,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
for origPath, expected := range testFiles {
|
||||
restoredPath := filepath.Join(restoreDir, origPath)
|
||||
|
||||
got, err := afero.ReadFile(fs, restoredPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
assert.Equalf(t, expected, got,
|
||||
"restored file %s holds corrupt content", origPath)
|
||||
}
|
||||
}
|
||||
@@ -13,14 +13,6 @@ import (
|
||||
|
||||
// ShowInfo displays system and configuration information
|
||||
func (v *Vaultik) ShowInfo() error {
|
||||
// The info report is the output this command exists to produce, so it
|
||||
// is written plain (markers would corrupt the aligned report) through
|
||||
// the UI writer's stdout; --quiet silences it like any other
|
||||
// non-error output.
|
||||
if v.UI.Quiet() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// System Information
|
||||
v.stdoutf("=== System Information ===\n")
|
||||
v.stdoutf("OS/Architecture: %s/%s\n", runtime.GOOS, runtime.GOARCH)
|
||||
@@ -221,12 +213,7 @@ func (v *Vaultik) RemoteInfo(jsonOutput bool) error {
|
||||
result.StorageType = storageInfo.Type
|
||||
result.StorageLocation = storageInfo.Location
|
||||
|
||||
// The human report is written only when it is neither the --json
|
||||
// document (which needs stdout to itself) nor silenced by --quiet. The
|
||||
// scan still runs in both cases so --json still gets a full result.
|
||||
showText := !jsonOutput && !v.UI.Quiet()
|
||||
|
||||
if showText {
|
||||
if !jsonOutput {
|
||||
v.stdoutf("=== Remote Storage ===\n")
|
||||
v.stdoutf("Type: %s\n", storageInfo.Type)
|
||||
v.stdoutf("Location: %s\n", storageInfo.Location)
|
||||
@@ -239,7 +226,7 @@ func (v *Vaultik) RemoteInfo(jsonOutput bool) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if showText {
|
||||
if !jsonOutput {
|
||||
v.stdoutf("Downloading %d manifest(s)...\n", len(snapshotIDs))
|
||||
}
|
||||
|
||||
@@ -247,7 +234,7 @@ func (v *Vaultik) RemoteInfo(jsonOutput bool) error {
|
||||
|
||||
v.populateRemoteInfoResult(result, snapshotMetadata, snapshotIDs, referencedBlobs)
|
||||
|
||||
err = v.scanRemoteBlobStorage(result, referencedBlobs, showText)
|
||||
err = v.scanRemoteBlobStorage(result, referencedBlobs, jsonOutput)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -265,9 +252,7 @@ func (v *Vaultik) RemoteInfo(jsonOutput bool) error {
|
||||
return enc.Encode(result)
|
||||
}
|
||||
|
||||
if showText {
|
||||
v.printRemoteInfoTable(result)
|
||||
}
|
||||
v.printRemoteInfoTable(result)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -377,13 +362,11 @@ func (v *Vaultik) populateRemoteInfoResult(
|
||||
}
|
||||
}
|
||||
|
||||
// scanRemoteBlobStorage lists all blobs on remote and computes orphan
|
||||
// stats. showText is true only when the human report is being printed
|
||||
// (not --json, not --quiet), gating the progress line.
|
||||
// scanRemoteBlobStorage lists all blobs on remote and computes orphan stats
|
||||
func (v *Vaultik) scanRemoteBlobStorage(
|
||||
result *RemoteInfoResult, referencedBlobs map[string]int64, showText bool,
|
||||
result *RemoteInfoResult, referencedBlobs map[string]int64, jsonOutput bool,
|
||||
) error {
|
||||
if showText {
|
||||
if !jsonOutput {
|
||||
v.stdoutf("Scanning blobs...\n")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
|
||||
// TestShowInfo_QuietSuppressesReport checks that --quiet silences the
|
||||
// whole info report. The report is human-facing status, not a scriptable
|
||||
// value, so under --quiet the command produces nothing (and touches none
|
||||
// of its dependencies, which is why this minimal instance suffices).
|
||||
func TestShowInfo_QuietSuppressesReport(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
v := &vaultik.Vaultik{
|
||||
Stdout: &out,
|
||||
UI: ui.NewWithColor(&out, false),
|
||||
}
|
||||
v.UI.SetQuiet(true)
|
||||
|
||||
require.NoError(t, v.ShowInfo())
|
||||
require.Empty(t, out.String(),
|
||||
"the info report must be suppressed under --quiet")
|
||||
}
|
||||
@@ -167,12 +167,6 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
|
||||
|
||||
// collectReferencedBlobs downloads all manifests and returns the set of
|
||||
// referenced blob hashes.
|
||||
//
|
||||
// Every manifest must be read successfully. A manifest that cannot be
|
||||
// downloaded or decoded means its snapshot's blobs are unknown, so
|
||||
// treating them as unreferenced would let prune delete data a snapshot
|
||||
// still needs. Rather than risk that silent loss, any failure returns an
|
||||
// error naming the remote key and prune deletes nothing.
|
||||
func (v *Vaultik) collectReferencedBlobs() (map[string]bool, error) {
|
||||
log.Info("Listing remote snapshots")
|
||||
// IDs returned by listUniqueSnapshotIDs are remote keys (hashed
|
||||
@@ -185,22 +179,27 @@ func (v *Vaultik) collectReferencedBlobs() (map[string]bool, error) {
|
||||
log.Info("Found manifests in remote storage", "count", len(remoteKeys))
|
||||
|
||||
allBlobsReferenced := make(map[string]bool)
|
||||
manifestCount := 0
|
||||
|
||||
for _, remoteKey := range remoteKeys {
|
||||
log.Debug("Processing manifest", "remote_key", remoteKey)
|
||||
|
||||
manifest, err := v.downloadManifestByKey(remoteKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading manifest %s: %w", remoteKey, err)
|
||||
log.Error("Failed to download manifest", "remote_key", remoteKey, "error", err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
for _, blob := range manifest.Blobs {
|
||||
allBlobsReferenced[blob.Hash] = true
|
||||
}
|
||||
|
||||
manifestCount++
|
||||
}
|
||||
|
||||
log.Info("Processed manifests",
|
||||
"count", len(remoteKeys), "unique_blobs_referenced", len(allBlobsReferenced))
|
||||
"count", manifestCount, "unique_blobs_referenced", len(allBlobsReferenced))
|
||||
|
||||
return allBlobsReferenced, nil
|
||||
}
|
||||
@@ -247,22 +246,9 @@ func (v *Vaultik) listAllRemoteBlobs() (map[string]int64, error) {
|
||||
}
|
||||
|
||||
parts := strings.Split(object.Key, "/")
|
||||
if len(parts) != blobKeyParts || parts[0] != "blobs" {
|
||||
continue
|
||||
if len(parts) == blobKeyParts && parts[0] == "blobs" {
|
||||
allBlobs[parts[3]] = object.Size
|
||||
}
|
||||
|
||||
// The object name is read from the destination store and is not
|
||||
// trusted. A name that is not a blob hash (e.g. a short or
|
||||
// non-hex string) would panic the later hash[:2]/hash[2:4] path
|
||||
// build, so skip it with a warning rather than delete it.
|
||||
if !isBlobHash(parts[3]) {
|
||||
log.Warn("Skipping non-conforming object under blobs/",
|
||||
"key", object.Key)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
allBlobs[parts[3]] = object.Size
|
||||
}
|
||||
|
||||
log.Info("Found blobs in storage", "count", len(allBlobs))
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
|
||||
// TestPruneBlobs_UnreadableManifestDeletesNothing is the regression guard
|
||||
// for issue #157: prune identifies referenced blobs by reading every
|
||||
// snapshot's manifest, and a manifest it cannot decode used to be logged
|
||||
// and skipped. Blobs referenced only by that snapshot then looked
|
||||
// unreferenced and were deleted, with a zero exit — silent backup loss,
|
||||
// made worse by `snapshot create --prune` running unattended with force.
|
||||
//
|
||||
// The single blob here is referenced only by the snapshot whose manifest
|
||||
// is corrupt, so the old behaviour would delete it and succeed. Prune
|
||||
// must instead delete nothing and return an error.
|
||||
func TestPruneBlobs_UnreadableManifestDeletesNothing(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
env := newListEnv(t)
|
||||
ctx := context.Background()
|
||||
|
||||
blobKey := "blobs/" + testBlobHashA[:2] + "/" + testBlobHashA[2:4] +
|
||||
"/" + testBlobHashA
|
||||
require.NoError(t, env.store.Put(ctx, blobKey,
|
||||
bytes.NewReader([]byte("blob-bytes"))))
|
||||
|
||||
// A manifest at the path prune reads, but with contents it cannot
|
||||
// decode.
|
||||
require.NoError(t, env.store.Put(ctx,
|
||||
"metadata/corruptkey/manifest.json.zst",
|
||||
bytes.NewReader([]byte("not a valid manifest"))))
|
||||
|
||||
err := env.v.PruneBlobs(&vaultik.PruneOptions{Force: true})
|
||||
|
||||
require.Error(t, err, "prune must fail when a manifest cannot be read")
|
||||
assert.True(t, env.store.hasKey(blobKey),
|
||||
"no blob may be deleted when a manifest is unreadable")
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
"sneak.berlin/go/vaultik/internal/types"
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
|
||||
// setupConsistencyTest builds a Vaultik whose local database and mock
|
||||
// remote both hold the given snapshots. Remote metadata is stored under
|
||||
// the production layout, metadata/<RemoteSnapshotKey(id)>/manifest.json.zst.
|
||||
// It returns the instance and the mock so a test can inspect the remote.
|
||||
func setupConsistencyTest(
|
||||
t *testing.T, snapshotIDs []string,
|
||||
) (*vaultik.Vaultik, *MockStorer) {
|
||||
t.Helper()
|
||||
|
||||
ctx := context.Background()
|
||||
db, err := database.New(ctx, ":memory:")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
mockStorage := NewMockStorer()
|
||||
|
||||
for _, id := range snapshotIDs {
|
||||
parts := strings.Split(id, "_")
|
||||
startedAt, err := time.Parse(time.RFC3339, parts[len(parts)-1])
|
||||
require.NoError(t, err, "parsing timestamp from snapshot ID %q", id)
|
||||
|
||||
completedAt := startedAt.Add(5 * time.Minute)
|
||||
snap := &database.Snapshot{
|
||||
ID: types.SnapshotID(id),
|
||||
Hostname: testHostname,
|
||||
VaultikVersion: testLabel,
|
||||
StartedAt: startedAt,
|
||||
CompletedAt: &completedAt,
|
||||
}
|
||||
err = repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
||||
return repos.Snapshots.Create(ctx, tx, snap)
|
||||
})
|
||||
require.NoError(t, err, "creating snapshot %s", id)
|
||||
|
||||
metadataKey := "metadata/" + snapshot.RemoteSnapshotKey(id) +
|
||||
"/manifest.json.zst"
|
||||
err = mockStorage.Put(ctx, metadataKey, strings.NewReader("stub"))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
v := &vaultik.Vaultik{
|
||||
Storage: mockStorage,
|
||||
Repositories: repos,
|
||||
DB: db,
|
||||
Stdout: &bytes.Buffer{},
|
||||
Stderr: &bytes.Buffer{},
|
||||
Stdin: &bytes.Buffer{},
|
||||
}
|
||||
v.SetContext(ctx)
|
||||
|
||||
return v, mockStorage
|
||||
}
|
||||
|
||||
func remoteHasSnapshot(t *testing.T, m *MockStorer, id string) bool {
|
||||
t.Helper()
|
||||
|
||||
prefix := "metadata/" + snapshot.RemoteSnapshotKey(id) + "/"
|
||||
keys, err := m.List(context.Background(), prefix)
|
||||
require.NoError(t, err)
|
||||
|
||||
return len(keys) > 0
|
||||
}
|
||||
|
||||
// TestPurgeKeepsRemotelyBackedLocalRows guards against issue #160
|
||||
// (https://git.eeqj.de/sneak/vaultik/issues/160): purge reconciles local
|
||||
// rows against the remote first, and that step compared human snapshot IDs
|
||||
// against the hashed remote directory names, which never match — so it
|
||||
// deleted every local record and the purge itself then removed nothing.
|
||||
//
|
||||
// With every snapshot still present remotely and nothing old enough to
|
||||
// purge, all local rows must survive the reconcile untouched.
|
||||
func TestPurgeKeepsRemotelyBackedLocalRows(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ids := []string{snapHomeT0, snapHomeT1, snapSystemT0}
|
||||
|
||||
v, _ := setupConsistencyTest(t, ids)
|
||||
|
||||
err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
||||
// 100 years: nothing is old enough to delete, so the reconcile
|
||||
// is the only thing that touches the rows.
|
||||
OlderThan: "36500d",
|
||||
Force: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
remaining := listRemainingSnapshots(t, v)
|
||||
assert.Len(t, remaining, len(ids),
|
||||
"remotely-backed local rows must survive the reconcile")
|
||||
assert.Contains(t, remaining, snapHomeT0)
|
||||
assert.Contains(t, remaining, snapHomeT1)
|
||||
assert.Contains(t, remaining, snapSystemT0)
|
||||
}
|
||||
|
||||
// TestPurgeRemovesLocalAndRemoteTogether proves the two halves stay
|
||||
// consistent: a purged snapshot is gone both locally and remotely, while a
|
||||
// retained one keeps both. Before the fix, the reconcile dropped every
|
||||
// local row yet the remote metadata was left in place.
|
||||
func TestPurgeRemovesLocalAndRemoteTogether(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ids := []string{snapHomeT0, snapHomeT1, snapSystemT0}
|
||||
|
||||
v, mock := setupConsistencyTest(t, ids)
|
||||
|
||||
err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
||||
KeepLatest: true,
|
||||
Force: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Keep latest per name: newest home and the lone system are kept.
|
||||
remaining := listRemainingSnapshots(t, v)
|
||||
assert.ElementsMatch(t, []string{snapHomeT1, snapSystemT0}, remaining)
|
||||
|
||||
// Local and remote agree: the older home snapshot is gone from both,
|
||||
// the retained ones are present in both.
|
||||
assert.False(t, remoteHasSnapshot(t, mock, snapHomeT0),
|
||||
"purged snapshot must also be removed remotely")
|
||||
assert.True(t, remoteHasSnapshot(t, mock, snapHomeT1),
|
||||
"retained snapshot must remain remotely")
|
||||
assert.True(t, remoteHasSnapshot(t, mock, snapSystemT0),
|
||||
"retained snapshot must remain remotely")
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
"sneak.berlin/go/vaultik/internal/types"
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
@@ -61,11 +60,8 @@ func setupPurgeTest(t *testing.T, snapshotIDs []string) *vaultik.Vaultik {
|
||||
})
|
||||
require.NoError(t, err, "creating snapshot %s", id)
|
||||
|
||||
// Create the remote metadata stub under the production layout so
|
||||
// syncWithRemote keeps the local row. Production stores metadata
|
||||
// under the hashed remote key, not the human snapshot ID.
|
||||
metadataKey := "metadata/" + snapshot.RemoteSnapshotKey(id) +
|
||||
"/manifest.json.zst"
|
||||
// Create remote metadata stub so syncWithRemote keeps it
|
||||
metadataKey := "metadata/" + id + "/manifest.json.zst"
|
||||
err = mockStorage.Put(ctx, metadataKey, strings.NewReader("stub"))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
+81
-267
@@ -1,6 +1,7 @@
|
||||
package vaultik
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
@@ -18,7 +19,6 @@ import (
|
||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
"sneak.berlin/go/vaultik/internal/types"
|
||||
)
|
||||
|
||||
@@ -29,47 +29,21 @@ var (
|
||||
errDecryptionKeyRequired = errors.New(
|
||||
"decryption key required for restore\n\n" +
|
||||
"Set the VAULTIK_AGE_SECRET_KEY environment variable to your " +
|
||||
"age private key file:\n" +
|
||||
" export VAULTIK_AGE_SECRET_KEY=\"$(cat vaultik_backup_private_key.txt)\"")
|
||||
// errInvalidAgeSecretKey is returned when the configured key does not
|
||||
// parse as any age identity. It names the source but never the value,
|
||||
// which is secret, so the message is safe to print and log.
|
||||
errInvalidAgeSecretKey = errors.New(
|
||||
"configured age secret key holds no usable age identity")
|
||||
"age private key:\n" +
|
||||
" export VAULTIK_AGE_SECRET_KEY='AGE-SECRET-KEY-...'")
|
||||
errBlobMissingFromIndex = errors.New("blob hash missing from blob index")
|
||||
errChunkNotInAnyBlob = errors.New("chunk not found in any blob")
|
||||
errBlobIDNotInHashIndex = errors.New("blob id missing from hash index")
|
||||
errShortChunkRead = errors.New("short read")
|
||||
errRestorePathEscapesTarget = errors.New(
|
||||
"refusing to restore path outside the target directory")
|
||||
errTrailingRestoreData = errors.New(
|
||||
"restored file has trailing data after its last chunk")
|
||||
errRestoreIncomplete = errors.New(
|
||||
"restore loop ended with files still pending")
|
||||
errSnapshotDBMismatch = errors.New(
|
||||
"decrypted database is not the requested snapshot")
|
||||
// errEmptySnapshotDB is returned when the decrypted metadata database has
|
||||
// zero length, which happens when the object was truncated or replaced
|
||||
// with an empty payload. Rejected before any schema is built on it.
|
||||
errEmptySnapshotDB = errors.New("decrypted snapshot database is empty")
|
||||
)
|
||||
|
||||
// snapshotDBFilename is the name the decrypted snapshot database is
|
||||
// written under inside its private temp directory.
|
||||
const snapshotDBFilename = "snapshot.db"
|
||||
|
||||
// restoreDirMode is the permission mode for directories created while
|
||||
// restoring (parent directories and the target root; restored
|
||||
// directories themselves get their stored mode).
|
||||
const restoreDirMode = 0o755
|
||||
|
||||
// restoreFileMode is the restrictive mode a regular file is created with
|
||||
// during restore. Content is written while the file holds this mode; the
|
||||
// stored mode is applied only after the file is fully written and closed,
|
||||
// so a file whose stored mode is restrictive is never briefly readable by
|
||||
// other local users while its content is being written.
|
||||
const restoreFileMode = 0o600
|
||||
|
||||
// sweepIntervalDivisor sets the sweeper threshold to one N-th of the
|
||||
// configured blob size limit.
|
||||
const sweepIntervalDivisor = 100
|
||||
@@ -105,7 +79,7 @@ type RestoreResult struct {
|
||||
func (v *Vaultik) Restore(opts *RestoreOptions) error {
|
||||
startTime := time.Now()
|
||||
|
||||
identities, err := v.restoreIdentities()
|
||||
identity, err := v.prepareRestoreIdentity()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -119,7 +93,7 @@ func (v *Vaultik) Restore(opts *RestoreOptions) error {
|
||||
// Step 1: Download and decrypt the snapshot metadata database
|
||||
log.Info("Downloading snapshot metadata...")
|
||||
|
||||
tempDB, tempDir, err := v.downloadSnapshotDB(opts.SnapshotID, identities)
|
||||
tempDB, err := v.downloadSnapshotDB(opts.SnapshotID, identity)
|
||||
if err != nil {
|
||||
return fmt.Errorf("downloading snapshot database: %w", err)
|
||||
}
|
||||
@@ -129,11 +103,10 @@ func (v *Vaultik) Restore(opts *RestoreOptions) error {
|
||||
if err != nil {
|
||||
log.Debug("Failed to close temp database", "error", err)
|
||||
}
|
||||
// Remove the whole private directory, so the decrypted database
|
||||
// and any SQLite side files it produced are gone on every path.
|
||||
err = v.Fs.RemoveAll(tempDir)
|
||||
// Clean up temp file
|
||||
err = v.Fs.Remove(tempDB.Path())
|
||||
if err != nil {
|
||||
log.Debug("Failed to remove temp database directory", "error", err)
|
||||
log.Debug("Failed to remove temp database", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -168,7 +141,7 @@ func (v *Vaultik) Restore(opts *RestoreOptions) error {
|
||||
}
|
||||
|
||||
// Step 5: Restore files
|
||||
result, err := v.restoreAllFiles(files, repos, opts, identities, chunkToBlobMap)
|
||||
result, err := v.restoreAllFiles(files, repos, opts, identity, chunkToBlobMap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -229,28 +202,21 @@ func (v *Vaultik) finishRestore(
|
||||
return nil
|
||||
}
|
||||
|
||||
// restoreIdentities parses the configured age secret key once into every
|
||||
// identity it contains. The value may be a single key line or a whole
|
||||
// age-keygen file with several identities; all of them are returned so
|
||||
// blobgen (via age.Decrypt) can read a blob encrypted to any of their
|
||||
// recipients. This is the first step of both restore and deep verify, so
|
||||
// a missing or unparseable key fails before anything is downloaded. The
|
||||
// error names the configuration source but never the key value.
|
||||
func (v *Vaultik) restoreIdentities() ([]age.Identity, error) {
|
||||
// prepareRestoreIdentity validates that an age secret key is configured
|
||||
// and parses it.
|
||||
//
|
||||
//nolint:ireturn // age.Identity is the decryption abstraction by design
|
||||
func (v *Vaultik) prepareRestoreIdentity() (age.Identity, error) {
|
||||
if v.Config.AgeSecretKey == "" {
|
||||
return nil, errDecryptionKeyRequired
|
||||
}
|
||||
|
||||
// age.ParseIdentities skips comment and blank lines and rejects a
|
||||
// malformed key. Its error can quote the offending line, so it is not
|
||||
// wrapped here — that would leak the secret into the message.
|
||||
identities, err := age.ParseIdentities(strings.NewReader(v.Config.AgeSecretKey))
|
||||
identity, err := age.ParseX25519Identity(v.Config.AgeSecretKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w (source: %s)",
|
||||
errInvalidAgeSecretKey, v.Config.AgeSecretKeySourceName())
|
||||
return nil, fmt.Errorf("parsing age secret key: %w", err)
|
||||
}
|
||||
|
||||
return identities, nil
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
// restoreAllFiles processes files in blob-locality order: drain every
|
||||
@@ -263,7 +229,7 @@ func (v *Vaultik) restoreAllFiles(
|
||||
files []*database.File,
|
||||
repos *database.Repositories,
|
||||
opts *RestoreOptions,
|
||||
identities []age.Identity,
|
||||
identity age.Identity,
|
||||
chunkToBlobMap map[string]*database.BlobChunk,
|
||||
) (*RestoreResult, error) {
|
||||
result := &RestoreResult{}
|
||||
@@ -317,7 +283,7 @@ func (v *Vaultik) restoreAllFiles(
|
||||
ctx: v.ctx,
|
||||
repos: repos,
|
||||
opts: opts,
|
||||
identities: identities,
|
||||
identity: identity,
|
||||
chunkToBlobMap: chunkToBlobMap,
|
||||
blobByHash: blobByHash,
|
||||
blobIDToHash: blobIDToHash,
|
||||
@@ -393,13 +359,6 @@ func (v *Vaultik) runRestoreLoop(
|
||||
totalBytesExpected, startTime, &lastStatusTime)
|
||||
}
|
||||
|
||||
// The loop above stops as soon as nothing is ready and nothing more
|
||||
// can be downloaded. If files still remain, they were abandoned
|
||||
// rather than restored; fail loudly instead of reporting success.
|
||||
if plan.hasPending() {
|
||||
return errRestoreIncomplete
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -414,26 +373,20 @@ func (v *Vaultik) runRestoreLoop(
|
||||
func (s *restoreSession) downloadNextBlobSet(plan *restorePlan) (bool, error) {
|
||||
s.sweeper.sweep()
|
||||
|
||||
next, ok := plan.pickNextDownload()
|
||||
if !ok {
|
||||
next := plan.pickNextDownload()
|
||||
if next.IsZero() {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
for _, hash := range plan.blobsNeeded(next) {
|
||||
// Stop between blobs on cancel so an interrupt ends the download
|
||||
// phase promptly rather than fetching the rest of the set.
|
||||
if s.ctx.Err() != nil {
|
||||
return false, s.ctx.Err()
|
||||
}
|
||||
|
||||
blob, ok := s.blobByHash[hash]
|
||||
if !ok {
|
||||
return false, fmt.Errorf("%w: %s", errBlobMissingFromIndex, shortHash(hash))
|
||||
return false, fmt.Errorf("%w: %s", errBlobMissingFromIndex, hash[:16])
|
||||
}
|
||||
|
||||
err := s.downloadBlobToCache(hash, blob.CompressedSize, blob.UncompressedSize)
|
||||
err := s.downloadBlobToCache(hash, blob.CompressedSize)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("downloading blob %s: %w", shortHash(hash), err)
|
||||
return false, fmt.Errorf("downloading blob %s: %w", hash[:16], err)
|
||||
}
|
||||
|
||||
s.result.BlobsDownloaded++
|
||||
@@ -477,16 +430,6 @@ func (v *Vaultik) buildBlobIndexes(
|
||||
blobByHash := make(map[string]*database.Blob, len(blobsByID))
|
||||
for id, blob := range blobsByID {
|
||||
hash := blob.Hash.String()
|
||||
|
||||
// The snapshot database is untrusted. A hash that is not 64
|
||||
// lowercase hex characters could steer a later fetch to a path
|
||||
// outside the cache directory, so reject it here, before any
|
||||
// blob is downloaded.
|
||||
if !isBlobHash(hash) {
|
||||
return nil, nil, fmt.Errorf(
|
||||
"%w: %s", errInvalidBlobHash, shortHash(hash))
|
||||
}
|
||||
|
||||
blobIDToHash[id] = hash
|
||||
blobByHash[hash] = blob
|
||||
}
|
||||
@@ -641,11 +584,11 @@ func (v *Vaultik) handleRestoreVerification(
|
||||
// for a remote-only snapshot) is used as-is, so a host with no local
|
||||
// index can restore the snapshots it can only see on the store.
|
||||
func (v *Vaultik) downloadSnapshotDB(
|
||||
snapshotID string, identities []age.Identity,
|
||||
) (*database.DB, string, error) {
|
||||
snapshotID string, identity age.Identity,
|
||||
) (*database.DB, error) {
|
||||
remoteKey, err := v.resolveSnapshotRemoteKey(snapshotID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Download encrypted database from storage
|
||||
@@ -653,128 +596,69 @@ func (v *Vaultik) downloadSnapshotDB(
|
||||
|
||||
reader, err := v.Storage.Get(v.ctx, dbKey)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("downloading %s: %w", dbKey, err)
|
||||
return nil, fmt.Errorf("downloading %s: %w", dbKey, err)
|
||||
}
|
||||
|
||||
defer func() { _ = reader.Close() }()
|
||||
|
||||
// Decrypt and decompress straight from the storage stream, then stream
|
||||
// the plaintext to a temp file. Neither the encrypted bytes nor the
|
||||
// decrypted database is ever held whole in memory; a snapshot database
|
||||
// can be large.
|
||||
blobReader, err := blobgen.NewReader(reader, identities...)
|
||||
// Read all data
|
||||
encryptedData, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("creating decryption reader: %w", err)
|
||||
return nil, fmt.Errorf("reading encrypted data: %w", err)
|
||||
}
|
||||
|
||||
log.Debug("Downloaded encrypted database",
|
||||
"size", ubytes(int64(len(encryptedData))))
|
||||
|
||||
// Decrypt and decompress using blobgen.Reader
|
||||
blobReader, err := blobgen.NewReader(bytes.NewReader(encryptedData), identity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating decryption reader: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = blobReader.Close() }()
|
||||
|
||||
db, tempDir, err := v.materializeSnapshotDB(blobReader)
|
||||
// Read the binary SQLite database
|
||||
dbData, err := io.ReadAll(blobReader)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
return nil, fmt.Errorf("decrypting and decompressing: %w", err)
|
||||
}
|
||||
|
||||
// Confirm the decrypted database really is the snapshot named by
|
||||
// remoteKey before any files are read from it. On mismatch, close the
|
||||
// database and remove its private directory so nothing is left behind.
|
||||
err = v.verifySnapshotDBIdentity(db, snapshotID, remoteKey)
|
||||
log.Debug("Decrypted database", "size", ubytes(int64(len(dbData))))
|
||||
|
||||
// Create a temporary database file and write the binary SQLite data directly
|
||||
tempFile, err := afero.TempFile(v.Fs, "", "vaultik-restore-*.db")
|
||||
if err != nil {
|
||||
_ = db.Close()
|
||||
_ = v.Fs.RemoveAll(tempDir)
|
||||
|
||||
return nil, "", err
|
||||
return nil, fmt.Errorf("creating temp file: %w", err)
|
||||
}
|
||||
|
||||
return db, tempDir, nil
|
||||
}
|
||||
tempPath := tempFile.Name()
|
||||
|
||||
// verifySnapshotDBIdentity confirms the decrypted metadata database really
|
||||
// is the snapshot named by remoteKey. age decryption proves the database
|
||||
// is readable, not that the object served at
|
||||
// metadata/<remoteKey>/db.zst.age is the snapshot that was requested: an
|
||||
// attacker who swaps in another valid db.zst.age (which needs no key
|
||||
// material) would otherwise redirect restore and deep verify to a
|
||||
// different snapshot's contents. The exported per-snapshot database holds
|
||||
// exactly one snapshot row, and a snapshot's remote key is derived from
|
||||
// that row's ID, so the database is the requested one exactly when its
|
||||
// sole snapshot hashes back to remoteKey. Comparing the requested
|
||||
// identifier directly would not do: it may be a remote-key prefix a
|
||||
// recovery host uses in place of a human snapshot ID it cannot know.
|
||||
func (v *Vaultik) verifySnapshotDBIdentity(
|
||||
db *database.DB, requested, remoteKey string,
|
||||
) error {
|
||||
repos := database.NewRepositories(db)
|
||||
|
||||
snap, err := repos.Snapshots.GetOnlySnapshot(v.ctx)
|
||||
// Write the binary SQLite database directly
|
||||
_, err = tempFile.Write(dbData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking identity of database for %s: %w", requested, err)
|
||||
_ = tempFile.Close()
|
||||
_ = v.Fs.Remove(tempPath)
|
||||
|
||||
return nil, fmt.Errorf("writing database file: %w", err)
|
||||
}
|
||||
|
||||
if snapshot.RemoteSnapshotKey(snap.ID.String()) != remoteKey {
|
||||
return fmt.Errorf("%w: requested %s but the database is snapshot %s",
|
||||
errSnapshotDBMismatch, requested, snap.ID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// materializeSnapshotDB streams the decrypted snapshot database into a
|
||||
// fresh private (0700) temp directory and opens the file read-only. The
|
||||
// database is copied through an io.Copy buffer rather than read whole into
|
||||
// memory. On any failure it removes the directory before returning, so no
|
||||
// decrypted metadata is left on disk when the copy is interrupted or the
|
||||
// payload is damaged. On success the returned directory is the caller's to
|
||||
// remove.
|
||||
func (v *Vaultik) materializeSnapshotDB(
|
||||
dbReader io.Reader,
|
||||
) (*database.DB, string, error) {
|
||||
tempDir, err := afero.TempDir(v.Fs, "", "vaultik-restore-")
|
||||
err = tempFile.Close()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("creating temp directory: %w", err)
|
||||
_ = v.Fs.Remove(tempPath)
|
||||
|
||||
return nil, fmt.Errorf("closing temp file: %w", err)
|
||||
}
|
||||
|
||||
success := false
|
||||
log.Debug("Created restore database", "path", tempPath)
|
||||
|
||||
defer func() {
|
||||
if !success {
|
||||
_ = v.Fs.RemoveAll(tempDir)
|
||||
}
|
||||
}()
|
||||
|
||||
dbPath := filepath.Join(tempDir, snapshotDBFilename)
|
||||
|
||||
dbFile, err := v.Fs.OpenFile(
|
||||
dbPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, restoreFileMode)
|
||||
// Open the database
|
||||
db, err := database.New(v.ctx, tempPath)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("creating database file: %w", err)
|
||||
return nil, fmt.Errorf("opening restore database: %w", err)
|
||||
}
|
||||
|
||||
written, copyErr := io.Copy(dbFile, dbReader)
|
||||
closeErr := dbFile.Close()
|
||||
|
||||
if copyErr != nil {
|
||||
return nil, "", fmt.Errorf("writing database file: %w", copyErr)
|
||||
}
|
||||
|
||||
if closeErr != nil {
|
||||
return nil, "", fmt.Errorf("closing database file: %w", closeErr)
|
||||
}
|
||||
|
||||
log.Debug("Created restore database", "path", dbPath, "size", ubytes(written))
|
||||
|
||||
// Reject an empty database before OpenReadOnly builds a schema on it.
|
||||
if written == 0 {
|
||||
return nil, "", errEmptySnapshotDB
|
||||
}
|
||||
|
||||
db, err := database.OpenReadOnly(v.ctx, dbPath)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("opening restore database: %w", err)
|
||||
}
|
||||
|
||||
success = true
|
||||
|
||||
return db, tempDir, nil
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// getFilesToRestore returns the list of files to restore based on path filters
|
||||
@@ -863,7 +747,7 @@ type restoreSession struct {
|
||||
ctx context.Context //nolint:containedctx // per-restore state by design
|
||||
repos *database.Repositories
|
||||
opts *RestoreOptions
|
||||
identities []age.Identity
|
||||
identity age.Identity
|
||||
chunkToBlobMap map[string]*database.BlobChunk
|
||||
blobByHash map[string]*database.Blob
|
||||
blobIDToHash map[string]string
|
||||
@@ -1005,13 +889,6 @@ func (s *restoreSession) restoreDirectory(
|
||||
return fmt.Errorf("creating directory: %w", err)
|
||||
}
|
||||
|
||||
// MkdirAll applies the process umask, so chmod to the exact stored
|
||||
// mode. A failure here is non-fatal.
|
||||
err = s.v.Fs.Chmod(targetPath, os.FileMode(file.Mode))
|
||||
if err != nil {
|
||||
log.Debug("Failed to set permissions", "path", targetPath, "error", err)
|
||||
}
|
||||
|
||||
s.applyFileMetadata(file, targetPath)
|
||||
|
||||
s.result.FilesRestored++
|
||||
@@ -1019,22 +896,25 @@ func (s *restoreSession) restoreDirectory(
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyFileMetadata applies ownership (when running as root on a real
|
||||
// filesystem) and mtime to a restored path. Permission mode is applied
|
||||
// separately by each caller, with different failure handling, so it is
|
||||
// not touched here. Failures are logged at debug level and do not abort
|
||||
// the restore.
|
||||
// applyFileMetadata applies stored permissions, ownership (when running
|
||||
// as root on a real filesystem), and mtime to a restored path. Failures
|
||||
// are logged at debug level and do not abort the restore.
|
||||
func (s *restoreSession) applyFileMetadata(file *database.File, targetPath string) {
|
||||
err := s.v.Fs.Chmod(targetPath, os.FileMode(file.Mode))
|
||||
if err != nil {
|
||||
log.Debug("Failed to set permissions", "path", targetPath, "error", err)
|
||||
}
|
||||
|
||||
if s.runningAsRoot {
|
||||
if _, ok := s.v.Fs.(*afero.OsFs); ok {
|
||||
err := os.Chown(targetPath, int(file.UID), int(file.GID))
|
||||
err = os.Chown(targetPath, int(file.UID), int(file.GID))
|
||||
if err != nil {
|
||||
log.Debug("Failed to set ownership", "path", targetPath, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err := s.v.Fs.Chtimes(targetPath, file.MTime, file.MTime)
|
||||
err = s.v.Fs.Chtimes(targetPath, file.MTime, file.MTime)
|
||||
if err != nil {
|
||||
log.Debug("Failed to set mtime", "path", targetPath, "error", err)
|
||||
}
|
||||
@@ -1068,30 +948,17 @@ func (s *restoreSession) restoreRegularFile(
|
||||
|
||||
t0 = time.Now()
|
||||
|
||||
// Remove any existing entry, then create the file with a restrictive
|
||||
// mode via O_EXCL. The stored mode is applied only after the content
|
||||
// is written and the file closed, so a file whose stored mode is
|
||||
// restrictive is never briefly readable by other local users while
|
||||
// its content is written. Removing first (rather than failing on a
|
||||
// leftover file) matches the documented behaviour that re-running
|
||||
// restore overwrites partial output.
|
||||
_ = s.v.Fs.Remove(targetPath)
|
||||
|
||||
outFile, err := s.v.Fs.OpenFile(
|
||||
targetPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, restoreFileMode)
|
||||
outFile, err := s.v.Fs.Create(targetPath)
|
||||
createDur := time.Since(t0)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating output file: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = outFile.Close() }()
|
||||
|
||||
bytesWritten, timings, err := s.writeFileChunks(outFile, fileChunks)
|
||||
if err != nil {
|
||||
// Do not leave a partial file behind.
|
||||
_ = outFile.Close()
|
||||
|
||||
s.removePartialRestore(targetPath)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1109,12 +976,9 @@ func (s *restoreSession) restoreRegularFile(
|
||||
|
||||
err = outFile.Close()
|
||||
if err != nil {
|
||||
s.removePartialRestore(targetPath)
|
||||
|
||||
return fmt.Errorf("closing output file: %w", err)
|
||||
}
|
||||
|
||||
s.applyRestoredFileMode(file, targetPath)
|
||||
s.applyFileMetadata(file, targetPath)
|
||||
|
||||
s.result.FilesRestored++
|
||||
@@ -1125,31 +989,6 @@ func (s *restoreSession) restoreRegularFile(
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyRestoredFileMode applies the stored permission bits to a
|
||||
// just-written regular file (created with restoreFileMode). A failure is
|
||||
// a user-visible warning, not a fatal error: the file's content is
|
||||
// intact and it remains at the restrictive create-time mode, so the
|
||||
// restore is not aborted or discarded over it.
|
||||
func (s *restoreSession) applyRestoredFileMode(
|
||||
file *database.File, targetPath string,
|
||||
) {
|
||||
err := s.v.Fs.Chmod(targetPath, os.FileMode(file.Mode))
|
||||
if err != nil {
|
||||
s.v.UI.Warningf("Failed to set mode %s on %s: %v",
|
||||
os.FileMode(file.Mode).Perm(), s.v.UI.Path(targetPath), err)
|
||||
}
|
||||
}
|
||||
|
||||
// removePartialRestore deletes a restore output file whose write did not
|
||||
// complete, so a failed restore never leaves a partial file behind.
|
||||
func (s *restoreSession) removePartialRestore(targetPath string) {
|
||||
err := s.v.Fs.Remove(targetPath)
|
||||
if err != nil {
|
||||
log.Debug("Failed to remove partial restore file",
|
||||
"path", targetPath, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// writeFileChunks streams each of the file's chunks from the blob disk
|
||||
// cache into outFile, crediting restored bytes to the sweeper as it
|
||||
// goes. Returns the bytes written plus per-phase timing accumulators.
|
||||
@@ -1162,12 +1001,6 @@ func (s *restoreSession) writeFileChunks(
|
||||
)
|
||||
|
||||
for _, fc := range fileChunks {
|
||||
// Stop between chunks on cancel so an interrupt does not keep
|
||||
// writing a large file after the operation has been told to stop.
|
||||
if s.ctx.Err() != nil {
|
||||
return bytesWritten, timings, s.ctx.Err()
|
||||
}
|
||||
|
||||
chunkHashStr := fc.ChunkHash.String()
|
||||
|
||||
blobChunk, ok := s.chunkToBlobMap[chunkHashStr]
|
||||
@@ -1220,12 +1053,12 @@ func (s *restoreSession) writeFileChunks(
|
||||
// size, which is what makes multi-GB blobs tractable on machines with
|
||||
// less RAM than the blob.
|
||||
func (s *restoreSession) downloadBlobToCache(
|
||||
blobHash string, compressedSize, uncompressedSize int64,
|
||||
blobHash string, expectedSize int64,
|
||||
) error {
|
||||
start := time.Now()
|
||||
|
||||
t0 := time.Now()
|
||||
rc, err := s.v.FetchAndDecryptBlob(s.ctx, blobHash, uncompressedSize, s.identities...)
|
||||
rc, err := s.v.FetchAndDecryptBlob(s.ctx, blobHash, expectedSize, s.identity)
|
||||
fetchSetupDur := time.Since(t0)
|
||||
|
||||
if err != nil {
|
||||
@@ -1237,25 +1070,17 @@ func (s *restoreSession) downloadBlobToCache(
|
||||
streamDur := time.Since(t0)
|
||||
closeErr := rc.Close()
|
||||
|
||||
// closeErr carries the blob's hash-verification result (a mismatch,
|
||||
// or the stream not being fully read). On any failure, drop the
|
||||
// cache entry so a blob that failed verification is never read back
|
||||
// as if it were valid.
|
||||
if copyErr != nil {
|
||||
s.blobCache.Delete(blobHash)
|
||||
|
||||
return copyErr
|
||||
}
|
||||
|
||||
if closeErr != nil {
|
||||
s.blobCache.Delete(blobHash)
|
||||
|
||||
return closeErr
|
||||
}
|
||||
|
||||
log.Debug("Streamed blob into disk cache",
|
||||
"hash", blobHash[:16],
|
||||
"compressed_bytes", compressedSize,
|
||||
"compressed_bytes", expectedSize,
|
||||
"plaintext_bytes", written,
|
||||
"ms_total", time.Since(start).Milliseconds(),
|
||||
"ms_fetch_setup", fetchSetupDur.Milliseconds(),
|
||||
@@ -1417,17 +1242,6 @@ func (v *Vaultik) verifyFile(
|
||||
bytesVerified += int64(n)
|
||||
}
|
||||
|
||||
// The stored chunks account for the whole file, so the reader must
|
||||
// be at EOF now. Trailing bytes past the last chunk are corruption
|
||||
// the per-chunk loop cannot see.
|
||||
extra := make([]byte, 1)
|
||||
|
||||
n, err := f.Read(extra)
|
||||
if n != 0 || !errors.Is(err, io.EOF) {
|
||||
return bytesVerified, fmt.Errorf("%w: file longer than its %d chunk(s)",
|
||||
errTrailingRestoreData, len(fileChunks))
|
||||
}
|
||||
|
||||
log.Debug("File verified",
|
||||
"path", file.Path, "bytes", bytesVerified, "chunks", len(fileChunks))
|
||||
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
package vaultik //nolint:testpackage // exercises unexported restoreIdentities
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||
"sneak.berlin/go/vaultik/internal/config"
|
||||
)
|
||||
|
||||
// encryptBlobTo returns a blobgen blob of plaintext encrypted to exactly
|
||||
// one recipient, so a decryptor succeeds only if it holds that recipient's
|
||||
// identity.
|
||||
func encryptBlobTo(t *testing.T, recipient string, plaintext []byte) []byte {
|
||||
t.Helper()
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
writer, err := blobgen.NewWriter(&buf, 1, []string{recipient})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = writer.Write(plaintext)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, writer.Close())
|
||||
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// decryptBlobWith reads a blob back through the identities and returns its
|
||||
// plaintext.
|
||||
func decryptBlobWith(t *testing.T, blob []byte, identities []age.Identity) []byte {
|
||||
t.Helper()
|
||||
|
||||
reader, err := blobgen.NewReader(bytes.NewReader(blob), identities...)
|
||||
require.NoError(t, err)
|
||||
|
||||
plaintext, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, reader.Close())
|
||||
|
||||
return plaintext
|
||||
}
|
||||
|
||||
// TestRestoreIdentitiesAcceptsEveryIdentity proves a key file holding two
|
||||
// identities yields both, so a blob encrypted only to the second
|
||||
// recipient — the one the previous single-identity parse dropped — still
|
||||
// decrypts.
|
||||
func TestRestoreIdentitiesAcceptsEveryIdentity(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
first, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
|
||||
second, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
|
||||
// A whole age-keygen-style file: comment lines plus two identity lines.
|
||||
keyFile := "# public key: " + first.Recipient().String() + "\n" +
|
||||
first.String() + "\n" +
|
||||
"# public key: " + second.Recipient().String() + "\n" +
|
||||
second.String() + "\n"
|
||||
|
||||
v := &Vaultik{Config: &config.Config{AgeSecretKey: keyFile}}
|
||||
|
||||
identities, err := v.restoreIdentities()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, identities, 2)
|
||||
|
||||
plaintext := []byte("payload encrypted only to the second identity")
|
||||
blob := encryptBlobTo(t, second.Recipient().String(), plaintext)
|
||||
|
||||
require.Equal(t, plaintext, decryptBlobWith(t, blob, identities))
|
||||
}
|
||||
|
||||
// TestRestoreIdentitiesAcceptsTrailingNewline mirrors a YAML
|
||||
// age_secret_key value that carries a trailing newline: it must still
|
||||
// parse to its one identity and decrypt a blob encrypted to it.
|
||||
func TestRestoreIdentitiesAcceptsTrailingNewline(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
id, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
|
||||
v := &Vaultik{Config: &config.Config{AgeSecretKey: id.String() + "\n"}}
|
||||
|
||||
identities, err := v.restoreIdentities()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, identities, 1)
|
||||
|
||||
plaintext := []byte("value with a trailing newline")
|
||||
blob := encryptBlobTo(t, id.Recipient().String(), plaintext)
|
||||
|
||||
require.Equal(t, plaintext, decryptBlobWith(t, blob, identities))
|
||||
}
|
||||
|
||||
// TestRestoreIdentitiesMissingKey reports the dedicated missing-key error
|
||||
// rather than a parse failure, so the user is told to set the key.
|
||||
func TestRestoreIdentitiesMissingKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
v := &Vaultik{Config: &config.Config{}}
|
||||
|
||||
_, err := v.restoreIdentities()
|
||||
require.ErrorIs(t, err, errDecryptionKeyRequired)
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
package vaultik //nolint:testpackage // sets ctx/cancel and inspects scratch files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
)
|
||||
|
||||
// blockingBlobStorer wraps a Storer and blocks the first blob download
|
||||
// until its context is cancelled, so a test can catch a restore while it
|
||||
// is mid-download. Metadata reads pass straight through, so the restore
|
||||
// reaches the blob-download phase — having already written its decrypted
|
||||
// scratch files — before it blocks.
|
||||
type blockingBlobStorer struct {
|
||||
storage.Storer
|
||||
|
||||
once sync.Once
|
||||
entered chan struct{}
|
||||
}
|
||||
|
||||
func newBlockingBlobStorer(inner storage.Storer) *blockingBlobStorer {
|
||||
return &blockingBlobStorer{Storer: inner, entered: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (b *blockingBlobStorer) Get(
|
||||
ctx context.Context, key string,
|
||||
) (io.ReadCloser, error) {
|
||||
if strings.HasPrefix(key, "blobs/") {
|
||||
b.once.Do(func() { close(b.entered) })
|
||||
<-ctx.Done()
|
||||
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
return b.Storer.Get(ctx, key)
|
||||
}
|
||||
|
||||
// TestRestoreCleansTempDirOnInterrupt drives a restore through the stop
|
||||
// path (v.StartOperation, which is what the fx OnStop hook uses) instead
|
||||
// of calling Restore directly, catches it mid-download, and asserts that
|
||||
// stopping waits for the operation to unwind and removes its decrypted
|
||||
// scratch files — the blob cache and the temporary snapshot database —
|
||||
// from the temp directory. Without the wait a SIGINT exits the process
|
||||
// before those defers run, leaving decrypted data on disk (issue #159).
|
||||
//
|
||||
// Not parallel: it points TMPDIR at a private directory (via t.Setenv)
|
||||
// so it can assert on exactly the scratch files this restore created.
|
||||
func TestRestoreCleansTempDirOnInterrupt(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
root := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(root, "source")
|
||||
storeDir := filepath.Join(root, "remote")
|
||||
restoreDir := filepath.Join(root, "restored")
|
||||
dbPath := filepath.Join(root, "index.sqlite")
|
||||
|
||||
require.NoError(t, fs.MkdirAll(dataDir, 0o755))
|
||||
|
||||
buildLocalityFixture(t, fs, dataDir)
|
||||
|
||||
cfg, storer, snapshotID := setupLocalityBackup(
|
||||
context.Background(), t, fs, dataDir, storeDir, dbPath)
|
||||
|
||||
// Point the "" temp paths (the blob cache directory and the
|
||||
// snapshot-database directory) at a private directory so the test can
|
||||
// assert on exactly the scratch this restore creates.
|
||||
scratch := filepath.Join(root, "scratch")
|
||||
require.NoError(t, fs.MkdirAll(scratch, 0o755))
|
||||
t.Setenv("TMPDIR", scratch)
|
||||
|
||||
gate := newBlockingBlobStorer(storer)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
v := &Vaultik{
|
||||
Config: cfg,
|
||||
Storage: gate,
|
||||
Fs: fs,
|
||||
Stdout: io.Discard,
|
||||
Stderr: io.Discard,
|
||||
UI: ui.NewWithColor(io.Discard, false),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
var (
|
||||
opReturned atomic.Bool
|
||||
restoreErr error
|
||||
)
|
||||
|
||||
stop := v.StartOperation(func() {
|
||||
defer opReturned.Store(true)
|
||||
|
||||
restoreErr = v.Restore(&RestoreOptions{
|
||||
SnapshotID: snapshotID,
|
||||
TargetDir: restoreDir,
|
||||
})
|
||||
})
|
||||
|
||||
// Wait until the restore is blocked mid-download; its decrypted
|
||||
// scratch files exist by now.
|
||||
select {
|
||||
case <-gate.entered:
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("restore never reached the blob-download phase")
|
||||
}
|
||||
|
||||
require.NotEmpty(t, scratchEntries(t, scratch),
|
||||
"expected decrypted scratch files to exist mid-restore")
|
||||
|
||||
// Stop the operation the way the fx OnStop hook does.
|
||||
stopCtx, stopCancel := context.WithTimeout(
|
||||
context.Background(), 30*time.Second)
|
||||
defer stopCancel()
|
||||
|
||||
require.True(t, stop(stopCtx),
|
||||
"stop timed out; the operation goroutine did not return")
|
||||
|
||||
// stop returns only once the operation goroutine has returned, so its
|
||||
// cleanup defers have run by the time we read these.
|
||||
require.True(t, opReturned.Load(),
|
||||
"stop returned before the operation goroutine finished")
|
||||
require.ErrorIs(t, restoreErr, context.Canceled)
|
||||
require.Empty(t, scratchEntries(t, scratch),
|
||||
"decrypted scratch files remained after the interrupt")
|
||||
}
|
||||
|
||||
// scratchEntries returns the vaultik blob-cache and snapshot-database
|
||||
// scratch entries currently present in dir.
|
||||
func scratchEntries(t *testing.T, dir string) []string {
|
||||
t.Helper()
|
||||
|
||||
var matches []string
|
||||
|
||||
for _, pattern := range []string{
|
||||
"vaultik-blobcache-*", "vaultik-restore-*",
|
||||
} {
|
||||
found, err := filepath.Glob(filepath.Join(dir, pattern))
|
||||
require.NoError(t, err)
|
||||
|
||||
matches = append(matches, found...)
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/config"
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
|
||||
// TestRestoreRejectsMalformedKeyBeforeDownload verifies that a malformed
|
||||
// age secret key stops restore at the parse step: nothing is fetched from
|
||||
// the store, and the error does not echo the key value (which is secret).
|
||||
func TestRestoreRejectsMalformedKeyBeforeDownload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const malformed = "this-is-not-a-valid-age-key"
|
||||
|
||||
mock := NewMockStorer()
|
||||
|
||||
v := &vaultik.Vaultik{
|
||||
Config: &config.Config{AgeSecretKey: malformed},
|
||||
Storage: mock,
|
||||
Stdout: io.Discard,
|
||||
Stderr: io.Discard,
|
||||
UI: ui.NewWithColor(io.Discard, false),
|
||||
}
|
||||
v.SetContext(context.Background())
|
||||
|
||||
err := v.Restore(&vaultik.RestoreOptions{
|
||||
SnapshotID: "any-snapshot",
|
||||
TargetDir: t.TempDir(),
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.NotContains(t, err.Error(), malformed,
|
||||
"error must not echo the key value")
|
||||
require.Contains(t, err.Error(), "age_secret_key",
|
||||
"error should name the configuration source")
|
||||
require.Empty(t, mock.GetCalls(),
|
||||
"a malformed key must fail before anything is fetched")
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
package vaultik //nolint:testpackage // drives restore through unexported session
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/config"
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
)
|
||||
|
||||
// errSpyWrite is the injected write failure used to exercise the
|
||||
// partial-file cleanup path.
|
||||
var errSpyWrite = errors.New("injected write failure")
|
||||
|
||||
// modeSpyFs wraps a real filesystem so restore tests can observe and
|
||||
// perturb the single output file whose path contains watch. It records
|
||||
// the on-disk permission bits seen at the moment content is first
|
||||
// written (the window during which another local user could read it),
|
||||
// and can inject a write failure or append trailing bytes on close.
|
||||
type modeSpyFs struct {
|
||||
afero.Fs
|
||||
|
||||
watch string
|
||||
|
||||
mu sync.Mutex
|
||||
writeModes []os.FileMode
|
||||
failWrite bool
|
||||
trailing int
|
||||
}
|
||||
|
||||
//nolint:ireturn // afero.Fs.OpenFile is defined to return the interface
|
||||
func (m *modeSpyFs) OpenFile(
|
||||
name string, flag int, perm os.FileMode,
|
||||
) (afero.File, error) {
|
||||
f, err := m.Fs.OpenFile(name, flag, perm)
|
||||
if err != nil || !strings.Contains(name, m.watch) {
|
||||
return f, err
|
||||
}
|
||||
|
||||
return &modeSpyFile{File: f, fs: m, path: name}, nil
|
||||
}
|
||||
|
||||
type modeSpyFile struct {
|
||||
afero.File
|
||||
|
||||
fs *modeSpyFs
|
||||
path string
|
||||
written bool
|
||||
}
|
||||
|
||||
func (f *modeSpyFile) Write(p []byte) (int, error) {
|
||||
if !f.written {
|
||||
f.written = true
|
||||
|
||||
info, err := f.fs.Stat(f.path)
|
||||
if err == nil {
|
||||
f.fs.mu.Lock()
|
||||
f.fs.writeModes = append(f.fs.writeModes, info.Mode().Perm())
|
||||
f.fs.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
if f.fs.failWrite {
|
||||
return 0, errSpyWrite
|
||||
}
|
||||
|
||||
return f.File.Write(p)
|
||||
}
|
||||
|
||||
func (f *modeSpyFile) Close() error {
|
||||
if f.fs.trailing > 0 {
|
||||
_, _ = f.File.Write(bytes.Repeat([]byte{'x'}, f.fs.trailing))
|
||||
}
|
||||
|
||||
return f.File.Close()
|
||||
}
|
||||
|
||||
// backupOneFile writes a single source file with the given mode and
|
||||
// backs it up into a fresh file storer, returning everything a restore
|
||||
// needs. The index database is closed before returning so the restore
|
||||
// half runs from the exported metadata and remote bytes only.
|
||||
func backupOneFile(
|
||||
ctx context.Context, t *testing.T, fs afero.Fs, tempDir, name string,
|
||||
content []byte, mode os.FileMode,
|
||||
) (*config.Config, *storage.FileStorer, string, string) {
|
||||
t.Helper()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "src")
|
||||
require.NoError(t, fs.MkdirAll(dataDir, 0o755))
|
||||
|
||||
srcPath := filepath.Join(dataDir, name)
|
||||
require.NoError(t, afero.WriteFile(fs, srcPath, content, mode))
|
||||
require.NoError(t, fs.Chmod(srcPath, mode))
|
||||
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
storer, err := storage.NewFileStorer(storeDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := &config.Config{
|
||||
AgeRecipients: []string{
|
||||
"age1ezrjmfpwsc95svdg0y54mums3zevgzu0x0ecq2f7tp8a05gl0sjq9q9wjg",
|
||||
},
|
||||
AgeSecretKey: "AGE-SECRET-KEY-19CR5YSFW59HM4TLD6GXVEDMZFTVVF7PPHKU" +
|
||||
"T68TXSFPK7APHXA2QS2NJA5",
|
||||
CompressionLevel: 3,
|
||||
Hostname: "test-host",
|
||||
BlobSizeLimit: config.Size(5 * 1024 * 1024),
|
||||
}
|
||||
|
||||
db, err := database.New(ctx, dbPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
|
||||
sm := snapshot.NewSnapshotManager(snapshot.SnapshotManagerParams{
|
||||
Repos: repos,
|
||||
Storage: storer,
|
||||
Config: cfg,
|
||||
})
|
||||
sm.SetFilesystem(fs)
|
||||
|
||||
scanner := snapshot.NewScanner(snapshot.ScannerConfig{
|
||||
FS: fs,
|
||||
Storage: storer,
|
||||
ChunkSize: 4 * 1024 * 1024,
|
||||
MaxBlobSize: 5 * 1024 * 1024,
|
||||
CompressionLevel: cfg.CompressionLevel,
|
||||
AgeRecipients: cfg.AgeRecipients,
|
||||
Repositories: repos,
|
||||
})
|
||||
|
||||
snapshotID, err := sm.CreateSnapshotWithName(
|
||||
ctx, cfg.Hostname, "perms", "test-version", "test-git")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = scanner.Scan(ctx, dataDir, snapshotID)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, sm.CompleteSnapshot(ctx, snapshotID))
|
||||
require.NoError(t, sm.ExportSnapshotMetadata(ctx, dbPath, snapshotID))
|
||||
require.NoError(t, db.Close())
|
||||
|
||||
return cfg, storer, snapshotID, srcPath
|
||||
}
|
||||
|
||||
// restoredPathFor returns where backupOneFile's source lands under a
|
||||
// restore target: restore recreates each file at its original absolute
|
||||
// path beneath TargetDir.
|
||||
func restoredPathFor(restoreDir, srcPath string) string {
|
||||
return filepath.Join(restoreDir, srcPath)
|
||||
}
|
||||
|
||||
// withUmask022 forces the process umask to 022 for the duration of a
|
||||
// test, so the difference between a 0600 create and a default create is
|
||||
// observable. Restored serially (no t.Parallel) so it does not race
|
||||
// other tests.
|
||||
func withUmask022(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
old := syscall.Umask(0o022)
|
||||
|
||||
t.Cleanup(func() { syscall.Umask(old) })
|
||||
}
|
||||
|
||||
// TestRestoreCreatesFileNeverWiderThanStoredMode checks that a file with
|
||||
// a restrictive stored mode (0600) is never observable with a wider mode
|
||||
// while its content is being written, and ends at its stored mode.
|
||||
//
|
||||
//nolint:paralleltest // sets the process umask; must run serially
|
||||
func TestRestoreCreatesFileNeverWiderThanStoredMode(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
withUmask022(t)
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
ctx := context.Background()
|
||||
|
||||
content := randomBytes(t, 4096)
|
||||
|
||||
cfg, storer, snapshotID, srcPath := backupOneFile(
|
||||
ctx, t, fs, tempDir, "secret.bin", content, 0o600)
|
||||
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
spy := &modeSpyFs{Fs: fs, watch: "secret.bin"}
|
||||
|
||||
v := newRestoreVaultik(ctx, cfg, storer, spy)
|
||||
require.NoError(t, v.Restore(&RestoreOptions{
|
||||
SnapshotID: snapshotID,
|
||||
TargetDir: restoreDir,
|
||||
}))
|
||||
|
||||
spy.mu.Lock()
|
||||
observed := append([]os.FileMode(nil), spy.writeModes...)
|
||||
spy.mu.Unlock()
|
||||
|
||||
require.NotEmpty(t, observed,
|
||||
"spy never saw the output file being written")
|
||||
|
||||
for _, m := range observed {
|
||||
assert.Equalf(t, os.FileMode(0o600), m,
|
||||
"file was observable at mode %o during write; must be 0600", m)
|
||||
}
|
||||
|
||||
// The stored mode is applied after the content is written.
|
||||
info, err := fs.Stat(restoredPathFor(restoreDir, srcPath))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, os.FileMode(0o600), info.Mode().Perm())
|
||||
|
||||
got, err := afero.ReadFile(fs, restoredPathFor(restoreDir, srcPath))
|
||||
require.NoError(t, err)
|
||||
require.True(t, bytes.Equal(got, content))
|
||||
}
|
||||
|
||||
// TestRestoreRemovesPartialFileOnWriteFailure checks that a file whose
|
||||
// content write fails is not left behind.
|
||||
//
|
||||
//nolint:paralleltest // sets the process umask; must run serially
|
||||
func TestRestoreRemovesPartialFileOnWriteFailure(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
withUmask022(t)
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
ctx := context.Background()
|
||||
|
||||
cfg, storer, snapshotID, srcPath := backupOneFile(
|
||||
ctx, t, fs, tempDir, "doomed.bin", randomBytes(t, 4096), 0o600)
|
||||
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
spy := &modeSpyFs{Fs: fs, watch: "doomed.bin", failWrite: true}
|
||||
|
||||
v := newRestoreVaultik(ctx, cfg, storer, spy)
|
||||
err := v.Restore(&RestoreOptions{
|
||||
SnapshotID: snapshotID,
|
||||
TargetDir: restoreDir,
|
||||
})
|
||||
require.Error(t, err, "restore should fail when the write fails")
|
||||
|
||||
exists, err := afero.Exists(fs, restoredPathFor(restoreDir, srcPath))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, exists, "partial file must be removed after a failed write")
|
||||
}
|
||||
|
||||
// TestVerifyRejectsTrailingBytes checks that --verify fails a restored
|
||||
// file that has bytes past its last chunk.
|
||||
//
|
||||
//nolint:paralleltest // sets the process umask; must run serially
|
||||
func TestVerifyRejectsTrailingBytes(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
withUmask022(t)
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
ctx := context.Background()
|
||||
|
||||
cfg, storer, snapshotID, _ := backupOneFile(
|
||||
ctx, t, fs, tempDir, "padded.bin", randomBytes(t, 4096), 0o600)
|
||||
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
// Append one byte to the file as it is written, so its content still
|
||||
// matches the stored chunks but it is one byte too long.
|
||||
spy := &modeSpyFs{Fs: fs, watch: "padded.bin", trailing: 1}
|
||||
|
||||
v := newRestoreVaultik(ctx, cfg, storer, spy)
|
||||
err := v.Restore(&RestoreOptions{
|
||||
SnapshotID: snapshotID,
|
||||
TargetDir: restoreDir,
|
||||
Verify: true,
|
||||
})
|
||||
require.Error(t, err, "verify should fail on a file with trailing bytes")
|
||||
assert.ErrorIs(t, err, errFilesFailedVerify)
|
||||
}
|
||||
|
||||
// newRestoreVaultik builds a Vaultik wired for a restore-only test.
|
||||
func newRestoreVaultik(
|
||||
ctx context.Context, cfg *config.Config, storer storage.Storer, fs afero.Fs,
|
||||
) *Vaultik {
|
||||
v := &Vaultik{
|
||||
Config: cfg,
|
||||
Storage: storer,
|
||||
Fs: fs,
|
||||
Stdout: io.Discard,
|
||||
Stderr: io.Discard,
|
||||
UI: ui.NewWithColor(io.Discard, false),
|
||||
}
|
||||
v.SetContext(ctx)
|
||||
|
||||
return v
|
||||
}
|
||||
@@ -171,13 +171,10 @@ func (p *restorePlan) finishFile(fileID types.FileID) {
|
||||
// downloaded next, after which it — together with any other pending
|
||||
// files whose blob sets become empty — moves to the ready queue.
|
||||
//
|
||||
// The second return value is false when no file needs a download, so a
|
||||
// genuine file carrying the nil UUID is picked rather than mistaken for
|
||||
// "nothing left".
|
||||
func (p *restorePlan) pickNextDownload() (types.FileID, bool) {
|
||||
// The zero FileID return means nothing is pending.
|
||||
func (p *restorePlan) pickNextDownload() types.FileID {
|
||||
var best types.FileID
|
||||
|
||||
found := false
|
||||
bestCount := math.MaxInt
|
||||
|
||||
var bestID string
|
||||
@@ -191,15 +188,14 @@ func (p *restorePlan) pickNextDownload() (types.FileID, bool) {
|
||||
}
|
||||
|
||||
idStr := id.String()
|
||||
if !found || n < bestCount || (n == bestCount && idStr < bestID) {
|
||||
if n < bestCount || (n == bestCount && (best.IsZero() || idStr < bestID)) {
|
||||
best = id
|
||||
found = true
|
||||
bestCount = n
|
||||
bestID = idStr
|
||||
}
|
||||
}
|
||||
|
||||
return best, found
|
||||
return best
|
||||
}
|
||||
|
||||
// blobsNeeded returns the uncached blob hashes for fileID in any order.
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
package vaultik //nolint:testpackage // inspects unexported restore plan internals
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
"sneak.berlin/go/vaultik/internal/types"
|
||||
)
|
||||
|
||||
// TestPickNextDownloadReturnsNilUUIDFile proves a genuine pending file
|
||||
// carrying the nil UUID is picked for download rather than mistaken for
|
||||
// "nothing left" — the bug that could abandon every remaining file.
|
||||
func TestPickNextDownloadReturnsNilUUIDFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var nilID types.FileID // zero value is the nil UUID
|
||||
|
||||
plan := &restorePlan{
|
||||
fileBlobs: map[types.FileID]map[string]struct{}{
|
||||
nilID: {"blobhash": {}},
|
||||
},
|
||||
}
|
||||
|
||||
id, ok := plan.pickNextDownload()
|
||||
require.True(t, ok,
|
||||
"pickNextDownload treated a pending nil-UUID file as nothing to do")
|
||||
require.True(t, id.IsZero(), "expected the nil-UUID file to be picked")
|
||||
}
|
||||
|
||||
// TestPickNextDownloadEmptyPlan confirms the second return value is false
|
||||
// only when no file needs a download.
|
||||
func TestPickNextDownloadEmptyPlan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
plan := &restorePlan{
|
||||
fileBlobs: map[types.FileID]map[string]struct{}{},
|
||||
}
|
||||
|
||||
_, ok := plan.pickNextDownload()
|
||||
require.False(t, ok, "pickNextDownload reported work on an empty plan")
|
||||
}
|
||||
|
||||
// TestRunRestoreLoopFailsOnAbandonedFiles proves the loop returns an
|
||||
// error rather than silent success when files remain pending after it
|
||||
// can make no further progress.
|
||||
func TestRunRestoreLoopFailsOnAbandonedFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
db, err := database.NewTestDB()
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
|
||||
cache, err := newBlobDiskCache(math.MaxInt64)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() { _ = cache.Close() })
|
||||
|
||||
v := &Vaultik{ctx: ctx}
|
||||
session := &restoreSession{
|
||||
v: v,
|
||||
ctx: ctx,
|
||||
repos: repos,
|
||||
sweeper: newRestoreSweeper(ctx, repos, cache, 1),
|
||||
result: &RestoreResult{},
|
||||
}
|
||||
|
||||
// A file that is still pending but whose uncached-blob set is empty
|
||||
// and which was never queued as ready: the loop can neither restore
|
||||
// nor download it. This is the abandonment the guard must catch.
|
||||
var stuck types.FileID
|
||||
|
||||
plan := &restorePlan{
|
||||
fileBlobs: map[types.FileID]map[string]struct{}{stuck: {}},
|
||||
blobFiles: map[string]map[types.FileID]struct{}{},
|
||||
cached: map[string]struct{}{},
|
||||
}
|
||||
|
||||
err = v.runRestoreLoop(session, plan, map[types.FileID]*database.File{}, 0)
|
||||
require.ErrorIs(t, err, errRestoreIncomplete)
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/config"
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
|
||||
// TestRestoreAndDeepVerifyRejectSwappedDatabase proves that swapping two
|
||||
// snapshots' encrypted databases on the store is caught. age decryption
|
||||
// alone proves only that a database is readable; without an identity check
|
||||
// restore would happily write the wrong snapshot's files and deep verify
|
||||
// would report success. After the swap, restore and deep verify of A both
|
||||
// fail, and the error names the snapshot the database actually holds (B).
|
||||
func TestRestoreAndDeepVerifyRejectSwappedDatabase(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
|
||||
chunkSize := int64(64 * 1024)
|
||||
|
||||
storer, err := storage.NewFileStorer(storeDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Two snapshots with different content, backed up into one shared
|
||||
// store. Different names give them different remote keys, so their
|
||||
// metadata directories are distinct and can be tampered with alone.
|
||||
dataA := filepath.Join(tempDir, "srcA")
|
||||
require.NoError(t, fs.MkdirAll(dataA, 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, filepath.Join(dataA, "a.bin"),
|
||||
bytesPattern("alpha-", int(chunkSize*2)), 0o644))
|
||||
|
||||
dataB := filepath.Join(tempDir, "srcB")
|
||||
require.NoError(t, fs.MkdirAll(dataB, 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, filepath.Join(dataB, "b.bin"),
|
||||
bytesPattern("beta-", int(chunkSize*2)), 0o644))
|
||||
|
||||
idA := backupNamedSnapshotToStore(ctx, t, fs, dataA, storer,
|
||||
filepath.Join(tempDir, "idxA.sqlite"), "alpha")
|
||||
idB := backupNamedSnapshotToStore(ctx, t, fs, dataB, storer,
|
||||
filepath.Join(tempDir, "idxB.sqlite"), "beta")
|
||||
require.NotEqual(t, idA, idB)
|
||||
|
||||
keyA := snapshot.RemoteSnapshotKey(idA)
|
||||
keyB := snapshot.RemoteSnapshotKey(idB)
|
||||
require.NotEqual(t, keyA, keyB)
|
||||
|
||||
// Baseline: each snapshot verifies against its own intact metadata.
|
||||
require.NoError(t, newStoreClient(ctx, t, fs, storer).RunDeepVerify(
|
||||
idA, &vaultik.VerifyOptions{Deep: true}))
|
||||
require.NoError(t, newStoreClient(ctx, t, fs, storer).RunDeepVerify(
|
||||
idB, &vaultik.VerifyOptions{Deep: true}))
|
||||
|
||||
// Swap the two snapshots' encrypted databases on the store.
|
||||
swapStoreFiles(t, fs,
|
||||
filepath.Join(storeDir, "metadata", keyA, "db.zst.age"),
|
||||
filepath.Join(storeDir, "metadata", keyB, "db.zst.age"))
|
||||
|
||||
// Restore of A now decrypts B's database; the identity check must
|
||||
// reject it and name the snapshot it actually found.
|
||||
restoreErr := newStoreClient(ctx, t, fs, storer).Restore(&vaultik.RestoreOptions{
|
||||
SnapshotID: idA,
|
||||
TargetDir: filepath.Join(tempDir, "restoreA"),
|
||||
})
|
||||
require.Error(t, restoreErr)
|
||||
require.ErrorContains(t, restoreErr, idB)
|
||||
|
||||
// Deep verify of A must reject the swapped database for the same reason.
|
||||
verifyErr := newStoreClient(ctx, t, fs, storer).RunDeepVerify(
|
||||
idA, &vaultik.VerifyOptions{Deep: true})
|
||||
require.Error(t, verifyErr)
|
||||
require.ErrorContains(t, verifyErr, idB)
|
||||
}
|
||||
|
||||
// TestDeepVerifyRejectsSwappedDatabaseWithEmptyManifest covers the case the
|
||||
// issue calls out: swapping in a database whose blob set is empty and
|
||||
// pairing it with an equally empty manifest. The manifest then agrees with
|
||||
// the database, so every blob-level check passes and deep verify used to
|
||||
// report success with zero blobs verified. The identity check rejects it
|
||||
// before any blob check runs.
|
||||
func TestDeepVerifyRejectsSwappedDatabaseWithEmptyManifest(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
|
||||
chunkSize := int64(64 * 1024)
|
||||
|
||||
storer, err := storage.NewFileStorer(storeDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Snapshot A: real content, so its manifest lists blobs.
|
||||
dataA := filepath.Join(tempDir, "srcA")
|
||||
require.NoError(t, fs.MkdirAll(dataA, 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, filepath.Join(dataA, "a.bin"),
|
||||
bytesPattern("alpha-", int(chunkSize*2)), 0o644))
|
||||
idA := backupNamedSnapshotToStore(ctx, t, fs, dataA, storer,
|
||||
filepath.Join(tempDir, "idxA.sqlite"), "alpha")
|
||||
|
||||
// Snapshot C: a single empty file, so it references no blobs and its
|
||||
// manifest is empty. This is the database/manifest pair an attacker
|
||||
// would swap in to make the blob checks vacuously pass.
|
||||
dataC := filepath.Join(tempDir, "srcC")
|
||||
require.NoError(t, fs.MkdirAll(dataC, 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs,
|
||||
filepath.Join(dataC, "empty.bin"), []byte{}, 0o644))
|
||||
idC := backupNamedSnapshotToStore(ctx, t, fs, dataC, storer,
|
||||
filepath.Join(tempDir, "idxC.sqlite"), "charlie")
|
||||
|
||||
keyA := snapshot.RemoteSnapshotKey(idA)
|
||||
keyC := snapshot.RemoteSnapshotKey(idC)
|
||||
|
||||
// Replace A's database and manifest with C's empty pair.
|
||||
copyStoreFile(t, fs,
|
||||
filepath.Join(storeDir, "metadata", keyC, "db.zst.age"),
|
||||
filepath.Join(storeDir, "metadata", keyA, "db.zst.age"))
|
||||
copyStoreFile(t, fs,
|
||||
filepath.Join(storeDir, "metadata", keyC, "manifest.json.zst"),
|
||||
filepath.Join(storeDir, "metadata", keyA, "manifest.json.zst"))
|
||||
|
||||
verifyErr := newStoreClient(ctx, t, fs, storer).RunDeepVerify(
|
||||
idA, &vaultik.VerifyOptions{Deep: true})
|
||||
require.Error(t, verifyErr)
|
||||
require.ErrorContains(t, verifyErr, idC)
|
||||
}
|
||||
|
||||
// backupNamedSnapshotToStore backs up dataDir into the shared storer under
|
||||
// the given snapshot name and returns the human snapshot ID. Two snapshots
|
||||
// backed up under different names get different remote keys, so their
|
||||
// metadata directories on the store are distinct.
|
||||
func backupNamedSnapshotToStore(
|
||||
ctx context.Context, t *testing.T, fs afero.Fs,
|
||||
dataDir string, storer storage.Storer, dbPath, name string,
|
||||
) string {
|
||||
t.Helper()
|
||||
|
||||
const (
|
||||
chunkSize = int64(64 * 1024)
|
||||
maxBlobSize = int64(512 * 1024)
|
||||
)
|
||||
|
||||
cfg := &config.Config{
|
||||
AgeRecipients: []string{testAgePublicKey},
|
||||
AgeSecretKey: testAgeSecretKey,
|
||||
CompressionLevel: 3,
|
||||
Hostname: testHostname,
|
||||
}
|
||||
|
||||
db, err := database.New(ctx, dbPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
|
||||
sm := snapshot.NewSnapshotManager(snapshot.SnapshotManagerParams{
|
||||
Repos: repos,
|
||||
Storage: storer,
|
||||
Config: cfg,
|
||||
})
|
||||
sm.SetFilesystem(fs)
|
||||
|
||||
scanner := snapshot.NewScanner(snapshot.ScannerConfig{
|
||||
FS: fs,
|
||||
Storage: storer,
|
||||
ChunkSize: chunkSize,
|
||||
MaxBlobSize: maxBlobSize,
|
||||
CompressionLevel: cfg.CompressionLevel,
|
||||
AgeRecipients: cfg.AgeRecipients,
|
||||
Repositories: repos,
|
||||
})
|
||||
|
||||
snapshotID, err := sm.CreateSnapshotWithName(
|
||||
ctx, cfg.Hostname, name, "test-version", "test-git")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = scanner.Scan(ctx, dataDir, snapshotID)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, sm.CompleteSnapshot(ctx, snapshotID))
|
||||
require.NoError(t, sm.ExportSnapshotMetadata(ctx, dbPath, snapshotID))
|
||||
require.NoError(t, db.Close())
|
||||
|
||||
return snapshotID
|
||||
}
|
||||
|
||||
// newStoreClient builds a Vaultik that reads only from the store: the
|
||||
// secret key, the storer, and a filesystem, with no local index. This is
|
||||
// what restore and deep verify need.
|
||||
func newStoreClient(
|
||||
ctx context.Context, t *testing.T, fs afero.Fs, storer storage.Storer,
|
||||
) *vaultik.Vaultik {
|
||||
t.Helper()
|
||||
|
||||
v := &vaultik.Vaultik{
|
||||
Config: &config.Config{
|
||||
AgeSecretKey: testAgeSecretKey,
|
||||
Hostname: testHostname,
|
||||
},
|
||||
Storage: storer,
|
||||
Fs: fs,
|
||||
Stdout: io.Discard,
|
||||
Stderr: io.Discard,
|
||||
UI: ui.NewWithColor(io.Discard, false),
|
||||
}
|
||||
v.SetContext(ctx)
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
// swapStoreFiles exchanges the contents of two files on the store.
|
||||
func swapStoreFiles(t *testing.T, fs afero.Fs, a, b string) {
|
||||
t.Helper()
|
||||
|
||||
dataA, err := afero.ReadFile(fs, a)
|
||||
require.NoError(t, err)
|
||||
|
||||
dataB, err := afero.ReadFile(fs, b)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, afero.WriteFile(fs, a, dataB, 0o644))
|
||||
require.NoError(t, afero.WriteFile(fs, b, dataA, 0o644))
|
||||
}
|
||||
|
||||
// copyStoreFile overwrites dst with the contents of src on the store.
|
||||
func copyStoreFile(t *testing.T, fs afero.Fs, src, dst string) {
|
||||
t.Helper()
|
||||
|
||||
data, err := afero.ReadFile(fs, src)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, afero.WriteFile(fs, dst, data, 0o644))
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
package vaultik //nolint:testpackage // inspects unexported snapshot-db materialization
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
)
|
||||
|
||||
// genuineSnapshotDBBytes returns the on-disk bytes of a real snapshot
|
||||
// database (the full schema applied).
|
||||
func genuineSnapshotDBBytes(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "snapshot.db")
|
||||
|
||||
db, err := database.New(context.Background(), path)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.Close())
|
||||
|
||||
data, err := os.ReadFile(path) //nolint:gosec // G304: test-controlled temp path
|
||||
require.NoError(t, err)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// TestMaterializeSnapshotDBPrivateDir proves the decrypted database lands
|
||||
// in a private (0700) directory and opens read-only.
|
||||
func TestMaterializeSnapshotDBPrivateDir(t *testing.T) {
|
||||
dbData := genuineSnapshotDBBytes(t)
|
||||
|
||||
t.Setenv("TMPDIR", t.TempDir())
|
||||
|
||||
v := &Vaultik{ctx: context.Background(), Fs: afero.NewOsFs()}
|
||||
|
||||
db, dir, err := v.materializeSnapshotDB(bytes.NewReader(dbData))
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = db.Close()
|
||||
_ = os.RemoveAll(dir)
|
||||
})
|
||||
|
||||
info, err := os.Stat(dir)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, os.FileMode(0o700), info.Mode().Perm(),
|
||||
"snapshot database directory must not be world-readable")
|
||||
|
||||
_, err = db.Conn().ExecContext(context.Background(),
|
||||
"CREATE TABLE probe_readonly (x)")
|
||||
require.Error(t, err, "materialized snapshot database must be read-only")
|
||||
}
|
||||
|
||||
// TestMaterializeSnapshotDBRejectsCompleteEmptyStream proves the written == 0
|
||||
// guard rejects a genuinely empty but complete metadata object: a real age
|
||||
// header, nonce, and final tag encrypting zero plaintext bytes. The truncation
|
||||
// case is stopped earlier by the reader (io.ErrUnexpectedEOF) and never reaches
|
||||
// this branch, so it needs its own input. This complete stream decrypts to zero
|
||||
// bytes with a clean EOF, passes the reader, and must be refused as empty rather
|
||||
// than accepted as a valid zero-table database. Reverting the guard lets the
|
||||
// empty file open as a fresh schema and the test fails.
|
||||
func TestMaterializeSnapshotDBRejectsCompleteEmptyStream(t *testing.T) {
|
||||
identity, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
|
||||
var stream bytes.Buffer
|
||||
|
||||
w, err := age.Encrypt(&stream, identity.Recipient())
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, w.Close())
|
||||
|
||||
blobReader, err := blobgen.NewReader(bytes.NewReader(stream.Bytes()), identity)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() { _ = blobReader.Close() })
|
||||
|
||||
t.Setenv("TMPDIR", t.TempDir())
|
||||
|
||||
v := &Vaultik{ctx: context.Background(), Fs: afero.NewOsFs()}
|
||||
|
||||
_, _, err = v.materializeSnapshotDB(blobReader)
|
||||
require.ErrorIs(t, err, errEmptySnapshotDB)
|
||||
}
|
||||
|
||||
// TestMaterializeSnapshotDBRemovesDirOnOpenFailure proves a failed open
|
||||
// leaves no temp directory behind.
|
||||
func TestMaterializeSnapshotDBRemovesDirOnOpenFailure(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
|
||||
t.Setenv("TMPDIR", base)
|
||||
|
||||
v := &Vaultik{ctx: context.Background(), Fs: afero.NewOsFs()}
|
||||
|
||||
_, _, err := v.materializeSnapshotDB(
|
||||
bytes.NewReader([]byte("this is not a sqlite database")))
|
||||
require.Error(t, err)
|
||||
|
||||
entries, rerr := os.ReadDir(base)
|
||||
require.NoError(t, rerr)
|
||||
require.Empty(t, entries, "temp directory left behind after open failure")
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
|
||||
// TestRestoreRejectsTruncatedMetadataDB backs up a real tree, then replaces
|
||||
// the snapshot's db.zst.age with a stream cut right after the age header and
|
||||
// its 16-byte nonce. age.Decrypt still accepts such an object and the zstd
|
||||
// decoder turns the truncated read into a clean EOF, so before the fix restore
|
||||
// built a fresh empty schema and reported success. Restore must now fail with
|
||||
// io.ErrUnexpectedEOF, the error the reader raises for a truncated object.
|
||||
// Asserting that specific error pins the reader fix: without it the truncation
|
||||
// yields an empty database, which the identity check rejects for an unrelated
|
||||
// reason, and this test would pass anyway.
|
||||
func TestRestoreRejectsTruncatedMetadataDB(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
chunkSize := int64(64 * 1024)
|
||||
maxBlobSize := int64(512 * 1024)
|
||||
|
||||
setupE2ESourceTree(t, fs, dataDir, chunkSize)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
cfg, storer, snapshotID := runFileStorageBackup(
|
||||
ctx, t, fs, dataDir, storeDir, dbPath, chunkSize, maxBlobSize)
|
||||
|
||||
// Encrypting empty plaintext to the snapshot recipient yields
|
||||
// header + nonce(16) + a single 16-byte final chunk tag. Dropping the
|
||||
// trailing tag leaves exactly the age header plus its nonce — the
|
||||
// truncation an attacker can write over metadata without any key.
|
||||
recipient, err := age.ParseX25519Recipient(testAgePublicKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
var full bytes.Buffer
|
||||
|
||||
w, err := age.Encrypt(&full, recipient)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, w.Close())
|
||||
|
||||
truncated := full.Bytes()[:full.Len()-16]
|
||||
|
||||
dbKeyPath := filepath.Join(storeDir, "metadata",
|
||||
snapshot.RemoteSnapshotKey(snapshotID), "db.zst.age")
|
||||
require.NoError(t, afero.WriteFile(fs, dbKeyPath, truncated, 0o644))
|
||||
|
||||
restoreVaultik := &vaultik.Vaultik{
|
||||
Config: cfg,
|
||||
Storage: storer,
|
||||
Fs: fs,
|
||||
Stdout: io.Discard,
|
||||
Stderr: io.Discard,
|
||||
UI: ui.NewWithColor(io.Discard, false),
|
||||
}
|
||||
restoreVaultik.SetContext(ctx)
|
||||
|
||||
err = restoreVaultik.Restore(&vaultik.RestoreOptions{
|
||||
SnapshotID: snapshotID,
|
||||
TargetDir: restoreDir,
|
||||
Verify: true,
|
||||
})
|
||||
require.ErrorIs(t, err, io.ErrUnexpectedEOF)
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
|
||||
// TestShallowVerifyDetectsWrongBlobSize backs up a real snapshot, runs
|
||||
// shallow verify (which passes and reports exactly the blobs it checked),
|
||||
// then grows one stored blob so its size no longer matches the manifest.
|
||||
// Shallow verify must then fail, count the grown blob as a size mismatch,
|
||||
// and drop it from the verified count rather than continuing to report it
|
||||
// as checked.
|
||||
func TestShallowVerifyDetectsWrongBlobSize(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
chunkSize := int64(32 * 1024)
|
||||
maxBlobSize := int64(128 * 1024)
|
||||
|
||||
// Enough data to span several blobs, so the mismatch count and the
|
||||
// dropped verified count are both meaningful.
|
||||
require.NoError(t, fs.MkdirAll(dataDir, 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs,
|
||||
filepath.Join(dataDir, "data.bin"),
|
||||
bytesPattern("shallow-", int(maxBlobSize*4)), 0o644))
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
cfg, storer, snapshotID := runFileStorageBackup(
|
||||
ctx, t, fs, dataDir, storeDir, dbPath, chunkSize, maxBlobSize)
|
||||
|
||||
newVerifier := func(out io.Writer) *vaultik.Vaultik {
|
||||
v := &vaultik.Vaultik{
|
||||
Config: cfg,
|
||||
Storage: storer,
|
||||
Fs: fs,
|
||||
Stdout: out,
|
||||
Stderr: io.Discard,
|
||||
UI: ui.NewWithColor(io.Discard, false),
|
||||
}
|
||||
v.SetContext(ctx)
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
require.NoError(t,
|
||||
newVerifier(&out).VerifySnapshotWithOptions(
|
||||
snapshotID, &vaultik.VerifyOptions{JSON: true}),
|
||||
"shallow verify should pass on a healthy snapshot")
|
||||
|
||||
healthy := decodeVerifyResult(t, out.Bytes())
|
||||
require.Equal(t, "ok", healthy.Status)
|
||||
require.Positive(t, healthy.BlobCount)
|
||||
require.Equal(t, healthy.BlobCount, healthy.Verified,
|
||||
"shallow verify must report exactly the blobs it checked")
|
||||
require.Zero(t, healthy.Mismatched)
|
||||
|
||||
// Grow one stored blob so its size no longer matches the manifest.
|
||||
growOneBlob(t, fs, filepath.Join(storeDir, "blobs"))
|
||||
|
||||
out.Reset()
|
||||
err := newVerifier(&out).VerifySnapshotWithOptions(
|
||||
snapshotID, &vaultik.VerifyOptions{JSON: true})
|
||||
require.Error(t, err,
|
||||
"shallow verify must fail when a blob's stored size differs from the manifest")
|
||||
|
||||
bad := decodeVerifyResult(t, out.Bytes())
|
||||
require.Equal(t, "failed", bad.Status)
|
||||
require.Equal(t, 1, bad.Mismatched)
|
||||
require.Equal(t, healthy.BlobCount-1, bad.Verified,
|
||||
"the wrong-sized blob must not be counted as verified")
|
||||
}
|
||||
|
||||
// TestShallowVerifyDetectsMissingDatabase backs up a real snapshot,
|
||||
// confirms shallow verify passes, then deletes the snapshot's encrypted
|
||||
// database. Shallow verify must fail: a snapshot without its database is
|
||||
// not restorable, even when every blob is present.
|
||||
func TestShallowVerifyDetectsMissingDatabase(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
chunkSize := int64(32 * 1024)
|
||||
maxBlobSize := int64(128 * 1024)
|
||||
|
||||
require.NoError(t, fs.MkdirAll(dataDir, 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs,
|
||||
filepath.Join(dataDir, "data.bin"),
|
||||
bytesPattern("shallow-db-", int(maxBlobSize*2)), 0o644))
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
cfg, storer, snapshotID := runFileStorageBackup(
|
||||
ctx, t, fs, dataDir, storeDir, dbPath, chunkSize, maxBlobSize)
|
||||
|
||||
newVerifier := func() *vaultik.Vaultik {
|
||||
v := &vaultik.Vaultik{
|
||||
Config: cfg,
|
||||
Storage: storer,
|
||||
Fs: fs,
|
||||
Stdout: io.Discard,
|
||||
Stderr: io.Discard,
|
||||
UI: ui.NewWithColor(io.Discard, false),
|
||||
}
|
||||
v.SetContext(ctx)
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
require.NoError(t,
|
||||
newVerifier().VerifySnapshotWithOptions(
|
||||
snapshotID, &vaultik.VerifyOptions{}),
|
||||
"shallow verify should pass on a healthy snapshot")
|
||||
|
||||
// The database lives under the hashed remote key, not the human ID.
|
||||
dbObject := filepath.Join(storeDir, "metadata",
|
||||
snapshot.RemoteSnapshotKey(snapshotID), "db.zst.age")
|
||||
require.NoError(t, os.Remove(dbObject))
|
||||
|
||||
require.Error(t,
|
||||
newVerifier().VerifySnapshotWithOptions(
|
||||
snapshotID, &vaultik.VerifyOptions{}),
|
||||
"shallow verify must fail when db.zst.age is absent")
|
||||
}
|
||||
|
||||
// growOneBlob appends bytes to the first blob file found under blobsDir,
|
||||
// changing its on-disk size so it no longer matches the manifest.
|
||||
func growOneBlob(t *testing.T, fs afero.Fs, blobsDir string) {
|
||||
t.Helper()
|
||||
|
||||
var blobPath string
|
||||
|
||||
err := afero.Walk(fs, blobsDir,
|
||||
func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if blobPath == "" && !info.IsDir() {
|
||||
blobPath = path
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, blobPath, "expected at least one blob on disk")
|
||||
|
||||
data, err := afero.ReadFile(fs, blobPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
data = append(data, []byte("extra")...)
|
||||
require.NoError(t, afero.WriteFile(fs, blobPath, data, 0o644))
|
||||
}
|
||||
|
||||
func decodeVerifyResult(t *testing.T, b []byte) vaultik.VerifyResult {
|
||||
t.Helper()
|
||||
|
||||
var result vaultik.VerifyResult
|
||||
|
||||
require.NoError(t, json.Unmarshal(b, &result))
|
||||
|
||||
return result
|
||||
}
|
||||
+78
-182
@@ -20,6 +20,7 @@ import (
|
||||
var (
|
||||
errSnapshotNotInConfig = errors.New("snapshot not found in config")
|
||||
errNoSnapshotsInConfig = errors.New("no snapshots configured")
|
||||
errBlobsMissing = errors.New("blobs are missing")
|
||||
errSnapshotVerifyFailed = errors.New("verification failed")
|
||||
errRemoveAllNeedsForce = errors.New("--all requires --force")
|
||||
errInvalidTableName = errors.New("invalid table name")
|
||||
@@ -55,8 +56,8 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Clean up incomplete snapshots FIRST, before any scanning.
|
||||
// This is critical for data safety; PruneDatabase below does it.
|
||||
// Clean up incomplete snapshots FIRST, before any scanning
|
||||
// This is critical for data safety - see CleanupIncompleteSnapshots for details
|
||||
hostname := v.Config.Hostname
|
||||
if hostname == "" {
|
||||
hostname, _ = os.Hostname()
|
||||
@@ -69,9 +70,6 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
||||
// Prune the database before starting: delete incomplete snapshots and orphaned data.
|
||||
// This ensures the database is consistent before we start a new snapshot.
|
||||
// Since we use locking, only one vaultik instance accesses the DB at a time.
|
||||
// A snapshot whose metadata export was interrupted is left incomplete by
|
||||
// finalizeSnapshotMetadata, so it is among the incomplete snapshots dropped
|
||||
// here (https://git.eeqj.de/sneak/vaultik/issues/177).
|
||||
_, err = v.PruneDatabase()
|
||||
if err != nil {
|
||||
return fmt.Errorf("prune database: %w", err)
|
||||
@@ -327,12 +325,7 @@ func (v *Vaultik) collectUploadStats(scanner *snapshot.Scanner, stats *snapshotS
|
||||
}
|
||||
}
|
||||
|
||||
// finalizeSnapshotMetadata updates stats, exports metadata, and only then
|
||||
// marks the snapshot complete. Recording completion last is deliberate: an
|
||||
// export interrupted by a crash leaves the snapshot incomplete rather than
|
||||
// looking complete with no manifest or database at the destination. The next
|
||||
// run's PruneDatabase drops the incomplete snapshot and re-backs-up its data.
|
||||
// See https://git.eeqj.de/sneak/vaultik/issues/177.
|
||||
// finalizeSnapshotMetadata updates stats, marks complete, and exports metadata
|
||||
func (v *Vaultik) finalizeSnapshotMetadata(
|
||||
snapshotID string, stats *snapshotStats,
|
||||
) error {
|
||||
@@ -354,11 +347,9 @@ func (v *Vaultik) finalizeSnapshotMetadata(
|
||||
return fmt.Errorf("updating snapshot stats: %w", err)
|
||||
}
|
||||
|
||||
// snapshot_blobs must be populated before the export, which builds the
|
||||
// manifest and the trimmed metadata database from it.
|
||||
err = v.SnapshotManager.PopulateSnapshotBlobs(v.ctx, snapshotID)
|
||||
err = v.SnapshotManager.CompleteSnapshot(v.ctx, snapshotID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("populating snapshot blobs: %w", err)
|
||||
return fmt.Errorf("completing snapshot: %w", err)
|
||||
}
|
||||
|
||||
err = v.SnapshotManager.ExportSnapshotMetadata(
|
||||
@@ -367,13 +358,6 @@ func (v *Vaultik) finalizeSnapshotMetadata(
|
||||
return fmt.Errorf("exporting snapshot metadata: %w", err)
|
||||
}
|
||||
|
||||
// Record completion last, so an interrupted export never leaves a
|
||||
// snapshot marked complete without its metadata at the destination.
|
||||
err = v.SnapshotManager.MarkSnapshotComplete(v.ctx, snapshotID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marking snapshot complete: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -686,24 +670,11 @@ func (v *Vaultik) VerifySnapshotWithOptions(
|
||||
|
||||
v.printVerifyHeader(snapshotID, opts)
|
||||
|
||||
// Resolve the identifier to the snapshot's remote key. A human ID is
|
||||
// hashed; a remote key (or its abbreviation, as printed for a
|
||||
// remote-only snapshot) is used as-is, so a host with no local index
|
||||
// can verify a snapshot it can only see on the store. The key is kept
|
||||
// so we can also check for the snapshot's encrypted database below.
|
||||
remoteKey, err := v.resolveSnapshotRemoteKey(snapshotID)
|
||||
if err != nil {
|
||||
if opts.JSON {
|
||||
result.Status = verifyStatusFailed
|
||||
result.ErrorMessage = fmt.Sprintf("resolving snapshot identifier: %v", err)
|
||||
|
||||
return v.outputVerifyJSON(result)
|
||||
}
|
||||
|
||||
return fmt.Errorf("resolving snapshot identifier: %w", err)
|
||||
}
|
||||
|
||||
manifest, err := v.downloadManifestByKey(remoteKey)
|
||||
// Resolve the identifier to the snapshot's remote key and download the
|
||||
// manifest. A human ID is hashed; a remote key (or its abbreviation,
|
||||
// as printed for a remote-only snapshot) is used as-is, so a host with
|
||||
// no local index can verify a snapshot it can only see on the store.
|
||||
manifest, err := v.resolveAndDownloadManifest(snapshotID)
|
||||
if err != nil {
|
||||
if opts.JSON {
|
||||
result.Status = verifyStatusFailed
|
||||
@@ -733,34 +704,14 @@ func (v *Vaultik) VerifySnapshotWithOptions(
|
||||
|
||||
v.printlnStdout()
|
||||
|
||||
// Check each blob is present with the size the manifest records.
|
||||
v.stdoutf("Checking blob presence and sizes...\n")
|
||||
// Check each blob exists
|
||||
v.stdoutf("Checking blob existence...\n")
|
||||
}
|
||||
|
||||
// A snapshot is only restorable if its encrypted database is present
|
||||
// alongside the blobs. Shallow verify checks that the object exists; it
|
||||
// does not decrypt it (that is deep verify's job).
|
||||
dbPath := fmt.Sprintf("metadata/%s/db.zst.age", remoteKey)
|
||||
result.Verified, result.Missing, result.MissingSize =
|
||||
v.verifyManifestBlobsExist(manifest, opts)
|
||||
|
||||
_, dbErr := v.Storage.Stat(v.ctx, dbPath)
|
||||
if dbErr != nil {
|
||||
result.DatabaseMissing = true
|
||||
}
|
||||
|
||||
result.Verified, result.Missing, result.Mismatched, result.MissingSize, err =
|
||||
v.verifyManifestBlobs(manifest, opts)
|
||||
if err != nil {
|
||||
if opts.JSON {
|
||||
result.Status = verifyStatusFailed
|
||||
result.ErrorMessage = fmt.Sprintf("verifying manifest blobs: %v", err)
|
||||
|
||||
return v.outputVerifyJSON(result)
|
||||
}
|
||||
|
||||
return fmt.Errorf("verifying manifest blobs: %w", err)
|
||||
}
|
||||
|
||||
return v.formatVerifyResult(result, opts)
|
||||
return v.formatVerifyResult(result, manifest, opts)
|
||||
}
|
||||
|
||||
// printVerifyHeader prints the snapshot ID and parsed timestamp for
|
||||
@@ -785,34 +736,25 @@ func (v *Vaultik) printVerifyHeader(snapshotID string, opts *VerifyOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
// verifyManifestBlobs checks that each blob in the manifest is present in
|
||||
// storage with the size the manifest records, returning the counts of
|
||||
// blobs that were present with the right size, absent, and present but the
|
||||
// wrong size, plus the total bytes of the absent blobs. It does not read
|
||||
// blob contents; deep verification (RunDeepVerify) does that. The size
|
||||
// comparison matches the deep path (see verifyBlobExistenceFromDB).
|
||||
func (v *Vaultik) verifyManifestBlobs(
|
||||
// verifyManifestBlobsExist checks that each blob in the manifest exists
|
||||
// in storage, returning the verified count, missing count, and total
|
||||
// missing bytes.
|
||||
func (v *Vaultik) verifyManifestBlobsExist(
|
||||
manifest *snapshot.Manifest, opts *VerifyOptions,
|
||||
) (int, int, int, int64, error) {
|
||||
) (int, int, int64) {
|
||||
var (
|
||||
verified, missing, mismatched int
|
||||
missingSize int64
|
||||
verified, missing int
|
||||
missingSize int64
|
||||
)
|
||||
|
||||
for _, blob := range manifest.Blobs {
|
||||
// The manifest is unauthenticated, so its blob hashes are checked
|
||||
// before being spliced into a storage path.
|
||||
if !isBlobHash(blob.Hash) {
|
||||
return 0, 0, 0, 0, fmt.Errorf("%w: %s",
|
||||
errInvalidBlobHash, shortHash(blob.Hash))
|
||||
}
|
||||
|
||||
blobPath := fmt.Sprintf("blobs/%s/%s/%s",
|
||||
blob.Hash[:2], blob.Hash[2:4], blob.Hash)
|
||||
|
||||
stat, err := v.Storage.Stat(v.ctx, blobPath)
|
||||
switch {
|
||||
case err != nil:
|
||||
// Shallow: check existence only (deep verification is handled
|
||||
// by RunDeepVerify).
|
||||
_, err := v.Storage.Stat(v.ctx, blobPath)
|
||||
if err != nil {
|
||||
if !opts.JSON {
|
||||
v.stdoutf(" Missing: %s (%s)\n",
|
||||
blob.Hash, ubytes(blob.CompressedSize))
|
||||
@@ -820,32 +762,23 @@ func (v *Vaultik) verifyManifestBlobs(
|
||||
|
||||
missing++
|
||||
missingSize += blob.CompressedSize
|
||||
case stat.Size != blob.CompressedSize:
|
||||
if !opts.JSON {
|
||||
v.stdoutf(" Wrong size: %s (store has %s, manifest lists %s)\n",
|
||||
blob.Hash, ubytes(stat.Size), ubytes(blob.CompressedSize))
|
||||
}
|
||||
|
||||
mismatched++
|
||||
default:
|
||||
} else {
|
||||
verified++
|
||||
}
|
||||
}
|
||||
|
||||
return verified, missing, mismatched, missingSize, nil
|
||||
return verified, missing, missingSize
|
||||
}
|
||||
|
||||
// formatVerifyResult outputs the final verification results as JSON or
|
||||
// human-readable text.
|
||||
func (v *Vaultik) formatVerifyResult(
|
||||
result *VerifyResult, opts *VerifyOptions,
|
||||
result *VerifyResult, manifest *snapshot.Manifest, opts *VerifyOptions,
|
||||
) error {
|
||||
failure := shallowVerifyFailure(result)
|
||||
|
||||
if opts.JSON {
|
||||
if failure != "" {
|
||||
if result.Missing > 0 {
|
||||
result.Status = verifyStatusFailed
|
||||
result.ErrorMessage = failure
|
||||
result.ErrorMessage = fmt.Sprintf("%d blobs are missing", result.Missing)
|
||||
} else {
|
||||
result.Status = "ok"
|
||||
}
|
||||
@@ -854,57 +787,29 @@ func (v *Vaultik) formatVerifyResult(
|
||||
}
|
||||
|
||||
v.stdoutf("\nVerification complete:\n")
|
||||
v.stdoutf(" Present with listed size: %d blobs\n", result.Verified)
|
||||
v.stdoutf(" Verified: %d blobs (%s)\n", result.Verified,
|
||||
ubytes(manifest.TotalCompressedSize-result.MissingSize))
|
||||
|
||||
if result.Missing > 0 {
|
||||
v.stdoutf(" Missing: %d blobs (%s)\n",
|
||||
v.stdoutf(" Missing: %d blobs (%s)\n",
|
||||
result.Missing, ubytes(result.MissingSize))
|
||||
}
|
||||
|
||||
if result.Mismatched > 0 {
|
||||
v.stdoutf(" Wrong size: %d blobs\n", result.Mismatched)
|
||||
}
|
||||
|
||||
if result.DatabaseMissing {
|
||||
v.stdoutf(" Encrypted database: missing\n")
|
||||
} else {
|
||||
v.stdoutf(" Missing: 0 blobs\n")
|
||||
}
|
||||
|
||||
v.stdoutf(" Status: ")
|
||||
|
||||
if failure != "" {
|
||||
v.stdoutf("FAILED - %s\n", failure)
|
||||
if result.Missing > 0 {
|
||||
v.stdoutf("FAILED - %d blobs are missing\n", result.Missing)
|
||||
|
||||
return fmt.Errorf("%w: %s", errSnapshotVerifyFailed, failure)
|
||||
return fmt.Errorf("%d %w", result.Missing, errBlobsMissing)
|
||||
}
|
||||
|
||||
// Report only what was actually checked: presence and size, not contents.
|
||||
v.stdoutf("OK - all %d blobs listed in the manifest are present with the "+
|
||||
"listed size; contents not checked (use --deep)\n", result.Verified)
|
||||
v.stdoutf("OK - All blobs verified\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// shallowVerifyFailure returns a human-readable description of everything
|
||||
// that failed shallow verification, or the empty string if it passed.
|
||||
func shallowVerifyFailure(result *VerifyResult) string {
|
||||
var parts []string
|
||||
|
||||
if result.Missing > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d blobs are missing", result.Missing))
|
||||
}
|
||||
|
||||
if result.Mismatched > 0 {
|
||||
parts = append(parts,
|
||||
fmt.Sprintf("%d blobs have the wrong size", result.Mismatched))
|
||||
}
|
||||
|
||||
if result.DatabaseMissing {
|
||||
parts = append(parts, "the encrypted database is missing")
|
||||
}
|
||||
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// outputVerifyJSON outputs the verification result as JSON
|
||||
func (v *Vaultik) outputVerifyJSON(result *VerifyResult) error {
|
||||
encoder := json.NewEncoder(v.Stdout)
|
||||
@@ -1030,23 +935,29 @@ func (v *Vaultik) downloadManifestByKey(remoteKey string) (*snapshot.Manifest, e
|
||||
func (v *Vaultik) syncWithRemote() error {
|
||||
log.Info("Syncing with remote snapshots")
|
||||
|
||||
// Remote metadata lives under metadata/<remote-key>/, where the
|
||||
// directory name is snapshot.RemoteSnapshotKey(id), not the human
|
||||
// snapshot ID. Compare each local row's hashed key against that set
|
||||
// so a row still backed by remote metadata is kept. Comparing human
|
||||
// IDs against the hashed directory names matches nothing and deletes
|
||||
// every local snapshot record (issue #160).
|
||||
remoteKeys, err := v.listAllRemoteSnapshotKeys()
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing remote snapshots: %w", err)
|
||||
// Get all remote snapshot IDs
|
||||
remoteSnapshots := make(map[string]bool)
|
||||
objectCh := v.Storage.ListStream(v.ctx, "metadata/")
|
||||
|
||||
for object := range objectCh {
|
||||
if object.Err != nil {
|
||||
return fmt.Errorf("listing remote snapshots: %w", object.Err)
|
||||
}
|
||||
|
||||
// Extract snapshot ID from paths like metadata/hostname-20240115-143052Z/
|
||||
parts := strings.Split(object.Key, "/")
|
||||
if len(parts) >= minSnapshotIDParts &&
|
||||
parts[0] == metadataDirName && parts[1] != "" {
|
||||
// Skip macOS resource fork files (._*) and other hidden files
|
||||
if strings.HasPrefix(parts[1], ".") {
|
||||
continue
|
||||
}
|
||||
|
||||
remoteSnapshots[parts[1]] = true
|
||||
}
|
||||
}
|
||||
|
||||
remoteKeySet := make(map[string]bool, len(remoteKeys))
|
||||
for _, k := range remoteKeys {
|
||||
remoteKeySet[k] = true
|
||||
}
|
||||
|
||||
log.Debug("Found remote snapshots", "count", len(remoteKeySet))
|
||||
log.Debug("Found remote snapshots", "count", len(remoteSnapshots))
|
||||
|
||||
// Get all local snapshots (use a high limit to get all)
|
||||
localSnapshots, err := v.Repositories.Snapshots.ListRecent(v.ctx, listRecentLimit)
|
||||
@@ -1054,12 +965,12 @@ func (v *Vaultik) syncWithRemote() error {
|
||||
return fmt.Errorf("listing local snapshots: %w", err)
|
||||
}
|
||||
|
||||
// Remove local snapshots whose metadata is absent from the remote.
|
||||
// Remove local snapshots that don't exist remotely
|
||||
removedCount := 0
|
||||
|
||||
for _, snap := range localSnapshots {
|
||||
snapshotIDStr := snap.ID.String()
|
||||
if !remoteKeySet[snapshot.RemoteSnapshotKey(snapshotIDStr)] {
|
||||
if !remoteSnapshots[snapshotIDStr] {
|
||||
log.Info("Removing local snapshot not found in remote",
|
||||
"snapshot_id", snap.ID)
|
||||
|
||||
@@ -1347,36 +1258,21 @@ func (v *Vaultik) listAllRemoteSnapshotKeys() ([]string, error) {
|
||||
}
|
||||
|
||||
parts := strings.Split(object.Key, "/")
|
||||
if len(parts) < minSnapshotIDParts ||
|
||||
parts[0] != metadataDirName || parts[1] == "" {
|
||||
continue
|
||||
}
|
||||
if len(parts) >= minSnapshotIDParts &&
|
||||
parts[0] == metadataDirName && parts[1] != "" {
|
||||
// Skip macOS resource fork files (._*) and other hidden files
|
||||
if strings.HasPrefix(parts[1], ".") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip macOS resource fork files (._*) and other hidden files
|
||||
if strings.HasPrefix(parts[1], ".") {
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(object.Key, "/") &&
|
||||
!strings.Contains(object.Key, "/manifest.json.zst") {
|
||||
continue
|
||||
}
|
||||
|
||||
key := parts[1]
|
||||
|
||||
// A remote snapshot key is a SHA-256 hash: 64 lowercase hex
|
||||
// characters. The listing comes from the untrusted destination,
|
||||
// so accept a key only in that form.
|
||||
if !isBlobHash(key) {
|
||||
log.Warn("Skipping non-conforming key under metadata/",
|
||||
"key", object.Key)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
keys = append(keys, key)
|
||||
if strings.HasSuffix(object.Key, "/") ||
|
||||
strings.Contains(object.Key, "/manifest.json.zst") {
|
||||
key := parts[1]
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,20 @@ func (v *Vaultik) resolveSnapshotRemoteKey(identifier string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// resolveAndDownloadManifest resolves a snapshot identifier to its remote
|
||||
// key (see resolveSnapshotRemoteKey) and downloads that snapshot's
|
||||
// manifest.
|
||||
func (v *Vaultik) resolveAndDownloadManifest(
|
||||
identifier string,
|
||||
) (*snapshot.Manifest, error) {
|
||||
remoteKey, err := v.resolveSnapshotRemoteKey(identifier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return v.downloadManifestByKey(remoteKey)
|
||||
}
|
||||
|
||||
// isRemoteKeyOrPrefix reports whether s is a full remote key or the
|
||||
// leading part of one: 1 to 64 lowercase hex characters. A human snapshot
|
||||
// ID is never all hex, so this shape test is enough to tell the two apart.
|
||||
|
||||
@@ -114,15 +114,9 @@ func (v *Vaultik) ListSnapshots(jsonOutput bool) error {
|
||||
return encoder.Encode(snapshots)
|
||||
}
|
||||
|
||||
// The table is the output this command exists to produce, so it is
|
||||
// written plain (markers would corrupt the columns) to the UI writer's
|
||||
// stdout; --quiet silences it. Reconciliation notes below go through
|
||||
// the UI methods, so their warnings still emit under --quiet.
|
||||
if !v.UI.Quiet() {
|
||||
err = v.printSnapshotTable(snapshots)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = v.printSnapshotTable(snapshots)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if remoteErr == nil {
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
)
|
||||
|
||||
// TestListSnapshots_QuietSuppressesTableNotJSON is the --quiet contract
|
||||
// for `snapshot list`: the human table is silenced, but the --json
|
||||
// document a script depends on still emits. A local snapshot with its
|
||||
// remote counterpart present is used so there are no drift notes, whose
|
||||
// warnings would emit even under --quiet.
|
||||
func TestListSnapshots_QuietSuppressesTableNotJSON(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
env := newListEnv(t)
|
||||
|
||||
ts := time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC)
|
||||
env.addLocal(t, listLocalID, ts)
|
||||
env.addRemote(t, listLocalID, ts)
|
||||
|
||||
env.v.UI.SetQuiet(true)
|
||||
|
||||
// Table mode: nothing on stdout.
|
||||
require.NoError(t, env.v.ListSnapshots(false))
|
||||
require.Empty(t, env.stdout.String(),
|
||||
"the table must be suppressed under --quiet")
|
||||
|
||||
// JSON mode: the document is still written despite the quiet UI.
|
||||
env.stdout.Reset()
|
||||
require.NoError(t, env.v.ListSnapshots(true))
|
||||
|
||||
rows := decodeListJSON(t, env.stdout.String())
|
||||
require.Len(t, rows, 1)
|
||||
require.Equal(t, listLocalID, rows[0].ID,
|
||||
"the --json document must still emit under --quiet")
|
||||
}
|
||||
+28
-35
@@ -5,6 +5,7 @@ package vaultik
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"github.com/spf13/afero"
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/vaultik/internal/config"
|
||||
"sneak.berlin/go/vaultik/internal/crypto"
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
"sneak.berlin/go/vaultik/internal/globals"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
@@ -19,6 +21,12 @@ import (
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
)
|
||||
|
||||
// Sentinel errors for misconfigured encryption settings.
|
||||
var (
|
||||
errNoAgeRecipients = errors.New("no age recipients configured")
|
||||
errNoAgeSecretKey = errors.New("no age secret key configured")
|
||||
)
|
||||
|
||||
// Vaultik contains all dependencies needed for vaultik operations
|
||||
type Vaultik struct {
|
||||
Globals *globals.Globals
|
||||
@@ -128,45 +136,31 @@ func (v *Vaultik) Cancel() {
|
||||
v.cancel()
|
||||
}
|
||||
|
||||
// StartOperation runs fn in its own goroutine and returns a stop
|
||||
// function. fn is the command being run (a restore, verify, prune, and
|
||||
// so on); it observes cancellation through the Vaultik context and
|
||||
// removes its decrypted scratch files (the blob cache and the temporary
|
||||
// snapshot database) from the temp directory as it unwinds.
|
||||
//
|
||||
// Calling stop cancels the Vaultik context and then blocks until fn has
|
||||
// returned — so that unwinding, and the cleanup it does, completes
|
||||
// before the caller proceeds — or until the passed context is done,
|
||||
// whichever comes first. It reports whether fn returned before that
|
||||
// deadline. A signal-driven shutdown must call stop before the process
|
||||
// exits; otherwise the process can exit mid-operation and leave
|
||||
// decrypted data behind.
|
||||
func (v *Vaultik) StartOperation(fn func()) func(context.Context) bool {
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
|
||||
fn()
|
||||
}()
|
||||
|
||||
return func(ctx context.Context) bool {
|
||||
v.Cancel()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CanDecrypt returns true if this Vaultik instance has decryption capabilities
|
||||
func (v *Vaultik) CanDecrypt() bool {
|
||||
return v.Config.AgeSecretKey != ""
|
||||
}
|
||||
|
||||
// GetEncryptor creates a new Encryptor instance based on the configured age recipients
|
||||
// Returns an error if no recipients are configured
|
||||
func (v *Vaultik) GetEncryptor() (*crypto.Encryptor, error) {
|
||||
if len(v.Config.AgeRecipients) == 0 {
|
||||
return nil, errNoAgeRecipients
|
||||
}
|
||||
|
||||
return crypto.NewEncryptor(v.Config.AgeRecipients)
|
||||
}
|
||||
|
||||
// GetDecryptor creates a new Decryptor instance based on the configured age secret key
|
||||
// Returns an error if no secret key is configured
|
||||
func (v *Vaultik) GetDecryptor() (*crypto.Decryptor, error) {
|
||||
if v.Config.AgeSecretKey == "" {
|
||||
return nil, errNoAgeSecretKey
|
||||
}
|
||||
|
||||
return crypto.NewDecryptor(v.Config.AgeSecretKey)
|
||||
}
|
||||
|
||||
// GetFilesystem returns the filesystem instance used by Vaultik
|
||||
//
|
||||
//nolint:ireturn // afero.Fs is the filesystem abstraction by design
|
||||
@@ -215,7 +209,6 @@ func NewForTesting(storage storage.Storer) *TestVaultik {
|
||||
Stdout: stdout,
|
||||
Stderr: stderr,
|
||||
Stdin: stdin,
|
||||
UI: ui.NewWithColor(stdout, false),
|
||||
},
|
||||
Stdout: stdout,
|
||||
Stderr: stderr,
|
||||
|
||||
+127
-178
@@ -6,15 +6,15 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/klauspost/compress/zstd"
|
||||
|
||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
// Blank import registers the pure-Go sqlite driver for database/sql.
|
||||
_ "modernc.org/sqlite"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
)
|
||||
@@ -24,15 +24,12 @@ var (
|
||||
errVerificationFailed = errors.New("verification failed")
|
||||
errSecretKeyRequired = errors.New(
|
||||
"VAULTIK_AGE_SECRET_KEY not set; required for deep verification")
|
||||
errChunksOutOfOrder = errors.New("chunks out of order")
|
||||
errChunkHashMismatch = errors.New("chunk hash mismatch")
|
||||
errNegativeChunkLength = errors.New("chunk length is negative")
|
||||
errTrailingBlobData = errors.New(
|
||||
errChunksOutOfOrder = errors.New("chunks out of order")
|
||||
errChunkHashMismatch = errors.New("chunk hash mismatch")
|
||||
errTrailingBlobData = errors.New(
|
||||
"blob has unexpected trailing bytes not covered by chunk list")
|
||||
errManifestExtraBlob = errors.New("manifest contains blob not in database")
|
||||
errManifestMissingBlob = errors.New(
|
||||
"manifest omits blob present in database")
|
||||
errBlobSizeMismatch = errors.New("blob size mismatch")
|
||||
errManifestExtraBlob = errors.New("manifest contains blob not in database")
|
||||
errBlobSizeMismatch = errors.New("blob size mismatch")
|
||||
)
|
||||
|
||||
// verifyStatusFailed is the JSON status value for a failed verification.
|
||||
@@ -48,20 +45,15 @@ type VerifyOptions struct {
|
||||
//
|
||||
//nolint:tagliatelle // snake_case is the established JSON output format
|
||||
type VerifyResult struct {
|
||||
SnapshotID string `json:"snapshot_id"`
|
||||
Status string `json:"status"` // "ok" or "failed"
|
||||
Mode string `json:"mode"` // "shallow" or "deep"
|
||||
BlobCount int `json:"blob_count"`
|
||||
TotalSize int64 `json:"total_size"`
|
||||
Verified int `json:"verified"`
|
||||
Missing int `json:"missing"`
|
||||
MissingSize int64 `json:"missing_size,omitempty"`
|
||||
Mismatched int `json:"mismatched,omitempty"`
|
||||
// DatabaseMissing is set by shallow verify when the snapshot's
|
||||
// encrypted database (metadata/<key>/db.zst.age) is absent, which
|
||||
// makes the snapshot unrestorable regardless of the blobs.
|
||||
DatabaseMissing bool `json:"database_missing,omitempty"`
|
||||
ErrorMessage string `json:"error,omitempty"`
|
||||
SnapshotID string `json:"snapshot_id"`
|
||||
Status string `json:"status"` // "ok" or "failed"
|
||||
Mode string `json:"mode"` // "shallow" or "deep"
|
||||
BlobCount int `json:"blob_count"`
|
||||
TotalSize int64 `json:"total_size"`
|
||||
Verified int `json:"verified"`
|
||||
Missing int `json:"missing"`
|
||||
MissingSize int64 `json:"missing_size,omitempty"`
|
||||
ErrorMessage string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// deepVerifyFailure records a failure in the result and returns it appropriately
|
||||
@@ -94,21 +86,13 @@ func (v *Vaultik) RunDeepVerify(snapshotID string, opts *VerifyOptions) error {
|
||||
errSecretKeyRequired.Error(), errSecretKeyRequired)
|
||||
}
|
||||
|
||||
// Parse the age secret key once, the same way restore does, and reuse
|
||||
// the identities for the database and every blob.
|
||||
identities, err := v.restoreIdentities()
|
||||
if err != nil {
|
||||
return v.deepVerifyFailure(result, opts, err.Error(), err)
|
||||
}
|
||||
|
||||
log.Info("Starting snapshot verification", "snapshot_id", snapshotID, "mode", "deep")
|
||||
|
||||
if !opts.JSON {
|
||||
v.stdoutf("Deep verification of snapshot: %s\n\n", snapshotID)
|
||||
}
|
||||
|
||||
manifest, tempDB, dbBlobs, err := v.loadVerificationData(
|
||||
snapshotID, opts, result, identities)
|
||||
manifest, tempDB, dbBlobs, err := v.loadVerificationData(snapshotID, opts, result)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -128,8 +112,7 @@ func (v *Vaultik) RunDeepVerify(snapshotID string, opts *VerifyOptions) error {
|
||||
|
||||
result.TotalSize = totalSize
|
||||
|
||||
err = v.runVerificationSteps(
|
||||
manifest, dbBlobs, tempDB, opts, result, totalSize, identities)
|
||||
err = v.runVerificationSteps(manifest, dbBlobs, tempDB, opts, result, totalSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -154,7 +137,6 @@ func (v *Vaultik) RunDeepVerify(snapshotID string, opts *VerifyOptions) error {
|
||||
// loadVerificationData downloads manifest, database, and blob list for verification
|
||||
func (v *Vaultik) loadVerificationData(
|
||||
snapshotID string, opts *VerifyOptions, result *VerifyResult,
|
||||
identities []age.Identity,
|
||||
) (*snapshot.Manifest, *tempDB, []snapshot.BlobInfo, error) {
|
||||
// Resolve the identifier to the snapshot's remote key. A human ID is
|
||||
// hashed; a remote key (or its abbreviation, as printed for a
|
||||
@@ -191,13 +173,27 @@ func (v *Vaultik) loadVerificationData(
|
||||
v.stdoutf("Downloading and decrypting database...\n")
|
||||
}
|
||||
|
||||
tdb, err := v.downloadVerifiedSnapshotDB(
|
||||
snapshotID, remoteKey, opts, result, identities)
|
||||
// Download and decrypt database
|
||||
dbPath := fmt.Sprintf("metadata/%s/db.zst.age", remoteKey)
|
||||
log.Info("Downloading encrypted database", "path", dbPath)
|
||||
|
||||
dbReader, err := v.Storage.Get(v.ctx, dbPath)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
return nil, nil, nil, v.deepVerifyFailure(result, opts,
|
||||
fmt.Sprintf("failed to download database: %v", err),
|
||||
fmt.Errorf("failed to download database: %w", err))
|
||||
}
|
||||
|
||||
dbBlobs, err := v.getBlobsFromDatabase(tdb.db.Conn())
|
||||
defer func() { _ = dbReader.Close() }()
|
||||
|
||||
tdb, err := v.decryptAndLoadDatabase(dbReader)
|
||||
if err != nil {
|
||||
return nil, nil, nil, v.deepVerifyFailure(result, opts,
|
||||
fmt.Sprintf("failed to decrypt database: %v", err),
|
||||
fmt.Errorf("failed to decrypt database: %w", err))
|
||||
}
|
||||
|
||||
dbBlobs, err := v.getBlobsFromDatabase(tdb.DB)
|
||||
if err != nil {
|
||||
_ = tdb.Close()
|
||||
|
||||
@@ -223,45 +219,6 @@ func (v *Vaultik) loadVerificationData(
|
||||
return manifest, tdb, dbBlobs, nil
|
||||
}
|
||||
|
||||
// downloadVerifiedSnapshotDB downloads and decrypts the snapshot metadata
|
||||
// database and confirms it really is the snapshot named by remoteKey
|
||||
// before any of its rows are trusted (see verifySnapshotDBIdentity). On
|
||||
// any failure it records the failure in result and returns the error the
|
||||
// caller should propagate; the temp database is closed on a rejected
|
||||
// identity so nothing is left on disk.
|
||||
func (v *Vaultik) downloadVerifiedSnapshotDB(
|
||||
snapshotID, remoteKey string, opts *VerifyOptions, result *VerifyResult,
|
||||
identities []age.Identity,
|
||||
) (*tempDB, error) {
|
||||
dbPath := fmt.Sprintf("metadata/%s/db.zst.age", remoteKey)
|
||||
log.Info("Downloading encrypted database", "path", dbPath)
|
||||
|
||||
dbReader, err := v.Storage.Get(v.ctx, dbPath)
|
||||
if err != nil {
|
||||
return nil, v.deepVerifyFailure(result, opts,
|
||||
fmt.Sprintf("failed to download database: %v", err),
|
||||
fmt.Errorf("failed to download database: %w", err))
|
||||
}
|
||||
|
||||
defer func() { _ = dbReader.Close() }()
|
||||
|
||||
tdb, err := v.decryptAndLoadDatabase(dbReader, identities)
|
||||
if err != nil {
|
||||
return nil, v.deepVerifyFailure(result, opts,
|
||||
fmt.Sprintf("failed to decrypt database: %v", err),
|
||||
fmt.Errorf("failed to decrypt database: %w", err))
|
||||
}
|
||||
|
||||
err = v.verifySnapshotDBIdentity(tdb.db, snapshotID, remoteKey)
|
||||
if err != nil {
|
||||
_ = tdb.Close()
|
||||
|
||||
return nil, v.deepVerifyFailure(result, opts, err.Error(), err)
|
||||
}
|
||||
|
||||
return tdb, nil
|
||||
}
|
||||
|
||||
// runVerificationSteps executes manifest verification, blob existence
|
||||
// check, and deep content verification.
|
||||
func (v *Vaultik) runVerificationSteps(
|
||||
@@ -271,7 +228,6 @@ func (v *Vaultik) runVerificationSteps(
|
||||
opts *VerifyOptions,
|
||||
result *VerifyResult,
|
||||
totalSize int64,
|
||||
identities []age.Identity,
|
||||
) error {
|
||||
if !opts.JSON {
|
||||
v.stdoutf("Verifying manifest against database...\n")
|
||||
@@ -298,7 +254,7 @@ func (v *Vaultik) runVerificationSteps(
|
||||
len(dbBlobs), ubytes(totalSize))
|
||||
}
|
||||
|
||||
err = v.performDeepVerificationFromDB(dbBlobs, tdb.db.Conn(), opts, identities)
|
||||
err = v.performDeepVerificationFromDB(dbBlobs, tdb.DB, opts)
|
||||
if err != nil {
|
||||
return v.deepVerifyFailure(result, opts, err.Error(), err)
|
||||
}
|
||||
@@ -306,123 +262,127 @@ func (v *Vaultik) runVerificationSteps(
|
||||
return nil
|
||||
}
|
||||
|
||||
// tempDB is the downloaded snapshot database opened read-only for deep
|
||||
// verify, held in a private temp directory removed in full on Close.
|
||||
// tempDB wraps sql.DB with cleanup
|
||||
type tempDB struct {
|
||||
db *database.DB
|
||||
tempDir string
|
||||
*sql.DB
|
||||
|
||||
tempPath string
|
||||
}
|
||||
|
||||
func (t *tempDB) Close() error {
|
||||
err := t.db.Close()
|
||||
// Remove the whole private directory so the decrypted database and
|
||||
// any SQLite side files are gone on every path.
|
||||
_ = os.RemoveAll(t.tempDir)
|
||||
err := t.DB.Close()
|
||||
_ = os.Remove(t.tempPath)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// decryptAndLoadDatabase decrypts and loads the binary SQLite database
|
||||
// from the encrypted stream. It reads through the same blobgen reader restore
|
||||
// uses, streaming the decrypted, decompressed database to a temp file.
|
||||
func (v *Vaultik) decryptAndLoadDatabase(
|
||||
reader io.ReadCloser, identities []age.Identity,
|
||||
) (*tempDB, error) {
|
||||
// Decrypt and decompress through the shared blobgen reader.
|
||||
blobReader, err := blobgen.NewReader(reader, identities...)
|
||||
// from the encrypted stream.
|
||||
func (v *Vaultik) decryptAndLoadDatabase(reader io.ReadCloser) (*tempDB, error) {
|
||||
// Get decryptor
|
||||
decryptor, err := v.GetDecryptor()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create decryption reader: %w", err)
|
||||
return nil, fmt.Errorf("failed to get decryptor: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = blobReader.Close() }()
|
||||
|
||||
// Materialize the decrypted database inside a private (0700) temp
|
||||
// directory so it is never world-readable, and remove the whole
|
||||
// directory on any failure below.
|
||||
tempDir, err := os.MkdirTemp("", "vaultik-verify-")
|
||||
// Decrypt the stream
|
||||
decryptedReader, err := decryptor.DecryptStream(reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create temp directory: %w", err)
|
||||
return nil, fmt.Errorf("failed to decrypt database: %w", err)
|
||||
}
|
||||
|
||||
success := false
|
||||
// Decompress the binary database
|
||||
decompressor, err := zstd.NewReader(decryptedReader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create decompressor: %w", err)
|
||||
}
|
||||
defer decompressor.Close()
|
||||
|
||||
defer func() {
|
||||
if !success {
|
||||
_ = os.RemoveAll(tempDir)
|
||||
}
|
||||
}()
|
||||
|
||||
dbPath := filepath.Join(tempDir, snapshotDBFilename)
|
||||
|
||||
//nolint:gosec // G304: dbPath is our MkdirTemp dir plus a constant filename
|
||||
tempFile, err := os.OpenFile(
|
||||
dbPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, restoreFileMode)
|
||||
// Create temporary file for the database
|
||||
tempFile, err := os.CreateTemp("", "vaultik-verify-*.db")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
|
||||
tempPath := tempFile.Name()
|
||||
|
||||
// Stream decompress directly to file
|
||||
log.Info("Decompressing database...")
|
||||
|
||||
written, err := io.Copy(tempFile, blobReader)
|
||||
written, err := io.Copy(tempFile, decompressor)
|
||||
if err != nil {
|
||||
_ = tempFile.Close()
|
||||
_ = os.Remove(tempPath)
|
||||
|
||||
return nil, fmt.Errorf("failed to decompress database: %w", err)
|
||||
}
|
||||
|
||||
err = tempFile.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to close temp database file: %w", err)
|
||||
}
|
||||
_ = tempFile.Close()
|
||||
|
||||
log.Info("Database decompressed", "size", ubytes(written))
|
||||
|
||||
db, err := database.OpenReadOnly(v.ctx, dbPath)
|
||||
// Open the database
|
||||
db, err := sql.Open("sqlite", tempPath)
|
||||
if err != nil {
|
||||
_ = os.Remove(tempPath)
|
||||
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
|
||||
success = true
|
||||
|
||||
return &tempDB{db: db, tempDir: tempDir}, nil
|
||||
return &tempDB{
|
||||
DB: db,
|
||||
tempPath: tempPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// verifyBlob downloads and verifies a single blob
|
||||
func (v *Vaultik) verifyBlob(
|
||||
blobInfo snapshot.BlobInfo, db *sql.DB, identities []age.Identity,
|
||||
) error {
|
||||
func (v *Vaultik) verifyBlob(blobInfo snapshot.BlobInfo, db *sql.DB) error {
|
||||
// Download blob using shared fetch method
|
||||
reader, err := v.FetchBlob(v.ctx, blobInfo.Hash)
|
||||
reader, _, err := v.FetchBlob(v.ctx, blobInfo.Hash, blobInfo.CompressedSize)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = reader.Close() }()
|
||||
|
||||
// Decrypt and decompress through the shared blobgen reader, which hashes
|
||||
// the plaintext as it is read. A blob's hash — its remote name — is the
|
||||
// double SHA-256 of that plaintext (see blobgen.DoubleSHA256), not of the
|
||||
// encrypted bytes.
|
||||
blobReader, err := blobgen.NewReader(reader, identities...)
|
||||
// Get decryptor
|
||||
decryptor, err := v.GetDecryptor()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create blob reader: %w", err)
|
||||
return fmt.Errorf("failed to get decryptor: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = blobReader.Close() }()
|
||||
// Decrypt blob
|
||||
decryptedReader, err := decryptor.DecryptStream(reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decrypt: %w", err)
|
||||
}
|
||||
|
||||
chunkCount, err := v.verifyBlobChunks(db, blobInfo.Hash, blobReader)
|
||||
// Decompress blob
|
||||
decompressor, err := zstd.NewReader(decryptedReader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decompress: %w", err)
|
||||
}
|
||||
defer decompressor.Close()
|
||||
|
||||
// A blob's hash — its remote name — is the double SHA256 of its
|
||||
// decompressed plaintext (see blobgen.Writer.Sum256), not of the
|
||||
// encrypted bytes. Hash the plaintext as chunk verification streams
|
||||
// it, then compare on completion.
|
||||
plaintextHasher := sha256.New()
|
||||
hashedStream := io.TeeReader(decompressor, plaintextHasher)
|
||||
|
||||
chunkCount, err := v.verifyBlobChunks(db, blobInfo.Hash, hashedStream)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = v.verifyBlobFinalIntegrity(blobReader, blobInfo.Hash)
|
||||
err = v.verifyBlobFinalIntegrity(hashedStream, plaintextHasher, blobInfo.Hash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Info("Blob verified",
|
||||
"hash", shortHash(blobInfo.Hash)+"...",
|
||||
"hash", blobInfo.Hash[:16]+"...",
|
||||
"chunks", chunkCount,
|
||||
"size", ubytes(blobInfo.CompressedSize),
|
||||
)
|
||||
@@ -488,24 +448,21 @@ func (v *Vaultik) verifyBlobChunks(
|
||||
totalRead = offset
|
||||
}
|
||||
|
||||
// length comes from an untrusted blob_chunks row: reject a
|
||||
// negative value, and hash by streaming exactly length bytes
|
||||
// rather than allocating a database-supplied size up front.
|
||||
if length < 0 {
|
||||
return 0, fmt.Errorf("%w: offset %d length %d",
|
||||
errNegativeChunkLength, offset, length)
|
||||
}
|
||||
// Read chunk data
|
||||
chunkData := make([]byte, length)
|
||||
|
||||
hasher := sha256.New()
|
||||
|
||||
n, err := io.CopyN(hasher, decompressor, length)
|
||||
_, err = io.ReadFull(decompressor, chunkData)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to read chunk at offset %d: %w", offset, err)
|
||||
}
|
||||
|
||||
totalRead += n
|
||||
totalRead += length
|
||||
|
||||
// Verify chunk hash
|
||||
hasher := sha256.New()
|
||||
hasher.Write(chunkData)
|
||||
calculatedHash := hex.EncodeToString(hasher.Sum(nil))
|
||||
|
||||
if calculatedHash != chunkHash {
|
||||
return 0, fmt.Errorf("%w at offset %d: calculated %s, expected %s",
|
||||
errChunkHashMismatch, offset, calculatedHash, chunkHash)
|
||||
@@ -525,12 +482,11 @@ func (v *Vaultik) verifyBlobChunks(
|
||||
// verifyBlobFinalIntegrity checks that no trailing data exists in the
|
||||
// decompressed stream and that the blob hash matches the expected value.
|
||||
func (v *Vaultik) verifyBlobFinalIntegrity(
|
||||
blobReader *blobgen.Reader, expectedHash string,
|
||||
plaintext io.Reader, plaintextHasher hash.Hash, expectedHash string,
|
||||
) error {
|
||||
// Verify no remaining data in blob - if the chunk list is accurate,
|
||||
// the blob should be fully consumed. Draining to EOF also completes the
|
||||
// reader's plaintext hash.
|
||||
remaining, err := io.Copy(io.Discard, blobReader)
|
||||
// the blob should be fully consumed.
|
||||
remaining, err := io.Copy(io.Discard, plaintext)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check for remaining blob data: %w", err)
|
||||
}
|
||||
@@ -539,9 +495,10 @@ func (v *Vaultik) verifyBlobFinalIntegrity(
|
||||
return fmt.Errorf("%w: %d bytes", errTrailingBlobData, remaining)
|
||||
}
|
||||
|
||||
// The blob hash is the double SHA-256 of its plaintext content.
|
||||
calculatedBlobHash := hex.EncodeToString(
|
||||
blobgen.DoubleSHA256(blobReader.Sum256()))
|
||||
// The blob hash is the double SHA256 of its plaintext content.
|
||||
firstHash := plaintextHasher.Sum(nil)
|
||||
secondHash := sha256.Sum256(firstHash)
|
||||
calculatedBlobHash := hex.EncodeToString(secondHash[:])
|
||||
|
||||
if calculatedBlobHash != expectedHash {
|
||||
return fmt.Errorf("%w: calculated %s, expected %s",
|
||||
@@ -618,11 +575,16 @@ func (v *Vaultik) verifyManifestAgainstDatabase(
|
||||
manifestBlobMap[blob.Hash] = blob.CompressedSize
|
||||
}
|
||||
|
||||
// The manifest is the only blob list prune consults, so it must match
|
||||
// the database exactly. A blob in the manifest but not the database
|
||||
// points at a corrupt manifest; a blob in the database but omitted
|
||||
// from the manifest would be pruned away while this snapshot still
|
||||
// needs it. Either divergence fails verification.
|
||||
// Check counts match
|
||||
if len(dbBlobMap) != len(manifestBlobMap) {
|
||||
log.Warn("Manifest blob count mismatch",
|
||||
"database_blobs", len(dbBlobMap),
|
||||
"manifest_blobs", len(manifestBlobMap),
|
||||
)
|
||||
// This is a warning, not an error - database is authoritative
|
||||
}
|
||||
|
||||
// Check each manifest blob exists in database with correct size
|
||||
for hash, manifestSize := range manifestBlobMap {
|
||||
dbSize, exists := dbBlobMap[hash]
|
||||
if !exists {
|
||||
@@ -636,12 +598,6 @@ func (v *Vaultik) verifyManifestAgainstDatabase(
|
||||
}
|
||||
}
|
||||
|
||||
for hash := range dbBlobMap {
|
||||
if _, exists := manifestBlobMap[hash]; !exists {
|
||||
return fmt.Errorf("%w: %s", errManifestMissingBlob, hash)
|
||||
}
|
||||
}
|
||||
|
||||
log.Info("✓ Manifest verified against database",
|
||||
"manifest_blobs", len(manifestBlobMap),
|
||||
"database_blobs", len(dbBlobMap),
|
||||
@@ -655,12 +611,6 @@ func (v *Vaultik) verifyBlobExistenceFromDB(blobs []snapshot.BlobInfo) error {
|
||||
log.Info("Verifying blob existence in S3", "blob_count", len(blobs))
|
||||
|
||||
for i, blob := range blobs {
|
||||
// The hash is read from the snapshot database, which is not
|
||||
// trusted; check it before it is spliced into a storage path.
|
||||
if !isBlobHash(blob.Hash) {
|
||||
return fmt.Errorf("%w: %s", errInvalidBlobHash, shortHash(blob.Hash))
|
||||
}
|
||||
|
||||
// Construct blob path
|
||||
blobPath := fmt.Sprintf("blobs/%s/%s/%s", blob.Hash[:2], blob.Hash[2:4], blob.Hash)
|
||||
|
||||
@@ -697,7 +647,6 @@ func (v *Vaultik) verifyBlobExistenceFromDB(blobs []snapshot.BlobInfo) error {
|
||||
// each blob using the database as source.
|
||||
func (v *Vaultik) performDeepVerificationFromDB(
|
||||
blobs []snapshot.BlobInfo, db *sql.DB, opts *VerifyOptions,
|
||||
identities []age.Identity,
|
||||
) error {
|
||||
// Calculate total bytes for ETA
|
||||
var totalBytesExpected int64
|
||||
@@ -715,7 +664,7 @@ func (v *Vaultik) performDeepVerificationFromDB(
|
||||
|
||||
for i, blobInfo := range blobs {
|
||||
// Verify individual blob
|
||||
err := v.verifyBlob(blobInfo, db, identities)
|
||||
err := v.verifyBlob(blobInfo, db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("blob %s verification failed: %w", blobInfo.Hash, err)
|
||||
}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
package vaultik //nolint:testpackage // calls unexported verifyManifestAgainstDatabase
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
)
|
||||
|
||||
// Blob hashes shared by the manifest-verification tests below.
|
||||
const (
|
||||
manifestTestBlobA = "blob-a"
|
||||
manifestTestBlobB = "blob-b"
|
||||
)
|
||||
|
||||
// TestVerifyManifestAgainstDatabase_MissingBlobFails is the regression
|
||||
// guard for issue #157: deep verify must fail when the manifest omits a
|
||||
// blob the database records. The divergence used to be logged as a
|
||||
// warning while verification still returned ok, so an incomplete
|
||||
// manifest — the exact defect that lets prune later delete a needed blob
|
||||
// — passed unnoticed.
|
||||
func TestVerifyManifestAgainstDatabase_MissingBlobFails(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
v := &Vaultik{}
|
||||
|
||||
dbBlobs := []snapshot.BlobInfo{
|
||||
{Hash: manifestTestBlobA, CompressedSize: 10},
|
||||
{Hash: manifestTestBlobB, CompressedSize: 20},
|
||||
}
|
||||
manifest := &snapshot.Manifest{
|
||||
Blobs: []snapshot.BlobInfo{
|
||||
{Hash: manifestTestBlobA, CompressedSize: 10},
|
||||
},
|
||||
}
|
||||
|
||||
err := v.verifyManifestAgainstDatabase(manifest, dbBlobs)
|
||||
require.Error(t, err, "verify must fail when the manifest omits a database blob")
|
||||
assert.Contains(t, err.Error(), manifestTestBlobB)
|
||||
}
|
||||
|
||||
// TestVerifyManifestAgainstDatabase_MatchingSetsPass keeps the other half
|
||||
// honest: identical blob sets still verify, so the check above cannot be
|
||||
// satisfied by failing everything.
|
||||
func TestVerifyManifestAgainstDatabase_MatchingSetsPass(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
v := &Vaultik{}
|
||||
|
||||
blobs := []snapshot.BlobInfo{
|
||||
{Hash: manifestTestBlobA, CompressedSize: 10},
|
||||
{Hash: manifestTestBlobB, CompressedSize: 20},
|
||||
}
|
||||
manifest := &snapshot.Manifest{Blobs: blobs}
|
||||
|
||||
require.NoError(t, v.verifyManifestAgainstDatabase(manifest, blobs))
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/klauspost/compress/zstd"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/crypto"
|
||||
)
|
||||
|
||||
// TestTeeReaderWithDecryption tests that TeeReader correctly hashes all encrypted
|
||||
// bytes when streaming through age decryption and zstd decompression.
|
||||
// This validates the verification path: hash encrypted blob -> decrypt -> decompress.
|
||||
func TestTeeReaderWithDecryption(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Test data - use random data that doesn't compress well (5MB)
|
||||
testData := make([]byte, 5*1024*1024)
|
||||
_, err := rand.Read(testData)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Compress the data
|
||||
var compressedBuf bytes.Buffer
|
||||
|
||||
compressor, err := zstd.NewWriter(&compressedBuf,
|
||||
zstd.WithEncoderLevel(zstd.SpeedDefault))
|
||||
require.NoError(t, err)
|
||||
_, err = compressor.Write(testData)
|
||||
require.NoError(t, err)
|
||||
err = compressor.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Encrypt the compressed data
|
||||
testRecipient := "age1cplgrwj77ta54dnmydvvmzn64ltk83ankxl5sww04mrt" +
|
||||
"mu62kv3s89gmvv"
|
||||
testSecretKey := "AGE-SECRET-KEY-1C77PYNTHXSHNNC6EYR2W52UWYXACXA5J" +
|
||||
"T00J9CCW9986M3XY87PSGP89AQ"
|
||||
|
||||
encryptor, err := crypto.NewEncryptor([]string{testRecipient})
|
||||
require.NoError(t, err)
|
||||
|
||||
var encryptedBuf bytes.Buffer
|
||||
|
||||
err = encryptor.EncryptStream(&encryptedBuf, bytes.NewReader(compressedBuf.Bytes()))
|
||||
require.NoError(t, err)
|
||||
|
||||
encryptedData := encryptedBuf.Bytes()
|
||||
|
||||
// Calculate the expected hash of the encrypted data directly
|
||||
expectedHash := sha256.Sum256(encryptedData)
|
||||
expectedHashStr := hex.EncodeToString(expectedHash[:])
|
||||
|
||||
t.Logf("Encrypted data size: %d bytes", len(encryptedData))
|
||||
t.Logf("Expected hash: %s", expectedHashStr)
|
||||
|
||||
// Now simulate what verifyBlob does: use TeeReader to hash while decrypting
|
||||
decryptor, err := crypto.NewDecryptor(testSecretKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create hasher and tee reader
|
||||
hasher := sha256.New()
|
||||
reader := bytes.NewReader(encryptedData)
|
||||
teeReader := io.TeeReader(reader, hasher)
|
||||
|
||||
// Decrypt through the tee reader
|
||||
decryptedReader, err := decryptor.DecryptStream(teeReader)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Decompress
|
||||
decompressor, err := zstd.NewReader(decryptedReader)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer decompressor.Close()
|
||||
|
||||
// Read all decompressed data (simulating chunk verification)
|
||||
decompressedData, err := io.ReadAll(decompressor)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify we got the original data back
|
||||
assert.Equal(t, testData, decompressedData, "Decompressed data should match original")
|
||||
|
||||
// Drain remaining decompressed data (should be 0)
|
||||
remaining, err := io.Copy(io.Discard, decompressor)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(0), remaining, "No remaining decompressed data")
|
||||
|
||||
// Calculate hash from tee reader
|
||||
calculatedHashStr := hex.EncodeToString(hasher.Sum(nil))
|
||||
t.Logf("Calculated hash (before drain): %s", calculatedHashStr)
|
||||
|
||||
// Verify the hash matches the direct hash of encrypted data
|
||||
assert.Equal(t, expectedHashStr, calculatedHashStr,
|
||||
"Hash calculated via TeeReader should match direct hash of encrypted data")
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
age_recipients:
|
||||
- age1278m9q7dp3chsh2dcy82qk27v047zywyvtxwnj4cvt0z65jw6a7q5dqhfj # sneak's long term age key
|
||||
- age1ezrjmfpwsc95svdg0y54mums3zevgzu0x0ecq2f7tp8a05gl0sjq9q9wjg # add additional recipients as needed
|
||||
- age1otherpubkey... # add additional recipients as needed
|
||||
snapshots:
|
||||
test:
|
||||
paths:
|
||||
|
||||
Reference in New Issue
Block a user