Compare commits
9 Commits
307867f59e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e534746cf3 | |||
| 5397b37c13 | |||
| 2df2792a75 | |||
| 4fe568f803 | |||
| 27e85f01f2 | |||
| d479bfcd52 | |||
| cb16d6869f | |||
| ff85f1e4f8 | |||
| b2e160944f |
@@ -20,8 +20,8 @@ builds:
|
|||||||
- arm64
|
- arm64
|
||||||
ldflags:
|
ldflags:
|
||||||
- -s -w
|
- -s -w
|
||||||
- -X 'git.eeqj.de/sneak/vaultik/internal/globals.Version={{ .Version }}'
|
- -X 'sneak.berlin/go/vaultik/internal/globals.Version={{ .Version }}'
|
||||||
- -X 'git.eeqj.de/sneak/vaultik/internal/globals.Commit={{ .Commit }}'
|
- -X 'sneak.berlin/go/vaultik/internal/globals.Commit={{ .Commit }}'
|
||||||
|
|
||||||
archives:
|
archives:
|
||||||
- id: default
|
- id: default
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ COPY . .
|
|||||||
RUN make test
|
RUN make test
|
||||||
|
|
||||||
# Build with CGO enabled (required for mattn/go-sqlite3)
|
# Build with CGO enabled (required for mattn/go-sqlite3)
|
||||||
RUN CGO_ENABLED=1 go build -ldflags "-X 'git.eeqj.de/sneak/vaultik/internal/globals.Version=${VERSION}' -X 'git.eeqj.de/sneak/vaultik/internal/globals.Commit=$(git rev-parse HEAD 2>/dev/null || echo unknown)'" -o /vaultik ./cmd/vaultik
|
RUN CGO_ENABLED=0 go build -ldflags "-X 'sneak.berlin/go/vaultik/internal/globals.Version=${VERSION}' -X 'sneak.berlin/go/vaultik/internal/globals.Commit=$(git rev-parse HEAD 2>/dev/null || echo unknown)'" -o /vaultik ./cmd/vaultik
|
||||||
|
|
||||||
# Runtime stage
|
# Runtime stage
|
||||||
# alpine:3.21, 2026-02-25
|
# alpine:3.21, 2026-02-25
|
||||||
|
|||||||
4
Makefile
4
Makefile
@@ -7,8 +7,8 @@ VERSION := 1.0.0-rc.1
|
|||||||
GIT_REVISION := $(shell git rev-parse HEAD 2>/dev/null || echo "unknown")
|
GIT_REVISION := $(shell git rev-parse HEAD 2>/dev/null || echo "unknown")
|
||||||
|
|
||||||
# Linker flags
|
# Linker flags
|
||||||
LDFLAGS := -X 'git.eeqj.de/sneak/vaultik/internal/globals.Version=$(VERSION)' \
|
LDFLAGS := -X 'sneak.berlin/go/vaultik/internal/globals.Version=$(VERSION)' \
|
||||||
-X 'git.eeqj.de/sneak/vaultik/internal/globals.Commit=$(GIT_REVISION)'
|
-X 'sneak.berlin/go/vaultik/internal/globals.Commit=$(GIT_REVISION)'
|
||||||
|
|
||||||
# Default target
|
# Default target
|
||||||
all: vaultik
|
all: vaultik
|
||||||
|
|||||||
134
README.md
134
README.md
@@ -6,6 +6,32 @@ remote S3-compatible object store. It requires no private keys, secrets, or
|
|||||||
credentials (other than those required to PUT to encrypted object storage,
|
credentials (other than those required to PUT to encrypted object storage,
|
||||||
such as S3 API keys) stored on the backed-up system.
|
such as S3 API keys) stored on the backed-up system.
|
||||||
|
|
||||||
|
## quickstart
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# install
|
||||||
|
go install sneak.berlin/go/vaultik/cmd/vaultik@latest
|
||||||
|
|
||||||
|
# create a default config file (prints the path it wrote to)
|
||||||
|
vaultik config init
|
||||||
|
|
||||||
|
# generate an age keypair; keep the private key file somewhere safe and
|
||||||
|
# offline — you need it to restore, and the backed-up machine does not need it
|
||||||
|
age-keygen -o vaultik_backup_private_key.txt
|
||||||
|
grep 'public key' vaultik_backup_private_key.txt
|
||||||
|
|
||||||
|
# configure the encryption key and backup destination
|
||||||
|
vaultik config set age_recipients.0 age1YOUR_PUBLIC_KEY_HERE
|
||||||
|
vaultik config set storage_url "file:///Volumes/usbstick/mybackup"
|
||||||
|
|
||||||
|
# back up your home directory (the default config includes a "home"
|
||||||
|
# snapshot of ~ with sensible excludes)
|
||||||
|
vaultik snapshot create
|
||||||
|
|
||||||
|
# see what you have
|
||||||
|
vaultik snapshot list
|
||||||
|
```
|
||||||
|
|
||||||
Features:
|
Features:
|
||||||
|
|
||||||
* modern encryption ([age](https://age-encryption.org/), X25519 + XChaCha20-Poly1305)
|
* modern encryption ([age](https://age-encryption.org/), X25519 + XChaCha20-Poly1305)
|
||||||
@@ -38,40 +64,19 @@ Requirements that no existing tool meets:
|
|||||||
* encrypted
|
* encrypted
|
||||||
* s3 compatible without an intermediate step or tool
|
* s3 compatible without an intermediate step or tool
|
||||||
|
|
||||||
## install
|
## daily use
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go install git.eeqj.de/sneak/vaultik@latest
|
# verify a snapshot (shallow: checks all blobs exist)
|
||||||
```
|
|
||||||
|
|
||||||
## quick start
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# 1. Install
|
|
||||||
go install git.eeqj.de/sneak/vaultik@latest
|
|
||||||
|
|
||||||
# 2. Generate an age keypair (store the private key somewhere safe, offline)
|
|
||||||
age-keygen -o key.txt
|
|
||||||
# the public key is printed to stdout and also in key.txt
|
|
||||||
|
|
||||||
# 3. Create a default config file
|
|
||||||
vaultik init
|
|
||||||
# Writes to the platform config directory with commented defaults:
|
|
||||||
# macOS: ~/Library/Application Support/vaultik/config.yml
|
|
||||||
# Linux: ~/.config/vaultik/config.yml
|
|
||||||
# root: /etc/vaultik/config.yml
|
|
||||||
|
|
||||||
# 4. Edit the config: set age_recipients, snapshots, and storage_url
|
|
||||||
# (init prints the path it wrote to)
|
|
||||||
|
|
||||||
# 5. Run your first backup
|
|
||||||
vaultik snapshot create
|
|
||||||
|
|
||||||
# 6. Verify it worked
|
|
||||||
vaultik snapshot list
|
|
||||||
vaultik snapshot verify <snapshot-id>
|
vaultik snapshot verify <snapshot-id>
|
||||||
|
|
||||||
# 7. Set up a daily cron job (keeps last 4 weeks of snapshots)
|
# deep verify (downloads and cryptographically verifies every blob)
|
||||||
|
VAULTIK_AGE_SECRET_KEY='AGE-SECRET-KEY-...' vaultik snapshot verify --deep <snapshot-id>
|
||||||
|
|
||||||
|
# restore (requires the private key)
|
||||||
|
VAULTIK_AGE_SECRET_KEY='AGE-SECRET-KEY-...' vaultik 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
|
# 0 3 * * * vaultik snapshot create --cron --prune --keep-newer-than 4w
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -82,7 +87,10 @@ vaultik snapshot verify <snapshot-id>
|
|||||||
### commands
|
### commands
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
vaultik [--config <path>] init
|
vaultik [--config <path>] config init
|
||||||
|
vaultik [--config <path>] config edit
|
||||||
|
vaultik [--config <path>] config get <key>
|
||||||
|
vaultik [--config <path>] config set <key> <value>
|
||||||
vaultik [--config <path>] snapshot create [snapshot-names...] [--cron] [--prune] [--keep-newer-than <duration>] [--skip-errors]
|
vaultik [--config <path>] snapshot create [snapshot-names...] [--cron] [--prune] [--keep-newer-than <duration>] [--skip-errors]
|
||||||
vaultik [--config <path>] snapshot list [--json]
|
vaultik [--config <path>] snapshot list [--json]
|
||||||
vaultik [--config <path>] snapshot verify <snapshot-id> [--deep] [--json]
|
vaultik [--config <path>] snapshot verify <snapshot-id> [--deep] [--json]
|
||||||
@@ -96,6 +104,7 @@ vaultik [--config <path>] info
|
|||||||
vaultik [--config <path>] remote info [--json]
|
vaultik [--config <path>] remote info [--json]
|
||||||
vaultik [--config <path>] store info
|
vaultik [--config <path>] store info
|
||||||
vaultik [--config <path>] database purge [--force]
|
vaultik [--config <path>] database purge [--force]
|
||||||
|
vaultik completion <bash|zsh|fish|powershell>
|
||||||
vaultik version
|
vaultik version
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -112,15 +121,43 @@ vaultik version
|
|||||||
* `VAULTIK_CONFIG`: Path to config file (overridden by `--config`)
|
* `VAULTIK_CONFIG`: Path to config file (overridden by `--config`)
|
||||||
* `VAULTIK_INDEX_PATH`: Override local SQLite index path
|
* `VAULTIK_INDEX_PATH`: Override local SQLite index path
|
||||||
|
|
||||||
|
### shell completion
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# zsh: load for the current session
|
||||||
|
source <(vaultik completion zsh)
|
||||||
|
|
||||||
|
# zsh: install permanently
|
||||||
|
vaultik completion zsh > "${fpath[1]}/_vaultik"
|
||||||
|
|
||||||
|
# bash: load for the current session
|
||||||
|
source <(vaultik completion bash)
|
||||||
|
|
||||||
|
# bash: install permanently (Linux)
|
||||||
|
vaultik completion bash > /etc/bash_completion.d/vaultik
|
||||||
|
|
||||||
|
# fish
|
||||||
|
vaultik completion fish > ~/.config/fish/completions/vaultik.fish
|
||||||
|
```
|
||||||
|
|
||||||
### command details
|
### command details
|
||||||
|
|
||||||
**init**: Write a default config file with commented explanations for every
|
**`config init`**: Write a default config file with commented explanations for
|
||||||
setting. Writes to the path from `--config`, `$VAULTIK_CONFIG`, or the
|
every setting. Writes to the path from `--config`, `$VAULTIK_CONFIG`, or the
|
||||||
platform config directory (`~/Library/Application Support/vaultik/` on macOS,
|
platform config directory (`~/Library/Application Support/vaultik/` on macOS,
|
||||||
`~/.config/vaultik/` on Linux, `/etc/vaultik/` as root). Refuses to overwrite an
|
`~/.config/vaultik/` on Linux, `/etc/vaultik/` as root). Refuses to overwrite an
|
||||||
existing file. Created with mode `0600` since it will contain credentials.
|
existing file. Created with mode `0600` since it will contain credentials.
|
||||||
|
|
||||||
**snapshot create**: Perform incremental backup of configured snapshots.
|
**`config edit`**: Open the config file in `$EDITOR` (falls back to `vi`).
|
||||||
|
|
||||||
|
**`config get`**: Print a config value addressed by dotted YAML path
|
||||||
|
(e.g. `vaultik config get s3.bucket`). Non-scalar values print as YAML.
|
||||||
|
|
||||||
|
**`config set`**: Set a scalar config value by dotted YAML path
|
||||||
|
(e.g. `vaultik config set compression_level 9`). Comments and formatting
|
||||||
|
in the file are preserved; intermediate maps are created as needed.
|
||||||
|
|
||||||
|
**`snapshot create`**: Perform incremental backup of configured snapshots.
|
||||||
* Optional snapshot names argument to create specific snapshots (default: all)
|
* Optional snapshot names argument to create specific snapshots (default: all)
|
||||||
* `--cron`: Silent unless error (for crontab)
|
* `--cron`: Silent unless error (for crontab)
|
||||||
* `--prune`: After backup, drop older snapshots of each backed-up name and
|
* `--prune`: After backup, drop older snapshots of each backed-up name and
|
||||||
@@ -130,16 +167,16 @@ existing file. Created with mode `0600` since it will contain credentials.
|
|||||||
this duration instead of only the latest (e.g. `4w`, `30d`, `6mo`, `1y`)
|
this duration instead of only the latest (e.g. `4w`, `30d`, `6mo`, `1y`)
|
||||||
* `--skip-errors`: Skip file read errors (log them loudly but continue)
|
* `--skip-errors`: Skip file read errors (log them loudly but continue)
|
||||||
|
|
||||||
**snapshot list**: List all snapshots with their timestamps and sizes.
|
**`snapshot list`**: List all snapshots with their timestamps and sizes.
|
||||||
* `--json`: Output in JSON format
|
* `--json`: Output in JSON format
|
||||||
|
|
||||||
**snapshot verify**: Verify snapshot integrity.
|
**`snapshot verify`**: Verify snapshot integrity.
|
||||||
* Default (shallow): checks that all blobs referenced in the manifest exist in storage
|
* Default (shallow): checks that all blobs referenced in the manifest exist in storage
|
||||||
* `--deep`: Downloads and decrypts each blob, verifies chunk hashes against the
|
* `--deep`: Downloads and decrypts each blob, verifies chunk hashes against the
|
||||||
encrypted metadata database
|
encrypted metadata database
|
||||||
* `--json`: Output results as JSON
|
* `--json`: Output results as JSON
|
||||||
|
|
||||||
**snapshot purge**: Remove old snapshots based on criteria. Retention is
|
**`snapshot purge`**: Remove old snapshots based on criteria. Retention is
|
||||||
per-snapshot-name (`--keep-latest` keeps the latest of each name, not the
|
per-snapshot-name (`--keep-latest` keeps the latest of each name, not the
|
||||||
latest globally).
|
latest globally).
|
||||||
* `--keep-latest`: Keep only the most recent snapshot of each name
|
* `--keep-latest`: Keep only the most recent snapshot of each name
|
||||||
@@ -147,42 +184,42 @@ latest globally).
|
|||||||
* `--snapshot <name>`: Restrict to specific snapshot names (repeat for multiple)
|
* `--snapshot <name>`: Restrict to specific snapshot names (repeat for multiple)
|
||||||
* `--force`: Skip confirmation prompt
|
* `--force`: Skip confirmation prompt
|
||||||
|
|
||||||
**snapshot remove**: Remove a specific snapshot from the local database.
|
**`snapshot remove`**: Remove a specific snapshot from the local database.
|
||||||
* `--remote`: Also remove snapshot metadata from remote storage
|
* `--remote`: Also remove snapshot metadata from remote storage
|
||||||
* `--all`: Remove all snapshots (requires `--force`)
|
* `--all`: Remove all snapshots (requires `--force`)
|
||||||
* `--dry-run`: Show what would be deleted without deleting
|
* `--dry-run`: Show what would be deleted without deleting
|
||||||
* `--force`: Skip confirmation prompt
|
* `--force`: Skip confirmation prompt
|
||||||
* `--json`: Output result as JSON
|
* `--json`: Output result as JSON
|
||||||
|
|
||||||
**snapshot prune**: Clean orphaned data from the local database (files,
|
**`snapshot prune`**: Clean orphaned data from the local database (files,
|
||||||
chunks, blobs not referenced by any snapshot).
|
chunks, blobs not referenced by any snapshot).
|
||||||
|
|
||||||
**snapshot cleanup**: Remove stale local snapshot records that have no
|
**`snapshot cleanup`**: Remove stale local snapshot records that have no
|
||||||
corresponding metadata in remote storage. These are typically left behind
|
corresponding metadata in remote storage. These are typically left behind
|
||||||
by incomplete or interrupted backups. Does not touch remote storage.
|
by incomplete or interrupted backups. Does not touch remote storage.
|
||||||
|
|
||||||
**restore**: Restore files from a backup snapshot.
|
**`restore`**: Restore files from a backup snapshot.
|
||||||
* Requires `VAULTIK_AGE_SECRET_KEY` environment variable
|
* Requires `VAULTIK_AGE_SECRET_KEY` environment variable
|
||||||
* Optional path arguments to restore specific files/directories (default: all)
|
* Optional path arguments to restore specific files/directories (default: all)
|
||||||
* Preserves file permissions, timestamps, ownership (ownership requires root),
|
* Preserves file permissions, timestamps, ownership (ownership requires root),
|
||||||
symlinks, and empty directories
|
symlinks, and empty directories
|
||||||
* `--verify`: After restoring, verify every file's chunk hashes match
|
* `--verify`: After restoring, verify every file's chunk hashes match
|
||||||
|
|
||||||
**prune**: Remove unreferenced blobs from remote storage.
|
**`prune`**: Remove unreferenced blobs from remote storage.
|
||||||
* Scans all snapshot manifests for referenced blobs, deletes any blob not referenced
|
* Scans all snapshot manifests for referenced blobs, deletes any blob not referenced
|
||||||
* `--force`: Skip confirmation prompt
|
* `--force`: Skip confirmation prompt
|
||||||
* `--json`: Output stats as JSON
|
* `--json`: Output stats as JSON
|
||||||
|
|
||||||
**info**: Display system configuration, storage settings, encryption
|
**`info`**: Display system configuration, storage settings, encryption
|
||||||
recipients, and local database statistics.
|
recipients, and local database statistics.
|
||||||
|
|
||||||
**remote info**: Show detailed remote storage information including per-snapshot
|
**`remote info`**: Show detailed remote storage information including per-snapshot
|
||||||
metadata sizes, blob counts, and orphaned blob detection.
|
metadata sizes, blob counts, and orphaned blob detection.
|
||||||
* `--json`: Output as JSON
|
* `--json`: Output as JSON
|
||||||
|
|
||||||
**store info**: Display storage backend type and statistics.
|
**`store info`**: Display storage backend type and statistics.
|
||||||
|
|
||||||
**database purge**: Delete the local SQLite state database entirely. Remote
|
**`database purge`**: Delete the local SQLite state database entirely. Remote
|
||||||
storage is unaffected; the next backup will do a full scan and re-deduplicate
|
storage is unaffected; the next backup will do a full scan and re-deduplicate
|
||||||
against existing remote blobs.
|
against existing remote blobs.
|
||||||
* `--force`: Skip confirmation prompt
|
* `--force`: Skip confirmation prompt
|
||||||
@@ -285,7 +322,8 @@ Snapshot IDs follow the format `<hostname>_<snapshot-name>_<RFC3339-timestamp>`
|
|||||||
|
|
||||||
## configuration reference
|
## configuration reference
|
||||||
|
|
||||||
See `config.example.yml` for a complete annotated example. Key fields:
|
Run `vaultik config init` to generate a fully commented config file.
|
||||||
|
Key fields:
|
||||||
|
|
||||||
| Field | Default | Description |
|
| Field | Default | Description |
|
||||||
|-------|---------|-------------|
|
|-------|---------|-------------|
|
||||||
@@ -298,7 +336,7 @@ See `config.example.yml` for a complete annotated example. Key fields:
|
|||||||
| `blob_size_limit` | `10GB` | Maximum blob size before splitting |
|
| `blob_size_limit` | `10GB` | Maximum blob size before splitting |
|
||||||
| `compression_level` | `3` | zstd compression level (1-19) |
|
| `compression_level` | `3` | zstd compression level (1-19) |
|
||||||
| `hostname` | system hostname | Hostname used in snapshot IDs |
|
| `hostname` | system hostname | Hostname used in snapshot IDs |
|
||||||
| `index_path` | `~/.local/share/.../index.sqlite` | Local SQLite index path |
|
| `index_path` | platform data dir | Local SQLite index path |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
"runtime/pprof"
|
"runtime/pprof"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/cli"
|
"sneak.berlin/go/vaultik/internal/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -1,4 +1,4 @@
|
|||||||
module git.eeqj.de/sneak/vaultik
|
module sneak.berlin/go/vaultik
|
||||||
|
|
||||||
go 1.26.1
|
go 1.26.1
|
||||||
|
|
||||||
|
|||||||
@@ -23,12 +23,12 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/blobgen"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/database"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/log"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/types"
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
|
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BlobHandler is a callback function invoked when a blob is finalized and ready for upload.
|
// BlobHandler is a callback function invoked when a blob is finalized and ready for upload.
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
"git.eeqj.de/sneak/vaultik/internal/database"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/log"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/types"
|
|
||||||
"github.com/klauspost/compress/zstd"
|
"github.com/klauspost/compress/zstd"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -10,16 +10,16 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/config"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/database"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/globals"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/log"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/pidlock"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/snapshot"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/storage"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/vaultik"
|
|
||||||
"github.com/adrg/xdg"
|
"github.com/adrg/xdg"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"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/pidlock"
|
||||||
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||||
|
"sneak.berlin/go/vaultik/internal/storage"
|
||||||
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AppOptions contains common options for creating the fx application.
|
// AppOptions contains common options for creating the fx application.
|
||||||
@@ -125,7 +125,7 @@ func RunApp(ctx context.Context, app *fx.App) error {
|
|||||||
// It acquires a PID lock before starting to prevent concurrent instances.
|
// It acquires a PID lock before starting to prevent concurrent instances.
|
||||||
func RunWithApp(ctx context.Context, opts AppOptions) error {
|
func RunWithApp(ctx context.Context, opts AppOptions) error {
|
||||||
// Acquire PID lock to prevent concurrent instances
|
// Acquire PID lock to prevent concurrent instances
|
||||||
lockDir := filepath.Join(xdg.DataHome, "berlin.sneak.app.vaultik")
|
lockDir := filepath.Join(xdg.DataHome, "vaultik")
|
||||||
lock, err := pidlock.Acquire(lockDir)
|
lock, err := pidlock.Acquire(lockDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pidlock.ErrAlreadyRunning) {
|
if errors.Is(err, pidlock.ErrAlreadyRunning) {
|
||||||
|
|||||||
@@ -3,20 +3,25 @@ package cli
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultConfigTemplate = `# vaultik configuration
|
const defaultConfigTemplate = `# vaultik configuration
|
||||||
# Documentation: https://git.eeqj.de/sneak/vaultik
|
# Documentation: https://sneak.berlin/go/vaultik
|
||||||
|
|
||||||
# ─── REQUIRED ────────────────────────────────────────────────────────────────
|
# ─── REQUIRED ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Age recipient public keys for encryption.
|
# Age recipient public keys for encryption.
|
||||||
# Backups are encrypted to ALL listed recipients. Any one of the corresponding
|
# Backups are encrypted to ALL listed recipients. Any one of the corresponding
|
||||||
# private keys can decrypt. Generate a keypair with:
|
# private keys can decrypt. Generate a keypair with:
|
||||||
# age-keygen -o key.txt && grep 'public key' key.txt
|
# age-keygen -o vaultik_backup_private_key.txt
|
||||||
|
# grep 'public key' vaultik_backup_private_key.txt
|
||||||
age_recipients:
|
age_recipients:
|
||||||
- age1REPLACE_WITH_YOUR_PUBLIC_KEY
|
- age1REPLACE_WITH_YOUR_PUBLIC_KEY
|
||||||
|
|
||||||
@@ -192,13 +197,31 @@ storage_url: ""
|
|||||||
# hostname: myserver
|
# hostname: myserver
|
||||||
|
|
||||||
# Path to the local SQLite index database.
|
# Path to the local SQLite index database.
|
||||||
# Default: ~/.local/share/berlin.sneak.app.vaultik/index.sqlite
|
# Default: the platform data directory, e.g.
|
||||||
|
# macOS: ~/Library/Application Support/vaultik/index.sqlite
|
||||||
|
# Linux: ~/.local/share/vaultik/index.sqlite
|
||||||
# index_path: /path/to/index.sqlite
|
# index_path: /path/to/index.sqlite
|
||||||
`
|
`
|
||||||
|
|
||||||
// NewInitCommand creates the init command that writes a default config file.
|
// NewConfigCommand creates the config command group.
|
||||||
func NewInitCommand() *cobra.Command {
|
func NewConfigCommand() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
|
Use: "config",
|
||||||
|
Short: "Manage the configuration file",
|
||||||
|
Long: "Commands for creating, editing, and querying the vaultik config file.",
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.AddCommand(newConfigInitCommand())
|
||||||
|
cmd.AddCommand(newConfigEditCommand())
|
||||||
|
cmd.AddCommand(newConfigGetCommand())
|
||||||
|
cmd.AddCommand(newConfigSetCommand())
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// newConfigInitCommand creates the 'config init' subcommand.
|
||||||
|
func newConfigInitCommand() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
Use: "init",
|
Use: "init",
|
||||||
Short: "Write a default config file",
|
Short: "Write a default config file",
|
||||||
Long: `Creates a default configuration file with commented explanations
|
Long: `Creates a default configuration file with commented explanations
|
||||||
@@ -230,8 +253,260 @@ on macOS, ~/.config/ on Linux, /etc/vaultik/ as root).`,
|
|||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return cmd
|
// newConfigEditCommand creates the 'config edit' subcommand.
|
||||||
|
func newConfigEditCommand() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "edit",
|
||||||
|
Short: "Open the config file in $EDITOR",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
path, err := ResolveConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
editor := os.Getenv("EDITOR")
|
||||||
|
if editor == "" {
|
||||||
|
editor = "vi"
|
||||||
|
}
|
||||||
|
|
||||||
|
ed := exec.Command(editor, path)
|
||||||
|
ed.Stdin = os.Stdin
|
||||||
|
ed.Stdout = os.Stdout
|
||||||
|
ed.Stderr = os.Stderr
|
||||||
|
return ed.Run()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newConfigGetCommand creates the 'config get' subcommand.
|
||||||
|
func newConfigGetCommand() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "get <key>",
|
||||||
|
Short: "Print a config value by dotted path (e.g. s3.bucket)",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
path, err := ResolveConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
root, err := loadYAMLFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
node, err := yamlPathGet(root, strings.Split(args[0], "."))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if node.Kind == yaml.ScalarNode {
|
||||||
|
fmt.Println(node.Value)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := yaml.Marshal(node)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshaling value: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Print(string(out))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newConfigSetCommand creates the 'config set' subcommand.
|
||||||
|
func newConfigSetCommand() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "set <key> <value>",
|
||||||
|
Short: "Set a config value by dotted path (e.g. compression_level 5)",
|
||||||
|
Long: `Sets a scalar config value addressed by dotted YAML path and writes
|
||||||
|
the file back, preserving comments and formatting. Intermediate maps
|
||||||
|
are created as needed.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
vaultik config set compression_level 9
|
||||||
|
vaultik config set s3.bucket mybucket
|
||||||
|
vaultik config set storage_url "file:///mnt/backups"`,
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
path, err := ResolveConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
root, err := loadYAMLFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := yamlPathSet(root, strings.Split(args[0], "."), args[1]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := yaml.Marshal(root)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshaling config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mode := os.FileMode(0o600)
|
||||||
|
if info, err := os.Stat(path); err == nil {
|
||||||
|
mode = info.Mode().Perm()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(path, out, mode); err != nil {
|
||||||
|
return fmt.Errorf("writing config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%s = %s\n", args[0], args[1])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadYAMLFile parses a YAML file into a yaml.Node document tree,
|
||||||
|
// which preserves comments and ordering for round-tripping.
|
||||||
|
func loadYAMLFile(path string) (*yaml.Node, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var root yaml.Node
|
||||||
|
if err := yaml.Unmarshal(data, &root); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty file yields a zero node; normalize to an empty mapping document.
|
||||||
|
if root.Kind == 0 {
|
||||||
|
root = yaml.Node{
|
||||||
|
Kind: yaml.DocumentNode,
|
||||||
|
Content: []*yaml.Node{{Kind: yaml.MappingNode}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &root, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlPathGet navigates a dotted key path through mapping and sequence
|
||||||
|
// nodes and returns the value node. Numeric path components index into
|
||||||
|
// sequences (e.g. "age_recipients.0").
|
||||||
|
func yamlPathGet(root *yaml.Node, keys []string) (*yaml.Node, error) {
|
||||||
|
node := root
|
||||||
|
if node.Kind == yaml.DocumentNode {
|
||||||
|
if len(node.Content) == 0 {
|
||||||
|
return nil, fmt.Errorf("empty config file")
|
||||||
|
}
|
||||||
|
node = node.Content[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, key := range keys {
|
||||||
|
switch node.Kind {
|
||||||
|
case yaml.MappingNode:
|
||||||
|
found := false
|
||||||
|
for j := 0; j+1 < len(node.Content); j += 2 {
|
||||||
|
if node.Content[j].Value == key {
|
||||||
|
node = node.Content[j+1]
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return nil, fmt.Errorf("key not found: %s", strings.Join(keys[:i+1], "."))
|
||||||
|
}
|
||||||
|
case yaml.SequenceNode:
|
||||||
|
idx, err := strconv.Atoi(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("key %q is a list; use a numeric index", strings.Join(keys[:i], "."))
|
||||||
|
}
|
||||||
|
if idx < 0 || idx >= len(node.Content) {
|
||||||
|
return nil, fmt.Errorf("index %d out of range for %s (len %d)", idx, strings.Join(keys[:i], "."), len(node.Content))
|
||||||
|
}
|
||||||
|
node = node.Content[idx]
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("key %q is not a map or list", strings.Join(keys[:i], "."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return node, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlPathSet navigates a dotted key path, creating intermediate maps as
|
||||||
|
// needed, and sets the final key to the given scalar value. Numeric path
|
||||||
|
// components index into sequences; an index equal to the sequence length
|
||||||
|
// appends a new element (e.g. "age_recipients.1" on a 1-element list).
|
||||||
|
func yamlPathSet(root *yaml.Node, keys []string, value string) error {
|
||||||
|
node := root
|
||||||
|
if node.Kind == yaml.DocumentNode {
|
||||||
|
if len(node.Content) == 0 {
|
||||||
|
node.Content = []*yaml.Node{{Kind: yaml.MappingNode}}
|
||||||
|
}
|
||||||
|
node = node.Content[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, key := range keys {
|
||||||
|
last := i == len(keys)-1
|
||||||
|
|
||||||
|
switch node.Kind {
|
||||||
|
case yaml.MappingNode:
|
||||||
|
var valueNode *yaml.Node
|
||||||
|
for j := 0; j+1 < len(node.Content); j += 2 {
|
||||||
|
if node.Content[j].Value == key {
|
||||||
|
valueNode = node.Content[j+1]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if valueNode == nil {
|
||||||
|
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: key}
|
||||||
|
valueNode = &yaml.Node{Kind: yaml.MappingNode}
|
||||||
|
if last {
|
||||||
|
valueNode = &yaml.Node{Kind: yaml.ScalarNode, Value: value}
|
||||||
|
}
|
||||||
|
node.Content = append(node.Content, keyNode, valueNode)
|
||||||
|
} else if last {
|
||||||
|
setScalar(valueNode, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
node = valueNode
|
||||||
|
|
||||||
|
case yaml.SequenceNode:
|
||||||
|
idx, err := strconv.Atoi(key)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("key %q is a list; use a numeric index", strings.Join(keys[:i], "."))
|
||||||
|
}
|
||||||
|
if idx < 0 || idx > len(node.Content) {
|
||||||
|
return fmt.Errorf("index %d out of range for %s (len %d)", idx, strings.Join(keys[:i], "."), len(node.Content))
|
||||||
|
}
|
||||||
|
if idx == len(node.Content) {
|
||||||
|
newNode := &yaml.Node{Kind: yaml.MappingNode}
|
||||||
|
if last {
|
||||||
|
newNode = &yaml.Node{Kind: yaml.ScalarNode, Value: value}
|
||||||
|
}
|
||||||
|
node.Content = append(node.Content, newNode)
|
||||||
|
} else if last {
|
||||||
|
setScalar(node.Content[idx], value)
|
||||||
|
}
|
||||||
|
node = node.Content[idx]
|
||||||
|
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("key %q is not a map or list", strings.Join(keys[:i], "."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setScalar overwrites a node in place with a plain scalar value.
|
||||||
|
func setScalar(n *yaml.Node, value string) {
|
||||||
|
n.Kind = yaml.ScalarNode
|
||||||
|
n.Tag = ""
|
||||||
|
n.Value = value
|
||||||
|
n.Content = nil
|
||||||
|
n.Style = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// configPathForInit returns the config path to write, checking --config flag,
|
// configPathForInit returns the config path to write, checking --config flag,
|
||||||
161
internal/cli/config_test.go
Normal file
161
internal/cli/config_test.go
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDefaultConfigTemplateParses ensures the init template is valid YAML
|
||||||
|
// that unmarshals into the Config struct with the expected snapshots.
|
||||||
|
func TestDefaultConfigTemplateParses(t *testing.T) {
|
||||||
|
var cfg config.Config
|
||||||
|
if err := yaml.Unmarshal([]byte(defaultConfigTemplate), &cfg); err != nil {
|
||||||
|
t.Fatalf("default config template is not valid YAML: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.AgeRecipients) != 1 {
|
||||||
|
t.Errorf("expected 1 placeholder age recipient, got %d", len(cfg.AgeRecipients))
|
||||||
|
}
|
||||||
|
|
||||||
|
home, ok := cfg.Snapshots["home"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected 'home' snapshot in default config")
|
||||||
|
}
|
||||||
|
if len(home.Paths) == 0 {
|
||||||
|
t.Error("home snapshot should have at least one path")
|
||||||
|
}
|
||||||
|
if len(home.Exclude) == 0 {
|
||||||
|
t.Error("home snapshot should have exclude patterns")
|
||||||
|
}
|
||||||
|
|
||||||
|
apps, ok := cfg.Snapshots["apps"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected 'apps' snapshot in default config")
|
||||||
|
}
|
||||||
|
if len(apps.Paths) != 1 || apps.Paths[0] != "/Applications" {
|
||||||
|
t.Errorf("apps snapshot should back up /Applications, got %v", apps.Paths)
|
||||||
|
}
|
||||||
|
if len(apps.Exclude) == 0 {
|
||||||
|
t.Error("apps snapshot should have exclude patterns")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const testYAML = `# top comment
|
||||||
|
compression_level: 3
|
||||||
|
age_recipients:
|
||||||
|
- age1aaa
|
||||||
|
s3:
|
||||||
|
bucket: oldbucket # inline comment
|
||||||
|
region: us-east-1
|
||||||
|
snapshots:
|
||||||
|
home:
|
||||||
|
paths:
|
||||||
|
- "~"
|
||||||
|
`
|
||||||
|
|
||||||
|
func parseTestYAML(t *testing.T) *yaml.Node {
|
||||||
|
t.Helper()
|
||||||
|
var root yaml.Node
|
||||||
|
if err := yaml.Unmarshal([]byte(testYAML), &root); err != nil {
|
||||||
|
t.Fatalf("parsing test yaml: %v", err)
|
||||||
|
}
|
||||||
|
return &root
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestYAMLPathGet(t *testing.T) {
|
||||||
|
root := parseTestYAML(t)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
path string
|
||||||
|
want string
|
||||||
|
err bool
|
||||||
|
}{
|
||||||
|
{"compression_level", "3", false},
|
||||||
|
{"s3.bucket", "oldbucket", false},
|
||||||
|
{"s3.region", "us-east-1", false},
|
||||||
|
{"age_recipients.0", "age1aaa", false},
|
||||||
|
{"age_recipients.5", "", true},
|
||||||
|
{"age_recipients.notanumber", "", true},
|
||||||
|
{"s3.nonexistent", "", true},
|
||||||
|
{"nonexistent", "", true},
|
||||||
|
{"compression_level.sub", "", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.path, func(t *testing.T) {
|
||||||
|
node, err := yamlPathGet(root, splitPath(tt.path))
|
||||||
|
if tt.err {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error for %q", tt.path)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if node.Value != tt.want {
|
||||||
|
t.Errorf("get %q = %q, want %q", tt.path, node.Value, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestYAMLPathSet(t *testing.T) {
|
||||||
|
root := parseTestYAML(t)
|
||||||
|
|
||||||
|
// Overwrite existing nested value
|
||||||
|
if err := yamlPathSet(root, splitPath("s3.bucket"), "newbucket"); err != nil {
|
||||||
|
t.Fatalf("set s3.bucket: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new nested key with intermediate map
|
||||||
|
if err := yamlPathSet(root, splitPath("s3.endpoint"), "s3.example.com"); err != nil {
|
||||||
|
t.Fatalf("set s3.endpoint: %v", err)
|
||||||
|
}
|
||||||
|
if err := yamlPathSet(root, splitPath("newmap.newkey"), "val"); err != nil {
|
||||||
|
t.Fatalf("set newmap.newkey: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overwrite a sequence element and append a new one
|
||||||
|
if err := yamlPathSet(root, splitPath("age_recipients.0"), "age1bbb"); err != nil {
|
||||||
|
t.Fatalf("set age_recipients.0: %v", err)
|
||||||
|
}
|
||||||
|
if err := yamlPathSet(root, splitPath("age_recipients.1"), "age1ccc"); err != nil {
|
||||||
|
t.Fatalf("append age_recipients.1: %v", err)
|
||||||
|
}
|
||||||
|
if err := yamlPathSet(root, splitPath("age_recipients.5"), "age1ddd"); err == nil {
|
||||||
|
t.Error("expected out-of-range append to fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round-trip and verify values + comment preservation
|
||||||
|
out, err := yaml.Marshal(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
text := string(out)
|
||||||
|
|
||||||
|
for _, want := range []string{"newbucket", "s3.example.com", "newkey: val", "# top comment", "# inline comment", "age1bbb", "age1ccc"} {
|
||||||
|
if !contains(text, want) {
|
||||||
|
t.Errorf("round-tripped YAML missing %q:\n%s", want, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := yamlPathGet(root, splitPath("s3.bucket"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get after set: %v", err)
|
||||||
|
}
|
||||||
|
if got.Value != "newbucket" {
|
||||||
|
t.Errorf("s3.bucket = %q after set, want newbucket", got.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitPath(s string) []string {
|
||||||
|
return strings.Split(s, ".")
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(haystack, needle string) bool {
|
||||||
|
return strings.Contains(haystack, needle)
|
||||||
|
}
|
||||||
@@ -4,9 +4,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/config"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/log"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewDatabaseCommand creates the database command group
|
// NewDatabaseCommand creates the database command group
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ func TestCLIEntry(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify all subcommands are registered
|
// Verify all subcommands are registered
|
||||||
expectedCommands := []string{"init", "snapshot", "store", "restore", "prune", "info", "version", "remote", "database"}
|
expectedCommands := []string{"config", "snapshot", "store", "restore", "prune", "info", "version", "remote", "database"}
|
||||||
for _, expected := range expectedCommands {
|
for _, expected := range expectedCommands {
|
||||||
found := false
|
found := false
|
||||||
for _, cmd := range cmd.Commands() {
|
for _, cmd := range cmd.Commands() {
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/log"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/vaultik"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewInfoCommand creates the info command
|
// NewInfoCommand creates the info command
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
package cli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/config"
|
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestDefaultConfigTemplateParses ensures the init template is valid YAML
|
|
||||||
// that unmarshals into the Config struct with the expected snapshots.
|
|
||||||
func TestDefaultConfigTemplateParses(t *testing.T) {
|
|
||||||
var cfg config.Config
|
|
||||||
if err := yaml.Unmarshal([]byte(defaultConfigTemplate), &cfg); err != nil {
|
|
||||||
t.Fatalf("default config template is not valid YAML: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(cfg.AgeRecipients) != 1 {
|
|
||||||
t.Errorf("expected 1 placeholder age recipient, got %d", len(cfg.AgeRecipients))
|
|
||||||
}
|
|
||||||
|
|
||||||
home, ok := cfg.Snapshots["home"]
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("expected 'home' snapshot in default config")
|
|
||||||
}
|
|
||||||
if len(home.Paths) == 0 {
|
|
||||||
t.Error("home snapshot should have at least one path")
|
|
||||||
}
|
|
||||||
if len(home.Exclude) == 0 {
|
|
||||||
t.Error("home snapshot should have exclude patterns")
|
|
||||||
}
|
|
||||||
|
|
||||||
apps, ok := cfg.Snapshots["apps"]
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("expected 'apps' snapshot in default config")
|
|
||||||
}
|
|
||||||
if len(apps.Paths) != 1 || apps.Paths[0] != "/Applications" {
|
|
||||||
t.Errorf("apps snapshot should back up /Applications, got %v", apps.Paths)
|
|
||||||
}
|
|
||||||
if len(apps.Exclude) == 0 {
|
|
||||||
t.Error("apps snapshot should have exclude patterns")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,10 +4,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/log"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/vaultik"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewPruneCommand creates the prune command
|
// NewPruneCommand creates the prune command
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/log"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/vaultik"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewRemoteCommand creates the remote command and subcommands
|
// NewRemoteCommand creates the remote command and subcommands
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/config"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/globals"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/log"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/storage"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/vaultik"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
|
"sneak.berlin/go/vaultik/internal/globals"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/storage"
|
||||||
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RestoreOptions contains options for the restore command
|
// RestoreOptions contains options for the restore command
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ on the source system.`,
|
|||||||
|
|
||||||
// Add subcommands
|
// Add subcommands
|
||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
NewInitCommand(),
|
NewConfigCommand(),
|
||||||
NewRestoreCommand(),
|
NewRestoreCommand(),
|
||||||
NewPruneCommand(),
|
NewPruneCommand(),
|
||||||
NewStoreCommand(),
|
NewStoreCommand(),
|
||||||
@@ -78,7 +78,7 @@ func ResolveConfigPath() (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", fmt.Errorf("no config file found; run 'vaultik init' to create one, or specify with --config")
|
return "", fmt.Errorf("no config file found; run 'vaultik config init' to create one, or specify with --config")
|
||||||
}
|
}
|
||||||
|
|
||||||
// defaultConfigPaths returns the ordered list of config paths to search.
|
// defaultConfigPaths returns the ordered list of config paths to search.
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/log"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/vaultik"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewSnapshotCommand creates the snapshot command and subcommands
|
// NewSnapshotCommand creates the snapshot command and subcommands
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/log"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/storage"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// StoreApp contains dependencies for store commands
|
// StoreApp contains dependencies for store commands
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/globals"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"sneak.berlin/go/vaultik/internal/globals"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewVersionCommand creates the version command
|
// NewVersionCommand creates the version command
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ import (
|
|||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
"git.eeqj.de/sneak/smartconfig"
|
"git.eeqj.de/sneak/smartconfig"
|
||||||
"git.eeqj.de/sneak/vaultik/internal/log"
|
|
||||||
"github.com/adrg/xdg"
|
"github.com/adrg/xdg"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
const appName = "berlin.sneak.app.vaultik"
|
const appName = "vaultik"
|
||||||
|
|
||||||
// expandTilde expands ~ at the start of a path to the user's home directory.
|
// expandTilde expands ~ at the start of a path to the user's home directory.
|
||||||
func expandTilde(path string) string {
|
func expandTilde(path string) string {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/types"
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBlobChunkRepository(t *testing.T) {
|
func TestBlobChunkRepository(t *testing.T) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/log"
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
type BlobRepository struct {
|
type BlobRepository struct {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/types"
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBlobRepository(t *testing.T) {
|
func TestBlobRepository(t *testing.T) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/types"
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestCascadeDeleteDebug tests cascade delete with debug output
|
// TestCascadeDeleteDebug tests cascade delete with debug output
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/types"
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ChunkFileRepository struct {
|
type ChunkFileRepository struct {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/types"
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestChunkFileRepository(t *testing.T) {
|
func TestChunkFileRepository(t *testing.T) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/log"
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ChunkRepository struct {
|
type ChunkRepository struct {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/types"
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestChunkRepository(t *testing.T) {
|
func TestChunkRepository(t *testing.T) {
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/log"
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed schema/*.sql
|
//go:embed schema/*.sql
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/types"
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
type FileChunkRepository struct {
|
type FileChunkRepository struct {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/types"
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFileChunkRepository(t *testing.T) {
|
func TestFileChunkRepository(t *testing.T) {
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/log"
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
"git.eeqj.de/sneak/vaultik/internal/types"
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
type FileRepository struct {
|
type FileRepository struct {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ package database
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/types"
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// File represents a file or directory in the backup system.
|
// File represents a file or directory in the backup system.
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/config"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/log"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Module provides database dependencies
|
// Module provides database dependencies
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/types"
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRepositoriesTransaction(t *testing.T) {
|
func TestRepositoriesTransaction(t *testing.T) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/types"
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestFileRepositoryUUIDGeneration tests that files get unique UUIDs
|
// TestFileRepositoryUUIDGeneration tests that files get unique UUIDs
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/types"
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestFileRepositoryEdgeCases tests edge cases for file repository
|
// TestFileRepositoryEdgeCases tests edge cases for file repository
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/types"
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SnapshotRepository struct {
|
type SnapshotRepository struct {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/types"
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/log"
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Upload represents a blob upload record
|
// Upload represents a blob upload record
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/s3"
|
"sneak.berlin/go/vaultik/internal/s3"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestClient(t *testing.T) {
|
func TestClient(t *testing.T) {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package s3
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/config"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Module exports S3 functionality as an fx module.
|
// Module exports S3 functionality as an fx module.
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import (
|
|||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/database"
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
"git.eeqj.de/sneak/vaultik/internal/types"
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MockS3Client is a mock implementation of S3 operations for testing
|
// MockS3Client is a mock implementation of S3 operations for testing
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/database"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/log"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/snapshot"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/types"
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/stretchr/testify/require"
|
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
func setupExcludeTestFS(t *testing.T) afero.Fs {
|
func setupExcludeTestFS(t *testing.T) afero.Fs {
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/database"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/log"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/snapshot"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/types"
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||||
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestFileContentChange verifies that when a file's content changes,
|
// TestFileContentChange verifies that when a file's content changes,
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
package snapshot
|
package snapshot
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"git.eeqj.de/sneak/vaultik/internal/config"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/database"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/storage"
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
"sneak.berlin/go/vaultik/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ScannerParams holds parameters for scanner creation
|
// ScannerParams holds parameters for scanner creation
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/log"
|
|
||||||
"github.com/dustin/go-humanize"
|
"github.com/dustin/go-humanize"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -12,15 +12,15 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/blob"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/chunker"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/database"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/log"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/storage"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/types"
|
|
||||||
"github.com/dustin/go-humanize"
|
"github.com/dustin/go-humanize"
|
||||||
"github.com/gobwas/glob"
|
"github.com/gobwas/glob"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
|
"sneak.berlin/go/vaultik/internal/blob"
|
||||||
|
"sneak.berlin/go/vaultik/internal/chunker"
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/storage"
|
||||||
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FileToProcess holds information about a file that needs processing
|
// FileToProcess holds information about a file that needs processing
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/database"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/log"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/snapshot"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/types"
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||||
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestScannerSimpleDirectory(t *testing.T) {
|
func TestScannerSimpleDirectory(t *testing.T) {
|
||||||
|
|||||||
@@ -44,15 +44,15 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/blobgen"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/config"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/database"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/log"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/storage"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/types"
|
|
||||||
"github.com/dustin/go-humanize"
|
"github.com/dustin/go-humanize"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||||
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/storage"
|
||||||
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SnapshotManager handles snapshot creation and metadata export
|
// SnapshotManager handles snapshot creation and metadata export
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/config"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/database"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/log"
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/config"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/s3"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
|
"sneak.berlin/go/vaultik/internal/s3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Module exports storage functionality as an fx module.
|
// Module exports storage functionality as an fx module.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/s3"
|
"sneak.berlin/go/vaultik/internal/s3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// S3Storer wraps the existing s3.Client to implement Storer.
|
// S3Storer wraps the existing s3.Client to implement Storer.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
"git.eeqj.de/sneak/vaultik/internal/blobgen"
|
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||||
)
|
)
|
||||||
|
|
||||||
// hashVerifyReader wraps a blobgen.Reader and verifies the double-SHA-256 hash
|
// hashVerifyReader wraps a blobgen.Reader and verifies the double-SHA-256 hash
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
"git.eeqj.de/sneak/vaultik/internal/blobgen"
|
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||||
"git.eeqj.de/sneak/vaultik/internal/vaultik"
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestFetchAndDecryptBlobVerifiesHash verifies that FetchAndDecryptBlob checks
|
// TestFetchAndDecryptBlobVerifiesHash verifies that FetchAndDecryptBlob checks
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/types"
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SnapshotInfo contains information about a snapshot
|
// SnapshotInfo contains information about a snapshot
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/log"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/snapshot"
|
|
||||||
"github.com/dustin/go-humanize"
|
"github.com/dustin/go-humanize"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ShowInfo displays system and configuration information
|
// ShowInfo displays system and configuration information
|
||||||
|
|||||||
@@ -11,16 +11,16 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/config"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/database"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/log"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/snapshot"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/storage"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/types"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/vaultik"
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
|
"sneak.berlin/go/vaultik/internal/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"
|
||||||
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MockStorer implements storage.Storer for testing
|
// MockStorer implements storage.Storer for testing
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/log"
|
|
||||||
"github.com/dustin/go-humanize"
|
"github.com/dustin/go-humanize"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PruneOptions contains options for the prune command
|
// PruneOptions contains options for the prune command
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/database"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/log"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/types"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/vaultik"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
)
|
)
|
||||||
|
|
||||||
// setupPurgeTest creates a Vaultik instance with an in-memory database and mock
|
// setupPurgeTest creates a Vaultik instance with an in-memory database and mock
|
||||||
|
|||||||
351
internal/vaultik/remove_snapshot_test.go
Normal file
351
internal/vaultik/remove_snapshot_test.go
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
package vaultik_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/klauspost/compress/zstd"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||||
|
"sneak.berlin/go/vaultik/internal/storage"
|
||||||
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testStorer implements storage.Storer for testing
|
||||||
|
type testStorer struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
data map[string][]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestStorer() *testStorer {
|
||||||
|
return &testStorer{
|
||||||
|
data: make(map[string][]byte),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testStorer) Put(ctx context.Context, key string, reader io.Reader) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.data[key] = data
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testStorer) PutWithProgress(ctx context.Context, key string, reader io.Reader, size int64, progress storage.ProgressCallback) error {
|
||||||
|
return s.Put(ctx, key, reader)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testStorer) Get(ctx context.Context, key string) (io.ReadCloser, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
data, exists := s.data[key]
|
||||||
|
if !exists {
|
||||||
|
return nil, storage.ErrNotFound
|
||||||
|
}
|
||||||
|
return io.NopCloser(bytes.NewReader(data)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testStorer) Stat(ctx context.Context, key string) (*storage.ObjectInfo, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
data, exists := s.data[key]
|
||||||
|
if !exists {
|
||||||
|
return nil, storage.ErrNotFound
|
||||||
|
}
|
||||||
|
return &storage.ObjectInfo{
|
||||||
|
Key: key,
|
||||||
|
Size: int64(len(data)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testStorer) Delete(ctx context.Context, key string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
delete(s.data, key)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testStorer) List(ctx context.Context, prefix string) ([]string, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
var keys []string
|
||||||
|
for key := range s.data {
|
||||||
|
if prefix == "" || strings.HasPrefix(key, prefix) {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testStorer) ListStream(ctx context.Context, prefix string) <-chan storage.ObjectInfo {
|
||||||
|
ch := make(chan storage.ObjectInfo)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(ch)
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
for key, data := range s.data {
|
||||||
|
if prefix == "" || strings.HasPrefix(key, prefix) {
|
||||||
|
ch <- storage.ObjectInfo{
|
||||||
|
Key: key,
|
||||||
|
Size: int64(len(data)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testStorer) hasKey(key string) bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
_, exists := s.data[key]
|
||||||
|
return exists
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testStorer) keyCount() int {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return len(s.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testStorer) Info() storage.StorageInfo {
|
||||||
|
return storage.StorageInfo{
|
||||||
|
Type: "test",
|
||||||
|
Location: "memory",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// addManifest creates a compressed manifest in storage
|
||||||
|
func addManifest(t *testing.T, store *testStorer, snapshotID string, blobHashes []string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
blobs := make([]snapshot.BlobInfo, len(blobHashes))
|
||||||
|
for i, hash := range blobHashes {
|
||||||
|
blobs[i] = snapshot.BlobInfo{
|
||||||
|
Hash: hash,
|
||||||
|
CompressedSize: 1000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest := &snapshot.Manifest{
|
||||||
|
SnapshotID: snapshotID,
|
||||||
|
BlobCount: len(blobs),
|
||||||
|
Blobs: blobs,
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := snapshot.EncodeManifest(manifest, 3)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
key := "metadata/" + snapshotID + "/manifest.json.zst"
|
||||||
|
err = store.Put(context.Background(), key, bytes.NewReader(data))
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// addBlob adds a fake blob to storage
|
||||||
|
func addBlob(t *testing.T, store *testStorer, hash string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Create zstd compressed data
|
||||||
|
var buf bytes.Buffer
|
||||||
|
writer, _ := zstd.NewWriter(&buf)
|
||||||
|
_, _ = writer.Write([]byte("blob data"))
|
||||||
|
_ = writer.Close()
|
||||||
|
|
||||||
|
key := "blobs/" + hash[:2] + "/" + hash[2:4] + "/" + hash
|
||||||
|
err := store.Put(context.Background(), key, bytes.NewReader(buf.Bytes()))
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Unit Tests for RemoveSnapshot
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
func TestRemoveSnapshot_LocalOnly(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
|
||||||
|
store := newTestStorer()
|
||||||
|
|
||||||
|
blobA := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
addManifest(t, store, "snapshot-001", []string{blobA})
|
||||||
|
addBlob(t, store, blobA)
|
||||||
|
|
||||||
|
tv := vaultik.NewForTesting(store)
|
||||||
|
|
||||||
|
opts := &vaultik.RemoveOptions{Force: true}
|
||||||
|
result, err := tv.RemoveSnapshot("snapshot-001", opts)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "snapshot-001", result.SnapshotID)
|
||||||
|
assert.False(t, result.RemoteRemoved)
|
||||||
|
|
||||||
|
// Blobs should NOT be deleted (that's what prune is for)
|
||||||
|
assert.True(t, store.hasKey("blobs/aa/aa/"+blobA))
|
||||||
|
// Remote metadata should NOT be deleted (no --remote flag)
|
||||||
|
assert.True(t, store.hasKey("metadata/snapshot-001/manifest.json.zst"))
|
||||||
|
|
||||||
|
// Verify output
|
||||||
|
assert.Contains(t, tv.Stdout.String(), "Removed snapshot 'snapshot-001' from local database")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveSnapshot_WithRemote(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
|
||||||
|
store := newTestStorer()
|
||||||
|
|
||||||
|
blobA := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
addManifest(t, store, "snapshot-001", []string{blobA})
|
||||||
|
addBlob(t, store, blobA)
|
||||||
|
|
||||||
|
tv := vaultik.NewForTesting(store)
|
||||||
|
|
||||||
|
opts := &vaultik.RemoveOptions{Force: true, Remote: true}
|
||||||
|
result, err := tv.RemoveSnapshot("snapshot-001", opts)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "snapshot-001", result.SnapshotID)
|
||||||
|
assert.True(t, result.RemoteRemoved)
|
||||||
|
|
||||||
|
// Blobs should NOT be deleted
|
||||||
|
assert.True(t, store.hasKey("blobs/aa/aa/"+blobA))
|
||||||
|
// Remote metadata SHOULD be deleted
|
||||||
|
assert.False(t, store.hasKey("metadata/snapshot-001/manifest.json.zst"))
|
||||||
|
|
||||||
|
// Verify output mentions prune
|
||||||
|
assert.Contains(t, tv.Stdout.String(), "Removed snapshot 'snapshot-001' from local database")
|
||||||
|
assert.Contains(t, tv.Stdout.String(), "Removed snapshot metadata from remote storage")
|
||||||
|
assert.Contains(t, tv.Stdout.String(), "Run 'vaultik prune' to remove orphaned blobs")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveSnapshot_DryRun(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
|
||||||
|
store := newTestStorer()
|
||||||
|
|
||||||
|
blobA := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
addManifest(t, store, "snapshot-001", []string{blobA})
|
||||||
|
addBlob(t, store, blobA)
|
||||||
|
|
||||||
|
initialCount := store.keyCount()
|
||||||
|
|
||||||
|
tv := vaultik.NewForTesting(store)
|
||||||
|
|
||||||
|
opts := &vaultik.RemoveOptions{Force: true, DryRun: true, Remote: true}
|
||||||
|
result, err := tv.RemoveSnapshot("snapshot-001", opts)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, result.DryRun)
|
||||||
|
|
||||||
|
// Nothing should be deleted
|
||||||
|
assert.Equal(t, initialCount, store.keyCount())
|
||||||
|
assert.True(t, store.hasKey("blobs/aa/aa/"+blobA))
|
||||||
|
assert.True(t, store.hasKey("metadata/snapshot-001/manifest.json.zst"))
|
||||||
|
|
||||||
|
// Verify dry run message
|
||||||
|
assert.Contains(t, tv.Stdout.String(), "[Dry run - no changes made]")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveAllSnapshots_RequiresForce(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
|
||||||
|
store := newTestStorer()
|
||||||
|
addManifest(t, store, "snapshot-001", []string{})
|
||||||
|
addManifest(t, store, "snapshot-002", []string{})
|
||||||
|
|
||||||
|
tv := vaultik.NewForTesting(store)
|
||||||
|
|
||||||
|
opts := &vaultik.RemoveOptions{All: true} // No Force
|
||||||
|
_, err := tv.RemoveAllSnapshots(opts)
|
||||||
|
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "--all requires --force")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveAllSnapshots_WithForce(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
|
||||||
|
store := newTestStorer()
|
||||||
|
|
||||||
|
blobA := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
addManifest(t, store, "snapshot-001", []string{blobA})
|
||||||
|
addManifest(t, store, "snapshot-002", []string{blobA})
|
||||||
|
addBlob(t, store, blobA)
|
||||||
|
|
||||||
|
tv := vaultik.NewForTesting(store)
|
||||||
|
|
||||||
|
opts := &vaultik.RemoveOptions{All: true, Force: true, Remote: true}
|
||||||
|
result, err := tv.RemoveAllSnapshots(opts)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, result.SnapshotsRemoved, 2)
|
||||||
|
assert.True(t, result.RemoteRemoved)
|
||||||
|
|
||||||
|
// Blobs should NOT be deleted
|
||||||
|
assert.True(t, store.hasKey("blobs/aa/aa/"+blobA))
|
||||||
|
// Remote metadata SHOULD be deleted
|
||||||
|
assert.False(t, store.hasKey("metadata/snapshot-001/manifest.json.zst"))
|
||||||
|
assert.False(t, store.hasKey("metadata/snapshot-002/manifest.json.zst"))
|
||||||
|
|
||||||
|
// Verify output
|
||||||
|
assert.Contains(t, tv.Stdout.String(), "Removed 2 snapshot(s)")
|
||||||
|
assert.Contains(t, tv.Stdout.String(), "Run 'vaultik prune' to remove orphaned blobs")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveAllSnapshots_DryRun(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
|
||||||
|
store := newTestStorer()
|
||||||
|
addManifest(t, store, "snapshot-001", []string{})
|
||||||
|
addManifest(t, store, "snapshot-002", []string{})
|
||||||
|
|
||||||
|
initialCount := store.keyCount()
|
||||||
|
|
||||||
|
tv := vaultik.NewForTesting(store)
|
||||||
|
|
||||||
|
opts := &vaultik.RemoveOptions{All: true, Force: true, DryRun: true}
|
||||||
|
result, err := tv.RemoveAllSnapshots(opts)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, result.DryRun)
|
||||||
|
assert.Len(t, result.SnapshotsRemoved, 2)
|
||||||
|
|
||||||
|
// Nothing should be deleted
|
||||||
|
assert.Equal(t, initialCount, store.keyCount())
|
||||||
|
|
||||||
|
// Verify dry run message
|
||||||
|
assert.Contains(t, tv.Stdout.String(), "[Dry run - no changes made]")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveAllSnapshots_NoSnapshots(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
|
||||||
|
store := newTestStorer()
|
||||||
|
// No snapshots added
|
||||||
|
|
||||||
|
tv := vaultik.NewForTesting(store)
|
||||||
|
|
||||||
|
opts := &vaultik.RemoveOptions{All: true, Force: true}
|
||||||
|
result, err := tv.RemoveAllSnapshots(opts)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, result.SnapshotsRemoved, 0)
|
||||||
|
|
||||||
|
// Verify output
|
||||||
|
assert.Contains(t, tv.Stdout.String(), "No snapshots found")
|
||||||
|
}
|
||||||
@@ -12,14 +12,14 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
"git.eeqj.de/sneak/vaultik/internal/blobgen"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/database"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/log"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/types"
|
|
||||||
"github.com/dustin/go-humanize"
|
"github.com/dustin/go-humanize"
|
||||||
"github.com/schollz/progressbar/v3"
|
"github.com/schollz/progressbar/v3"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
|
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ import (
|
|||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/database"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/log"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/snapshot"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/types"
|
|
||||||
"github.com/dustin/go-humanize"
|
"github.com/dustin/go-humanize"
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||||
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SnapshotCreateOptions contains options for the snapshot create command
|
// SnapshotCreateOptions contains options for the snapshot create command
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/config"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/crypto"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/database"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/globals"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/snapshot"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/storage"
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
|
"sneak.berlin/go/vaultik/internal/crypto"
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
"sneak.berlin/go/vaultik/internal/globals"
|
||||||
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||||
|
"sneak.berlin/go/vaultik/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Vaultik contains all dependencies needed for vaultik operations
|
// Vaultik contains all dependencies needed for vaultik operations
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/log"
|
|
||||||
"git.eeqj.de/sneak/vaultik/internal/snapshot"
|
|
||||||
"github.com/dustin/go-humanize"
|
"github.com/dustin/go-humanize"
|
||||||
"github.com/klauspost/compress/zstd"
|
"github.com/klauspost/compress/zstd"
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||||
)
|
)
|
||||||
|
|
||||||
// VerifyOptions contains options for the verify command
|
// VerifyOptions contains options for the verify command
|
||||||
|
|||||||
92
internal/vaultik/verify_test.go
Normal file
92
internal/vaultik/verify_test.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
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) {
|
||||||
|
// 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 := "age1cplgrwj77ta54dnmydvvmzn64ltk83ankxl5sww04mrtmu62kv3s89gmvv"
|
||||||
|
testSecretKey := "AGE-SECRET-KEY-1C77PYNTHXSHNNC6EYR2W52UWYXACXA5JT00J9CCW9986M3XY87PSGP89AQ"
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user