Compare commits

..

2 Commits

Author SHA1 Message Date
user
87acc05a77 fix: add RunDaemon test, remove dead daemonWatcherBatchDelay constant
All checks were successful
check / check (pull_request) Successful in 3m7s
- Add TestRunDaemon_CancelledContext: exercises RunDaemon with a
  daemon-friendly config, cancels via context, verifies clean return
  and startup output
- Remove unused daemonWatcherBatchDelay constant (batch-settle logic
  was never implemented; the watcher loop records changes immediately)
- Update TestDaemonConstants to remove reference to deleted constant
2026-03-24 13:40:08 -07:00
user
07a31a54d4 feat: implement daemon mode with filesystem watching
All checks were successful
check / check (pull_request) Successful in 4m57s
Replace the daemon mode stub with a full implementation that:

- Watches configured snapshot paths for filesystem changes using
  fsnotify (inotify on Linux, FSEvents on macOS, etc.)
- Runs an initial full backup on startup
- Triggers incremental backups at backup_interval when changes are
  detected, only for snapshots whose paths were affected
- Performs full periodic scans at full_scan_interval regardless of
  detected changes
- Respects min_time_between_run to prevent excessive backup runs
- Handles SIGTERM/SIGINT for graceful shutdown, completing any
  in-progress backup before exiting
- Automatically watches newly created subdirectories
- Uses a backup semaphore to prevent concurrent backup runs

New files:
- internal/vaultik/daemon.go: RunDaemon(), changeTracker, watcher setup
- internal/vaultik/daemon_test.go: Tests for changeTracker, isSubpath,
  concurrency safety, and daemon constants

closes #3
2026-03-24 09:48:06 -07:00
164 changed files with 7622 additions and 16383 deletions

View File

@@ -1,12 +0,0 @@
root = true
[*]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[Makefile]
indent_style = tab

View File

@@ -11,4 +11,4 @@ jobs:
# actions/checkout v4, 2024-09-16 # actions/checkout v4, 2024-09-16
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
- name: Build and check - name: Build and check
run: script/cibuild run: docker build .

2
.gitignore vendored
View File

@@ -1,5 +1,5 @@
# Binary # Binary
/vaultik vaultik
# Test artifacts # Test artifacts
*.out *.out

View File

@@ -1,34 +0,0 @@
version: "2"
# Config schema uses the golangci-lint v2 layout (settings live under
# linters.settings, not top-level linters-settings) so that the
# thresholds below are actually applied by golangci-lint >= v2.
run:
timeout: 5m
modules-download-mode: readonly
linters:
default: all
disable:
# Genuinely incompatible with project patterns
- exhaustruct # Requires all struct fields
- depguard # Dependency allow/block lists
- godot # Requires comments to end with periods
- wsl # Deprecated, replaced by wsl_v5
- wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go
settings:
lll:
line-length: 88
funlen:
lines: 80
statements: 50
cyclop:
max-complexity: 15
dupl:
threshold: 100
issues:
max-issues-per-linter: 0
max-same-issues: 0

View File

@@ -1,56 +0,0 @@
version: 2
project_name: vaultik
before:
hooks:
- go mod tidy
builds:
- id: vaultik
main: ./cmd/vaultik
binary: vaultik
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
goarch:
- amd64
- arm64
ldflags:
- -s -w
- -X 'sneak.berlin/go/vaultik/internal/globals.Version={{ .Version }}'
- -X 'sneak.berlin/go/vaultik/internal/globals.Commit={{ .Commit }}'
- -X 'sneak.berlin/go/vaultik/internal/globals.CommitDate={{ slice .CommitDate 0 10 }}'
archives:
- id: default
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
formats:
- tar.gz
files:
- LICENSE
- README.md
checksum:
name_template: "checksums.txt"
algorithm: sha256
snapshot:
version_template: "{{ incpatch .Version }}-next"
changelog:
sort: asc
use: git
filters:
exclude:
- "^docs:"
- "^test:"
- "^chore:"
- "Merge pull request"
- "Merge branch"
release:
draft: true
prerelease: auto

View File

@@ -38,9 +38,10 @@ Version: 2025-06-08
1. Before committing, tests must pass (`make test`), linting must pass 1. Before committing, tests must pass (`make test`), linting must pass
(`make lint`), and code must be formatted (`make fmt`). For go, those (`make lint`), and code must be formatted (`make fmt`). For go, those
makefile targets should use `go fmt` and `go test -v ./...` and makefile targets should use `go fmt` and `go test -v ./...` and
`golangci-lint run`. Each Makefile target does exactly one thing — to `golangci-lint run`. When you think your changes are complete, rather
run lint + fmt-check + test together (the standard pre-commit gate), than making three different tool calls to check, you can just run `make
use `make check`. test && make fmt && make lint` as a single tool call which will save
time.
2. Always write a `Makefile` with the default target being `test`, and with 2. Always write a `Makefile` with the default target being `test`, and with
a `fmt` target that formats the code. The `test` target should run all a `fmt` target that formats the code. The `test` target should run all
@@ -102,9 +103,3 @@ Version: 2025-06-08
build files are acceptable in the root, but source code and other files build files are acceptable in the root, but source code and other files
should be organized in appropriate subdirectories. should be organized in appropriate subdirectories.
13. Pre-1.0: NEVER write database migrations. There are no live databases
anywhere — every user's local index can be rebuilt from a fresh full
backup. When the schema changes, just change `schema.sql` (and any code
that touches the affected tables). The local index is disposable until
1.0 ships and is tagged.

View File

@@ -53,8 +53,8 @@ The database tracks five primary entities and their relationships:
### Entity Descriptions ### Entity Descriptions
#### File (`database.File`) #### File (`database.File`)
Represents a file, directory, or symlink in the backup system. Stores metadata needed for restoration: Represents a file or directory in the backup system. Stores metadata needed for restoration:
- Path, source_path (for restore path stripping), mtime - Path, mtime
- Size, mode, ownership (uid, gid) - Size, mode, ownership (uid, gid)
- Symlink target (if applicable) - Symlink target (if applicable)
@@ -95,7 +95,7 @@ Maps chunks to their position within blobs:
#### Snapshot (`database.Snapshot`) #### Snapshot (`database.Snapshot`)
Represents a point-in-time backup: Represents a point-in-time backup:
- `ID`: Format is `{hostname}_{snapshot-name}_{RFC3339}` (e.g. `server1_home_2025-06-01T12:00:00Z`) - `ID`: Format is `{hostname}-{YYYYMMDD}-{HHMMSS}Z`
- Tracks file count, chunk count, blob count, sizes, compression ratio - Tracks file count, chunk count, blob count, sizes, compression ratio
- `CompletedAt`: Null until snapshot finishes successfully - `CompletedAt`: Null until snapshot finishes successfully
@@ -127,7 +127,7 @@ fx.New(
config.Module, // 5. Config config.Module, // 5. Config
database.Module, // 6. Database + Repositories database.Module, // 6. Database + Repositories
log.Module, // 7. Logger initialization log.Module, // 7. Logger initialization
storage.Module, // 8. Storage backend (S3/file/rclone) s3.Module, // 8. S3 client
snapshot.Module, // 9. SnapshotManager + ScannerFactory snapshot.Module, // 9. SnapshotManager + ScannerFactory
fx.Provide(vaultik.New), // 10. Vaultik orchestrator fx.Provide(vaultik.New), // 10. Vaultik orchestrator
) )
@@ -161,7 +161,7 @@ type Vaultik struct {
Config *config.Config Config *config.Config
DB *database.DB DB *database.DB
Repositories *database.Repositories Repositories *database.Repositories
Storage storage.Storer S3Client *s3.Client
ScannerFactory snapshot.ScannerFactory ScannerFactory snapshot.ScannerFactory
SnapshotManager *snapshot.SnapshotManager SnapshotManager *snapshot.SnapshotManager
Shutdowner fx.Shutdowner Shutdowner fx.Shutdowner
@@ -341,11 +341,12 @@ CreateSnapshot(opts)
└─► SnapshotManager.ExportSnapshotMetadata() └─► SnapshotManager.ExportSnapshotMetadata()
├─► Copy database to temp file ├─► Copy database to temp file
├─► Clean to only current snapshot data (VACUUM) ├─► Clean to only current snapshot data
├─► Compress binary SQLite with zstd ├─► Dump to SQL
├─► Compress with zstd
├─► Encrypt with age ├─► Encrypt with age
├─► Upload db.zst.age to storage ├─► Upload db.zst.age to S3
└─► Upload manifest.json.zst to storage └─► Upload manifest.json.zst to S3
``` ```
## Deduplication Strategy ## Deduplication Strategy
@@ -367,8 +368,8 @@ bucket/
└── metadata/ └── metadata/
└── {snapshot-id}/ └── {snapshot-id}/
├── db.zst.age # Encrypted binary SQLite database ├── db.zst.age # Encrypted database dump
└── manifest.json.zst # Blob list (for pruning/verification) └── manifest.json.zst # Blob list (for verification)
``` ```
## Thread Safety ## Thread Safety

View File

@@ -1,6 +1,6 @@
# Lint stage # Lint stage
# golangci/golangci-lint:v2.12.2-alpine, 2026-08-07 # golangci/golangci-lint:v2.11.3-alpine, 2026-03-17
FROM golangci/golangci-lint:v2.12.2-alpine@sha256:91b27804074a0bacea298707f016911e60cf0cdbc6c7bf5ccacb5f0606d18d60 AS lint FROM golangci/golangci-lint:v2.11.3-alpine@sha256:b1c3de5862ad0a95b4e45a993b0f00415835d687e4f12c845c7493b86c13414e AS lint
RUN apk add --no-cache make build-base RUN apk add --no-cache make build-base
@@ -41,8 +41,8 @@ COPY . .
# Run tests # Run tests
RUN make test RUN make test
# Build (pure Go, no CGO required since we use modernc.org/sqlite) # Build with CGO enabled (required for mattn/go-sqlite3)
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)' -X 'sneak.berlin/go/vaultik/internal/globals.CommitDate=$(git show -s --format=%cs HEAD 2>/dev/null || echo unknown)'" -o /vaultik ./cmd/vaultik 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
# Runtime stage # Runtime stage
# alpine:3.21, 2026-02-25 # alpine:3.21, 2026-02-25

View File

@@ -1,72 +1,49 @@
.PHONY: all bootstrap setup check test lint lint-fix fmt fmt-check build clean deps test-coverage test-integration local install release release-snapshot docker hooks .PHONY: test fmt lint fmt-check check build clean all docker hooks
# Version number # Version number
VERSION := 1.0.0-rc.1 VERSION := 0.0.1
# Build variables # Build variables
GIT_REVISION := $(shell git rev-parse HEAD 2>/dev/null || echo "unknown") GIT_REVISION := $(shell git rev-parse HEAD 2>/dev/null || echo "unknown")
GIT_COMMIT_DATE := $(shell git show -s --format=%cs HEAD 2>/dev/null || echo "unknown")
# Linker flags # Linker flags
LDFLAGS := -X 'sneak.berlin/go/vaultik/internal/globals.Version=$(VERSION)' \ LDFLAGS := -X 'git.eeqj.de/sneak/vaultik/internal/globals.Version=$(VERSION)' \
-X 'sneak.berlin/go/vaultik/internal/globals.Commit=$(GIT_REVISION)' \ -X 'git.eeqj.de/sneak/vaultik/internal/globals.Commit=$(GIT_REVISION)'
-X 'sneak.berlin/go/vaultik/internal/globals.CommitDate=$(GIT_COMMIT_DATE)'
# Default target # Default target
all: vaultik all: vaultik
# Install all development dependencies. # Run tests
bootstrap:
@script/bootstrap
# Prepare a fresh clone: bootstrap plus pre-commit hook.
setup:
@script/setup
# Combined pre-commit/CI gate: tests, lint, format check.
check:
@script/check
# Run tests only.
test: test:
@script/test go test -race -timeout 30s ./...
# Check if code is formatted (read-only). # Check if code is formatted (read-only)
fmt-check: fmt-check:
@script/fmt-check @test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1)
# Format code. # Format code
fmt: fmt:
@script/fmt go fmt ./...
# Run linter only. # Run linter
lint: lint:
@script/lint golangci-lint run ./...
# Apply the linter's autofixes (rewrites files). # Build binary
lint-fix:
@script/lint-fix
# Build binary.
vaultik: internal/*/*.go cmd/vaultik/*.go vaultik: internal/*/*.go cmd/vaultik/*.go
go build -ldflags "$(LDFLAGS)" -o $@ ./cmd/vaultik go build -ldflags "$(LDFLAGS)" -o $@ ./cmd/vaultik
# Clean build artifacts. # Clean build artifacts
clean: clean:
rm -f vaultik rm -f vaultik
go clean go clean
# Install dependencies. # Run tests with coverage
deps:
go mod download
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
# Run tests with coverage.
test-coverage: test-coverage:
go test -v -coverprofile=coverage.out ./... go test -v -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html go tool cover -html=coverage.out -o coverage.html
# Run integration tests. # Run integration tests
test-integration: test-integration:
go test -v -tags=integration ./... go test -v -tags=integration ./...
@@ -77,18 +54,16 @@ local:
install: vaultik install: vaultik
cp ./vaultik $(HOME)/bin/ cp ./vaultik $(HOME)/bin/
# Build and publish release artifacts (linux/darwin × amd64/arm64) via goreleaser. # Run all checks (formatting, linting, tests) without modifying files
release: check: fmt-check lint test
goreleaser release --clean
# Dry-run a release build without publishing or tagging. # Build Docker image
release-snapshot:
goreleaser release --clean --snapshot
# Build Docker image.
docker: docker:
@script/docker docker build -t vaultik .
# Install pre-commit hook. # Install pre-commit hook
hooks: hooks:
@script/install-precommit @printf '#!/bin/sh\nset -e\n' > .git/hooks/pre-commit
@printf 'go mod tidy\ngo fmt ./...\ngit diff --exit-code -- go.mod go.sum || { echo "go mod tidy changed files; please stage and retry"; exit 1; }\n' >> .git/hooks/pre-commit
@printf 'make check\n' >> .git/hooks/pre-commit
@chmod +x .git/hooks/pre-commit

556
PROCESS.md Normal file
View File

@@ -0,0 +1,556 @@
# Vaultik Snapshot Creation Process
This document describes the lifecycle of objects during snapshot creation, with a focus on database transactions and foreign key constraints.
## Database Schema Overview
### Tables and Foreign Key Dependencies
```
┌─────────────────────────────────────────────────────────────────────────┐
│ FOREIGN KEY GRAPH │
│ │
│ snapshots ◄────── snapshot_files ────────► files │
│ │ │ │
│ └───────── snapshot_blobs ────────► blobs │ │
│ │ │ │
│ │ ├──► file_chunks ◄── chunks│
│ │ │ ▲ │
│ │ └──► chunk_files ────┘ │
│ │ │
│ └──► blob_chunks ─────────────┘│
│ │
│ uploads ───────► blobs.blob_hash │
│ └──────────► snapshots.id │
└─────────────────────────────────────────────────────────────────────────┘
```
### Critical Constraint: `chunks` Must Exist First
These tables reference `chunks.chunk_hash` **without CASCADE**:
- `file_chunks.chunk_hash``chunks.chunk_hash`
- `chunk_files.chunk_hash``chunks.chunk_hash`
- `blob_chunks.chunk_hash``chunks.chunk_hash`
**Implication**: A chunk record MUST be committed to the database BEFORE any of these referencing records can be created.
### Order of Operations Required by Schema
```
1. snapshots (created first, before scan)
2. blobs (created when packer starts new blob)
3. chunks (created during file processing)
4. blob_chunks (created immediately after chunk added to packer)
5. files (created after file fully chunked)
6. file_chunks (created with file record)
7. chunk_files (created with file record)
8. snapshot_files (created with file record)
9. snapshot_blobs (created after blob uploaded)
10. uploads (created after blob uploaded)
```
---
## Snapshot Creation Phases
### Phase 0: Initialization
**Actions:**
1. Snapshot record created in database (Transaction T0)
2. Known files loaded into memory from `files` table
3. Known chunks loaded into memory from `chunks` table
**Transactions:**
```
T0: INSERT INTO snapshots (id, hostname, ...) VALUES (...)
COMMIT
```
---
### Phase 1: Scan Directory
**Actions:**
1. Walk filesystem directory tree
2. For each file, compare against in-memory `knownFiles` map
3. Classify files as: unchanged, new, or modified
4. Collect unchanged file IDs for later association
5. Collect new/modified files for processing
**Transactions:**
```
(None during scan - all in-memory)
```
---
### Phase 1b: Associate Unchanged Files
**Actions:**
1. For unchanged files, add entries to `snapshot_files` table
2. Done in batches of 1000
**Transactions:**
```
For each batch of 1000 file IDs:
T: BEGIN
INSERT INTO snapshot_files (snapshot_id, file_id) VALUES (?, ?)
... (up to 1000 inserts)
COMMIT
```
---
### Phase 2: Process Files
For each file that needs processing:
#### Step 2a: Open and Chunk File
**Location:** `processFileStreaming()`
For each chunk produced by content-defined chunking:
##### Step 2a-1: Check Chunk Existence
```go
chunkExists := s.chunkExists(chunk.Hash) // In-memory lookup
```
##### Step 2a-2: Create Chunk Record (if new)
```go
// TRANSACTION: Create chunk in database
err := s.repos.WithTx(ctx, func(txCtx context.Context, tx *sql.Tx) error {
dbChunk := &database.Chunk{ChunkHash: chunk.Hash, Size: chunk.Size}
return s.repos.Chunks.Create(txCtx, tx, dbChunk)
})
// COMMIT immediately after WithTx returns
// Update in-memory cache
s.addKnownChunk(chunk.Hash)
```
**Transaction:**
```
T_chunk: BEGIN
INSERT INTO chunks (chunk_hash, size) VALUES (?, ?)
COMMIT
```
##### Step 2a-3: Add Chunk to Packer
```go
s.packer.AddChunk(&blob.ChunkRef{Hash: chunk.Hash, Data: chunk.Data})
```
**Inside packer.AddChunk → addChunkToCurrentBlob():**
```go
// TRANSACTION: Create blob_chunks record IMMEDIATELY
if p.repos != nil {
blobChunk := &database.BlobChunk{
BlobID: p.currentBlob.id,
ChunkHash: chunk.Hash,
Offset: offset,
Length: chunkSize,
}
err := p.repos.WithTx(context.Background(), func(ctx context.Context, tx *sql.Tx) error {
return p.repos.BlobChunks.Create(ctx, tx, blobChunk)
})
// COMMIT immediately
}
```
**Transaction:**
```
T_blob_chunk: BEGIN
INSERT INTO blob_chunks (blob_id, chunk_hash, offset, length) VALUES (?, ?, ?, ?)
COMMIT
```
**⚠️ CRITICAL DEPENDENCY**: This transaction requires `chunks.chunk_hash` to exist (FK constraint).
The chunk MUST be committed in Step 2a-2 BEFORE this can succeed.
---
#### Step 2b: Blob Size Limit Handling
If adding a chunk would exceed blob size limit:
```go
if err == blob.ErrBlobSizeLimitExceeded {
if err := s.packer.FinalizeBlob(); err != nil { ... }
// Retry adding the chunk
if err := s.packer.AddChunk(...); err != nil { ... }
}
```
**FinalizeBlob() transactions:**
```
T_blob_finish: BEGIN
UPDATE blobs SET blob_hash=?, uncompressed_size=?, compressed_size=?, finished_ts=? WHERE id=?
COMMIT
```
Then blob handler is called (handleBlobReady):
```
(Upload to S3 - no transaction)
T_blob_uploaded: BEGIN
UPDATE blobs SET uploaded_ts=? WHERE id=?
INSERT INTO snapshot_blobs (snapshot_id, blob_id, blob_hash) VALUES (?, ?, ?)
INSERT INTO uploads (blob_hash, snapshot_id, uploaded_at, size, duration_ms) VALUES (?, ?, ?, ?, ?)
COMMIT
```
---
#### Step 2c: Queue File for Batch Insertion
After all chunks for a file are processed:
```go
// Build file data (in-memory, no DB)
fileChunks := make([]database.FileChunk, len(chunks))
chunkFiles := make([]database.ChunkFile, len(chunks))
// Queue for batch insertion
return s.addPendingFile(ctx, pendingFileData{
file: fileToProcess.File,
fileChunks: fileChunks,
chunkFiles: chunkFiles,
})
```
**No transaction yet** - just adds to `pendingFiles` slice.
If `len(pendingFiles) >= fileBatchSize (100)`, triggers `flushPendingFiles()`.
---
### Step 2d: Flush Pending Files
**Location:** `flushPendingFiles()` - called when batch is full or at end of processing
```go
return s.repos.WithTx(ctx, func(txCtx context.Context, tx *sql.Tx) error {
for _, data := range files {
// 1. Create file record
s.repos.Files.Create(txCtx, tx, data.file) // INSERT OR REPLACE
// 2. Delete old associations
s.repos.FileChunks.DeleteByFileID(txCtx, tx, data.file.ID)
s.repos.ChunkFiles.DeleteByFileID(txCtx, tx, data.file.ID)
// 3. Create file_chunks records
for _, fc := range data.fileChunks {
s.repos.FileChunks.Create(txCtx, tx, &fc) // FK: chunks.chunk_hash
}
// 4. Create chunk_files records
for _, cf := range data.chunkFiles {
s.repos.ChunkFiles.Create(txCtx, tx, &cf) // FK: chunks.chunk_hash
}
// 5. Add file to snapshot
s.repos.Snapshots.AddFileByID(txCtx, tx, s.snapshotID, data.file.ID)
}
return nil
})
// COMMIT (all or nothing for the batch)
```
**Transaction:**
```
T_files_batch: BEGIN
-- For each file in batch:
INSERT OR REPLACE INTO files (...) VALUES (...)
DELETE FROM file_chunks WHERE file_id = ?
DELETE FROM chunk_files WHERE file_id = ?
INSERT INTO file_chunks (file_id, idx, chunk_hash) VALUES (?, ?, ?) -- FK: chunks
INSERT INTO chunk_files (chunk_hash, file_id, ...) VALUES (?, ?, ...) -- FK: chunks
INSERT INTO snapshot_files (snapshot_id, file_id) VALUES (?, ?)
-- Repeat for each file
COMMIT
```
**⚠️ CRITICAL DEPENDENCY**: `file_chunks` and `chunk_files` require `chunks.chunk_hash` to exist.
---
### Phase 2 End: Final Flush
```go
// Flush any remaining pending files
if err := s.flushAllPending(ctx); err != nil { ... }
// Final packer flush
s.packer.Flush()
```
---
## The Current Bug
### Problem
The current code attempts to batch file insertions, but `file_chunks` and `chunk_files` have foreign keys to `chunks.chunk_hash`. The batched file flush tries to insert these records, but if the chunks haven't been committed yet, the FK constraint fails.
### Why It's Happening
Looking at the sequence:
1. Process file A, chunk X
2. Create chunk X in DB (Transaction commits)
3. Add chunk X to packer
4. Packer creates blob_chunks for chunk X (needs chunk X - OK, committed in step 2)
5. Queue file A with chunk references
6. Process file B, chunk Y
7. Create chunk Y in DB (Transaction commits)
8. ... etc ...
9. At end: flushPendingFiles()
10. Insert file_chunks for file A referencing chunk X (chunk X committed - should work)
The chunks ARE being created individually. But something is going wrong.
### Actual Issue
Wait - let me re-read the code. The issue is:
In `processFileStreaming`, when we queue file data:
```go
fileChunks[i] = database.FileChunk{
FileID: fileToProcess.File.ID,
Idx: ci.fileChunk.Idx,
ChunkHash: ci.fileChunk.ChunkHash,
}
```
The `FileID` is set, but `fileToProcess.File.ID` might be empty at this point because the file record hasn't been created yet!
Looking at `checkFileInMemory`:
```go
// For new files:
if !exists {
return file, true // file.ID is empty string!
}
// For existing files:
file.ID = existingFile.ID // Reuse existing ID
```
**For NEW files, `file.ID` is empty!**
Then in `flushPendingFiles`:
```go
s.repos.Files.Create(txCtx, tx, data.file) // This generates/uses the ID
```
But `data.fileChunks` was built with the EMPTY ID!
### The Real Problem
For new files:
1. `checkFileInMemory` creates file record with empty ID
2. `processFileStreaming` queues file_chunks with empty `FileID`
3. `flushPendingFiles` creates file (generates ID), but file_chunks still have empty `FileID`
Wait, but `Files.Create` should be INSERT OR REPLACE by path, and the file struct should get updated... Let me check.
Actually, looking more carefully at the code path - the file IS created first in the flush, but the `fileChunks` slice was already built with the old (possibly empty) ID. The ID isn't updated after the file is created.
Hmm, but looking at the current code:
```go
fileChunks[i] = database.FileChunk{
FileID: fileToProcess.File.ID, // This uses the ID from the File struct
```
And in `checkFileInMemory` for new files, we create a file struct but don't set the ID. However, looking at the database repository, `Files.Create` should be doing `INSERT OR REPLACE` and the ID should be pre-generated...
Let me check if IDs are being generated. Looking at the File struct usage, it seems like UUIDs should be generated somewhere...
Actually, looking at the test failures again:
```
creating file chunk: inserting file_chunk: constraint failed: FOREIGN KEY constraint failed (787)
```
Error 787 is SQLite's foreign key constraint error. The failing FK is on `file_chunks.chunk_hash → chunks.chunk_hash`.
So the chunks ARE NOT in the database when we try to insert file_chunks. Let me trace through more carefully...
---
## Transaction Timing Issue
The problem is transaction visibility in SQLite.
Each `WithTx` creates a new transaction that commits at the end. But with batched file insertion:
1. Chunk transactions commit one at a time
2. File batch transaction runs later
If chunks are being inserted but something goes wrong with transaction isolation, the file batch might not see them.
But actually SQLite in WAL mode should have SERIALIZABLE isolation by default, so committed transactions should be visible.
Let me check if the in-memory cache is masking a database problem...
Actually, wait. Let me re-check the current broken code more carefully. The issue might be simpler.
---
## Current Code Flow Analysis
Looking at `processFileStreaming` in the current broken state:
```go
// For each chunk:
if !chunkExists {
err := s.repos.WithTx(ctx, func(txCtx context.Context, tx *sql.Tx) error {
dbChunk := &database.Chunk{ChunkHash: chunk.Hash, Size: chunk.Size}
return s.repos.Chunks.Create(txCtx, tx, dbChunk)
})
// ... check error ...
s.addKnownChunk(chunk.Hash)
}
// ... add to packer (creates blob_chunks) ...
// Collect chunk info for file
chunks = append(chunks, chunkInfo{...})
```
Then at end of function:
```go
// Queue file for batch insertion
return s.addPendingFile(ctx, pendingFileData{
file: fileToProcess.File,
fileChunks: fileChunks,
chunkFiles: chunkFiles,
})
```
At end of `processPhase`:
```go
if err := s.flushAllPending(ctx); err != nil { ... }
```
The chunks are being created one-by-one with individual transactions. By the time `flushPendingFiles` runs, all chunk transactions should have committed.
Unless... there's a bug in how the chunks are being referenced. Let me check if the chunk_hash values are correct.
Or... maybe the test database is being recreated between operations somehow?
Actually, let me check the test setup. Maybe the issue is specific to the test environment.
---
## Summary of Object Lifecycle
| Object | When Created | Transaction | Dependencies |
|--------|--------------|-------------|--------------|
| snapshot | Before scan | Individual tx | None |
| blob | When packer needs new blob | Individual tx | None |
| chunk | During file chunking (each chunk) | Individual tx | None |
| blob_chunks | Immediately after adding chunk to packer | Individual tx | chunks, blobs |
| files | Batched at end of processing | Batch tx | None |
| file_chunks | With file (batched) | Batch tx | files, chunks |
| chunk_files | With file (batched) | Batch tx | files, chunks |
| snapshot_files | With file (batched) | Batch tx | snapshots, files |
| snapshot_blobs | After blob upload | Individual tx | snapshots, blobs |
| uploads | After blob upload | Same tx as snapshot_blobs | blobs, snapshots |
---
## Root Cause Analysis
After detailed analysis, I believe the issue is one of the following:
### Hypothesis 1: File ID Not Set
Looking at `checkFileInMemory()` for NEW files:
```go
if !exists {
return file, true // file.ID is empty string!
}
```
For new files, `file.ID` is empty. Then in `processFileStreaming`:
```go
fileChunks[i] = database.FileChunk{
FileID: fileToProcess.File.ID, // Empty for new files!
...
}
```
The `FileID` in the built `fileChunks` slice is empty.
Then in `flushPendingFiles`:
```go
s.repos.Files.Create(txCtx, tx, data.file) // This generates the ID
// But data.fileChunks still has empty FileID!
for i := range data.fileChunks {
s.repos.FileChunks.Create(...) // Uses empty FileID
}
```
**Solution**: Generate file IDs upfront in `checkFileInMemory()`:
```go
file := &database.File{
ID: uuid.New().String(), // Generate ID immediately
Path: path,
...
}
```
### Hypothesis 2: Transaction Isolation
SQLite with a single connection pool (`MaxOpenConns(1)`) should serialize all transactions. Committed data should be visible to subsequent transactions.
However, there might be a subtle issue with how `context.Background()` is used in the packer vs the scanner's context.
## Recommended Fix
**Step 1: Generate file IDs upfront**
In `checkFileInMemory()`, generate the UUID for new files immediately:
```go
file := &database.File{
ID: uuid.New().String(), // Always generate ID
Path: path,
...
}
```
This ensures `file.ID` is set when building `fileChunks` and `chunkFiles` slices.
**Step 2: Verify by reverting to per-file transactions**
If Step 1 doesn't fix it, revert to non-batched file insertion to isolate the issue:
```go
// Instead of queuing:
// return s.addPendingFile(ctx, pendingFileData{...})
// Do immediate insertion:
return s.repos.WithTx(ctx, func(txCtx context.Context, tx *sql.Tx) error {
// Create file
s.repos.Files.Create(txCtx, tx, fileToProcess.File)
// Delete old associations
s.repos.FileChunks.DeleteByFileID(...)
s.repos.ChunkFiles.DeleteByFileID(...)
// Create new associations
for _, fc := range fileChunks {
s.repos.FileChunks.Create(...)
}
for _, cf := range chunkFiles {
s.repos.ChunkFiles.Create(...)
}
// Add to snapshot
s.repos.Snapshots.AddFileByID(...)
return nil
})
```
**Step 3: If batching is still desired**
After confirming per-file transactions work, re-implement batching with the ID fix in place, and add debug logging to trace exactly which chunk_hash is failing and why.

814
README.md
View File

@@ -1,65 +1,43 @@
# vaultik (ваултик) # vaultik (ваултик)
`vaultik` is an incremental backup tool written in Go. It encrypts data WIP: pre-1.0, some functions may not be fully implemented yet
`vaultik` is an incremental backup daemon written in Go. It encrypts data
using an `age` public key and uploads each encrypted blob directly to a using an `age` public key and uploads each encrypted blob directly to a
remote S3-compatible object store. It requires no private keys, secrets, or 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 It includes table-stakes features such as:
```sh * modern encryption (the excellent `age`)
# install * deduplication
go install sneak.berlin/go/vaultik/cmd/vaultik@latest * incremental backups
* modern multithreaded zstd compression with configurable levels
# 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"
# macOS only: grant your terminal app Full Disk Access first
# (System Settings → Privacy & Security → Full Disk Access), otherwise
# the backup will abort with a permission error on protected directories
# run your first backup (the default config backs up ~ and /Applications
# with sensible excludes)
vaultik snapshot create
# see what you have
vaultik snapshot list
```
Features:
* 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
* content-addressed immutable storage * content-addressed immutable storage
* local state tracking in SQLite (enables write-only incremental backups) * local state tracking in standard SQLite database, enables write-only
incremental backups to destination
* no mutable remote metadata * no mutable remote metadata
* no plaintext file paths or metadata in remote storage * no plaintext file paths or metadata stored in remote
* packs small files into large blobs (keeps S3 operation counts down) * does not create huge numbers of small files (to keep S3 operation counts
* backs up regular files, symlinks, empty directories, and file permissions down) even if the source system has many small files
* pluggable storage backends: S3, local filesystem, rclone (70+ providers)
* pure Go (no CGO), cross-compiles to linux/darwin × amd64/arm64
## why ## why
Existing backup software fails under one or more of these conditions:
* Requires secrets (passwords, private keys) on the source system, which
compromises encrypted backups in the case of host system compromise
* Depends on symmetric encryption unsuitable for zero-trust environments
* Creates one-blob-per-file, which results in excessive S3 operation counts
* is slow
Other backup tools like `restic`, `borg`, and `duplicity` are designed for Other backup tools like `restic`, `borg`, and `duplicity` are designed for
environments where the source host can store secrets and has access to environments where the source host can store secrets and has access to
decryption keys. `vaultik` is for environments where you don't want to decryption keys. I don't want to store backup decryption keys on my hosts,
store backup decryption keys on your hosts — only public keys for only public keys for encryption.
encryption.
Requirements that no existing tool meets: My requirements are:
* open source * open source
* no passphrases or private keys on the source host * no passphrases or private keys on the source host
@@ -68,21 +46,99 @@ Requirements that no existing tool meets:
* encrypted * encrypted
* s3 compatible without an intermediate step or tool * s3 compatible without an intermediate step or tool
## daily use Surprisingly, no existing tool meets these requirements, so I wrote `vaultik`.
```sh ## design goals
# verify a snapshot (shallow: checks all blobs exist)
vaultik snapshot verify <snapshot-id>
# deep verify (downloads and cryptographically verifies every blob) 1. Backups must require only a public key on the source host.
VAULTIK_AGE_SECRET_KEY='AGE-SECRET-KEY-...' vaultik snapshot verify --deep <snapshot-id> 1. No secrets or private keys may exist on the source system.
1. Restore must be possible using **only** the backup bucket and a private key.
1. Prune must be possible (requires private key, done on different hosts).
1. All encryption uses [`age`](https://age-encryption.org/) (X25519, XChaCha20-Poly1305).
1. Compression uses `zstd` at a configurable level.
1. Files are chunked, and multiple chunks are packed into encrypted blobs
to reduce object count for filesystems with many small files.
1. All metadata (snapshots) is stored remotely as encrypted SQLite DBs.
# restore (requires the private key) ## what
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 `vaultik` walks a set of configured directories and builds a
# 0 3 * * * vaultik snapshot create --cron --prune --keep-newer-than 4w content-addressable chunk map of changed files using deterministic chunking.
``` Each chunk is streamed into a blob packer. Blobs are compressed with `zstd`,
encrypted with `age`, and uploaded directly to remote storage under a
content-addressed S3 path. At the end, a pruned snapshot-specific sqlite
database of metadata is created, encrypted, and uploaded alongside the
blobs.
No plaintext file contents ever hit disk. No private key or secret
passphrase is needed or stored locally.
## how
1. **install**
```sh
go install git.eeqj.de/sneak/vaultik@latest
```
1. **generate keypair**
```sh
age-keygen -o agekey.txt
grep 'public key:' agekey.txt
```
1. **write config**
```yaml
# Named snapshots - each snapshot can contain multiple paths
snapshots:
system:
paths:
- /etc
- /var/lib
exclude:
- '*.cache' # Snapshot-specific exclusions
home:
paths:
- /home/user/documents
- /home/user/photos
# Global exclusions (apply to all snapshots)
exclude:
- '*.log'
- '*.tmp'
- '.git'
- 'node_modules'
age_recipients:
- age1278m9q7dp3chsh2dcy82qk27v047zywyvtxwnj4cvt0z65jw6a7q5dqhfj
s3:
endpoint: https://s3.example.com
bucket: vaultik-data
prefix: host1/
access_key_id: ...
secret_access_key: ...
region: us-east-1
backup_interval: 1h
full_scan_interval: 24h
min_time_between_run: 15m
chunk_size: 10MB
blob_size_limit: 1GB
```
1. **run**
```sh
# Create all configured snapshots
vaultik --config /etc/vaultik.yaml snapshot create
# Create specific snapshots by name
vaultik --config /etc/vaultik.yaml snapshot create home system
# Silent mode for cron
vaultik --config /etc/vaultik.yaml snapshot create --cron
```
--- ---
@@ -91,500 +147,302 @@ VAULTIK_AGE_SECRET_KEY='AGE-SECRET-KEY-...' vaultik snapshot restore <snapshot-i
### commands ### commands
```sh ```sh
vaultik [--config <path>] config init vaultik [--config <path>] snapshot create [snapshot-names...] [--cron] [--daemon] [--prune]
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>]
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]
vaultik [--config <path>] snapshot purge [--keep-latest | --older-than <duration>] [--snapshot <name>...] [--force] vaultik [--config <path>] snapshot purge [--keep-latest | --older-than <duration>] [--name <name>] [--force]
vaultik [--config <path>] snapshot remove <snapshot-id> [--dry-run] [--force] [--local-only] [--json] vaultik [--config <path>] snapshot remove <snapshot-id> [--dry-run] [--force]
vaultik [--config <path>] snapshot restore <snapshot-id> <target-dir> [paths...] [--verify] vaultik [--config <path>] snapshot prune
vaultik [--config <path>] prune [--force] [--json] vaultik [--config <path>] restore <snapshot-id> <target-dir> [paths...]
vaultik [--config <path>] prune [--dry-run] [--force]
vaultik [--config <path>] info vaultik [--config <path>] info
vaultik [--config <path>] remote info [--json] vaultik [--config <path>] store info
vaultik [--config <path>] remote nuke --force
vaultik [--config <path>] database delete [--force]
vaultik completion <bash|zsh|fish|powershell>
vaultik version
``` ```
### global flags ### environment
* `--config <path>`: Path to config file (default: `$VAULTIK_CONFIG`, then platform config dir, then `/etc/vaultik/config.yml`) * `VAULTIK_AGE_SECRET_KEY`: Required for `restore` and deep `verify`. Contains the age private key for decryption.
* `--verbose`, `-v`: Enable verbose output * `VAULTIK_CONFIG`: Optional path to config file.
* `--debug`: Enable debug output
* `--quiet`, `-q`: Suppress non-error output (also suppresses startup banner)
* `--skip-errors`: Continue past per-file errors instead of aborting (applies to `snapshot create` and `restore`)
### environment variables
* `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
### 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
**`config init`**: Write a default config file with commented explanations for **snapshot create**: Perform incremental backup of configured snapshots
every setting. Writes to the path from `--config`, `$VAULTIK_CONFIG`, or the * Config is located at `/etc/vaultik/config.yml` by default
platform config directory (`~/Library/Application Support/vaultik/` on macOS,
`~/.config/vaultik/` on Linux, `/etc/vaultik/` as root). Refuses to overwrite an
existing file. Created with mode `0600` since it will contain credentials.
**`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 storage_url`). 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`,
`vaultik config set storage_url "file:///mnt/backups"`). 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)
* On macOS, the terminal application running vaultik needs Full Disk Access
(System Settings → Privacy & Security → Full Disk Access) to read
TCC-protected directories; without it the backup aborts with a permission
error that explains how to fix it
* `--cron`: Silent unless error (for crontab) * `--cron`: Silent unless error (for crontab)
* `--prune`: After backup, drop older snapshots of each backed-up name and * `--daemon`: Run continuously with filesystem monitoring and periodic scans (see [daemon mode](#daemon-mode))
remove orphaned blobs from remote storage. By default keeps only the latest * `--prune`: Delete old snapshots and orphaned blobs after backup
snapshot per name; use `--keep-newer-than` for a rolling window. * `--skip-errors`: Skip file read errors (log them loudly but continue)
* `--keep-newer-than <duration>`: With `--prune`, keep snapshots newer than
this duration instead of only the latest (e.g. `4w`, `30d`, `6mo`, `1y`)
**`snapshot list`**: Show every snapshot known to the destination **snapshot list**: List all snapshots with their timestamps and sizes
store with timestamps and three sizes per snapshot (compressed
remote size; total uncompressed chunk size; size of chunks newly
referenced by that snapshot). The uncompressed and "new chunk"
columns show `<remote only>` for snapshots not in the local index.
* `--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 * `--deep`: Download and verify blob contents (not just existence)
* `--deep`: Downloads and decrypts each blob, verifies chunk hashes against the
encrypted metadata database
* `--json`: Output results as JSON
**`snapshot purge`**: Remove old snapshots based on criteria. Retention is **snapshot purge**: Remove old snapshots based on criteria
per-snapshot-name (`--keep-latest` keeps the latest of each name, not the * `--keep-latest`: Keep the most recent snapshot per snapshot name
latest globally). * `--older-than`: Remove snapshots older than duration (e.g., 30d, 6mo, 1y)
* `--keep-latest`: Keep only the most recent snapshot of each name * `--name`: Filter purge to a specific snapshot name
* `--older-than <duration>`: Remove snapshots older than duration (e.g. `30d`, `6m`, `1y`)
* `--snapshot <name>`: Restrict to specific snapshot names (repeat for multiple)
* `--force`: Skip confirmation prompt * `--force`: Skip confirmation prompt
**`snapshot remove`**: Remove one snapshot. By default this removes the **snapshot remove**: Remove a specific snapshot
snapshot from the local index and strips the snapshot's metadata from
the backup destination store. Blobs are NOT touched — deleting blobs
requires reading every remaining remote manifest (the destination store
may hold snapshots this host doesn't know about), which is what
`vaultik prune` does. On success the command prints the exact `vaultik
prune` invocation to run as a follow-up. Local row cleanup (files,
chunks, blobs the snapshot was the last referrer for) runs
automatically. If the destination store is unreachable, the local-DB
removal still completes and a warning is emitted; rerun `vaultik prune`
once the store is reachable to finish remote cleanup. To wipe everything
on the destination in one go, use `vaultik remote nuke --force`.
* `--local-only`: Skip remote cleanup; only touch the local index
* `--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
**`snapshot restore`**: Restore files from a backup snapshot. **snapshot prune**: Clean orphaned data from local database
* Requires `VAULTIK_AGE_SECRET_KEY` environment variable
**restore**: Restore snapshot to target directory
* Requires `VAULTIK_AGE_SECRET_KEY` environment variable with age private key
* 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), * Downloads and decrypts metadata, fetches required blobs, reconstructs files
symlinks, and empty directories * Preserves file permissions, timestamps, and ownership (ownership requires root)
* `--verify`: After restoring, verify every file's chunk hashes match * Handles symlinks and directories
**`prune`**: Tidy up everything that isn't needed. Runs three passes: **prune**: Remove unreferenced blobs from remote storage
(1) reconcile the local index against the destination store — any * Scans all snapshots for referenced blobs
local snapshot whose remote metadata is missing is dropped from the * Deletes orphaned blobs
local index; (2) delete orphaned local rows (files, chunks, blobs no
longer referenced by any completed snapshot); (3) list every remote
manifest on the destination store to compute the still-referenced blob
set and delete any blob not in that set. Step (3) reads all remote
manifests — network cost scales with the number of snapshots. `snapshot
create --prune` runs the same cleanup automatically; this is the
manual entry point for the same work.
* `--force`: Skip confirmation prompt
* `--json`: Output stats as JSON
**`info`**: Display system configuration, storage settings, encryption **info**: Display system and configuration information
recipients, and local database statistics.
**`remote info`**: Show storage backend type and location plus detailed **store info**: Display S3 bucket configuration and storage statistics
remote storage inventory: per-snapshot metadata sizes, blob counts, and
orphaned blob detection.
* `--json`: Output as JSON
**`remote nuke`**: Delete every snapshot's metadata and every blob from the
backup destination store, leaving the bucket prefix empty. Destructive and
irreversible. This is the single supported way to wipe the entire
destination store.
* `--force`: Required to confirm destruction.
**`database delete`**: Delete the local SQLite state database file
entirely. Remote storage is unaffected; the next backup will do a full
scan and re-deduplicate against existing remote blobs, and the local
index will re-bind to the currently configured storage destination.
Use this after changing `storage_url` to a different destination.
* `--force`: Skip confirmation prompt
--- ---
## storage backends ## daemon mode
vaultik supports three storage backends, selected via the `storage_url` config field: When `--daemon` is passed to `snapshot create`, vaultik runs as a
long-running process that continuously monitors configured directories for
changes and creates backups automatically.
**S3** (`s3://bucket/prefix?endpoint=host&region=us-east-1`): Any S3-compatible ```sh
object store. Credentials are read from `s3.access_key_id` and vaultik --config /etc/vaultik.yaml snapshot create --daemon
`s3.secret_access_key` in the config file. ```
**Local filesystem** (`file:///path/to/backup`): Stores blobs and metadata on ### how it works
a local or mounted filesystem. Useful for testing or backing up to a NAS.
**Rclone** (`rclone://remote/path`): Uses rclone's 70+ supported cloud 1. **Initial backup**: On startup, a full backup of all configured snapshots
providers. Requires rclone to be configured separately (`rclone config`). runs immediately.
2. **Filesystem watching**: All configured snapshot paths are monitored for
file changes using OS-native filesystem notifications (inotify on Linux,
FSEvents on macOS, ReadDirectoryChangesW on Windows) via the
[fsnotify](https://github.com/fsnotify/fsnotify) library.
3. **Periodic backups**: At each `backup_interval` tick, if filesystem
changes have been detected and `min_time_between_run` has elapsed since
the last backup, a backup runs for only the affected snapshots.
4. **Full scans**: At each `full_scan_interval` tick, a full backup of all
snapshots runs regardless of detected changes. This catches any changes
that filesystem notifications may have missed.
5. **Graceful shutdown**: On SIGTERM or SIGINT, the daemon completes any
in-progress backup before exiting.
Legacy S3 configuration via `s3.*` fields (endpoint, bucket, prefix, etc.) is ### configuration
still supported for backward compatibility. `storage_url` takes precedence if
both are set. These config fields control daemon behavior:
```yaml
backup_interval: 1h # How often to check for changes and run backups
full_scan_interval: 24h # How often to do a complete scan of all paths
min_time_between_run: 15m # Minimum gap between consecutive backup runs
```
### notes
* New directories created under watched paths are automatically picked up.
* The daemon uses the same `CreateSnapshot` logic as one-shot mode — each
backup run is a standard incremental snapshot.
* The `--prune`, `--cron`, and `--skip-errors` flags work in daemon mode
and apply to each individual backup run.
--- ---
## architecture ## architecture
### remote storage layout ### s3 bucket layout
``` ```
<bucket>/<prefix>/ s3://<bucket>/<prefix>/
├── blobs/ ├── blobs/
│ └── <aa>/<bb>/<full_blob_hash> │ └── <aa>/<bb>/<full_blob_hash>
└── metadata/ └── metadata/
── <snapshot_id>/ ── <snapshot_id>/
├── db.zst.age # Encrypted binary SQLite database ├── db.zst.age
└── manifest.json.zst # Unencrypted blob list (for pruning) └── manifest.json.zst
``` ```
* Blobs are two-level directory sharded using the first 4 hex chars of the blob hash * `blobs/<aa>/<bb>/...`: Two-level directory sharding using first 4 hex chars of blob hash
* `db.zst.age` is a binary SQLite database (zstd compressed, age encrypted) * `metadata/<snapshot_id>/db.zst.age`: Encrypted, compressed SQLite database
containing all file metadata, chunk mappings, and relationships for the snapshot * `metadata/<snapshot_id>/manifest.json.zst`: Unencrypted blob list for pruning
* `manifest.json.zst` is an unencrypted compressed JSON blob list, enabling
pruning without the private key
Snapshot IDs follow the format `<hostname>_<snapshot-name>_<RFC3339-timestamp>` ### blob manifest format
(e.g. `server1_home_2025-06-01T12:00:00Z`).
The `manifest.json.zst` file is unencrypted (compressed JSON) to enable pruning without decryption:
```json
{
"snapshot_id": "hostname_snapshotname_2025-01-01T12:00:00Z",
"blob_hashes": [
"aa1234567890abcdef...",
"bb2345678901bcdef0..."
]
}
```
Snapshot IDs follow the format `<hostname>_<snapshot-name>_<timestamp>` (e.g., `server1_home_2025-01-01T12:00:00Z`).
### local sqlite schema
```sql
CREATE TABLE files (
id TEXT PRIMARY KEY,
path TEXT NOT NULL UNIQUE,
mtime INTEGER NOT NULL,
size INTEGER NOT NULL,
mode INTEGER NOT NULL,
uid INTEGER NOT NULL,
gid INTEGER NOT NULL
);
CREATE TABLE file_chunks (
file_id TEXT NOT NULL,
idx INTEGER NOT NULL,
chunk_hash TEXT NOT NULL,
PRIMARY KEY (file_id, idx),
FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE
);
CREATE TABLE chunks (
chunk_hash TEXT PRIMARY KEY,
size INTEGER NOT NULL
);
CREATE TABLE blobs (
id TEXT PRIMARY KEY,
blob_hash TEXT NOT NULL UNIQUE,
uncompressed INTEGER NOT NULL,
compressed INTEGER NOT NULL,
uploaded_at INTEGER
);
CREATE TABLE blob_chunks (
blob_hash TEXT NOT NULL,
chunk_hash TEXT NOT NULL,
offset INTEGER NOT NULL,
length INTEGER NOT NULL,
PRIMARY KEY (blob_hash, chunk_hash)
);
CREATE TABLE chunk_files (
chunk_hash TEXT NOT NULL,
file_id TEXT NOT NULL,
file_offset INTEGER NOT NULL,
length INTEGER NOT NULL,
PRIMARY KEY (chunk_hash, file_id)
);
CREATE TABLE snapshots (
id TEXT PRIMARY KEY,
hostname TEXT NOT NULL,
vaultik_version TEXT NOT NULL,
started_at INTEGER NOT NULL,
completed_at INTEGER,
file_count INTEGER NOT NULL,
chunk_count INTEGER NOT NULL,
blob_count INTEGER NOT NULL,
total_size INTEGER NOT NULL,
blob_size INTEGER NOT NULL,
compression_ratio REAL NOT NULL
);
CREATE TABLE snapshot_files (
snapshot_id TEXT NOT NULL,
file_id TEXT NOT NULL,
PRIMARY KEY (snapshot_id, file_id)
);
CREATE TABLE snapshot_blobs (
snapshot_id TEXT NOT NULL,
blob_id TEXT NOT NULL,
blob_hash TEXT NOT NULL,
PRIMARY KEY (snapshot_id, blob_id)
);
```
### data flow ### data flow
**backup:** #### backup
1. Open local SQLite index, load known files and chunks into memory 1. Load config, open local SQLite index
2. Walk source directories, compare mtime/size/mode against index 1. Walk source directories, check mtime/size against index
3. For changed/new files: chunk using content-defined chunking (FastCDC) 1. For changed/new files: chunk using content-defined chunking
4. For symlinks and directories: record metadata (no chunking) 1. For each chunk: hash, check if already uploaded, add to blob packer
5. For each chunk: hash, check dedup, add to blob packer 1. When blob reaches threshold: compress, encrypt, upload to S3
6. When blob reaches size threshold: compress (zstd), encrypt (age), upload 1. Build snapshot metadata, compress, encrypt, upload
7. Build snapshot metadata database, compress, encrypt, upload 1. Create blob manifest (unencrypted) for pruning support
8. Create unencrypted blob manifest for pruning support
**restore:** #### restore
1. Download and decrypt `metadata/<snapshot_id>/db.zst.age` 1. Download `metadata/<snapshot_id>/db.zst.age`
2. Open the binary SQLite database 1. Decrypt and decompress SQLite database
3. Query files (optionally filtered by paths) 1. Query files table (optionally filtered by paths)
4. Download and decrypt required blobs 1. For each file, get ordered chunk list from file_chunks
5. Extract chunks, reconstruct files 1. Download required blobs, decrypt, decompress
6. Restore permissions, timestamps, ownership, symlinks 1. Extract chunks and reconstruct files
1. Restore permissions, mtime, uid/gid
**prune:** #### prune
1. List all snapshot manifests 1. List all snapshot manifests
2. Build set of all referenced blob hashes 1. Build set of all referenced blob hashes
3. List all blobs in storage 1. List all blobs in storage
4. Delete any blob not in the referenced set 1. Delete any blob not in referenced set
### chunking and deduplication ### chunking
* Content-defined chunking using the FastCDC algorithm * Content-defined chunking using FastCDC algorithm
* Average chunk size: configurable (default 10MB) * Average chunk size: configurable (default 10MB)
* Deduplication at file level (unchanged files skipped) and chunk level * Deduplication at chunk level
(identical chunks across files stored once) * Multiple chunks packed into blobs for efficiency
* Multiple chunks packed into blobs to reduce object count
### encryption ### encryption
* Asymmetric encryption using age (X25519 + XChaCha20-Poly1305) * Asymmetric encryption using age (X25519 + XChaCha20-Poly1305)
* Only the public key is needed on the source host * Only public key needed on source host
* Each blob and each metadata database is encrypted independently * Each blob encrypted independently
* Multiple recipients supported (encrypt to multiple keys) * Metadata databases also encrypted
### compression ### compression
* zstd compression at configurable level (1-19, default 3) * zstd compression at configurable level
* Applied before encryption at the blob level * Applied before encryption
* Blob-level compression for efficiency
--- ---
## configuration reference ## does not
Run `vaultik config init` to generate a fully commented config file. * Store any secrets on the backed-up machine
Key fields: * Require mutable remote metadata
* Use tarballs, restic, rsync, or ssh
* Require a symmetric passphrase or password
* Trust the source system with anything
| Field | Default | Description | ## does
|-------|---------|-------------|
| `age_recipients` | (required) | Age public keys for encryption | * Incremental deduplicated backup
| `snapshots` | (required) | Named snapshot definitions with paths and excludes | * Blob-packed chunk encryption
| `storage_url` | | Storage backend URL (`s3://`, `file://`, `rclone://`) | * Content-addressed immutable blobs
| `s3.*` | | Legacy S3 configuration (endpoint, bucket, credentials) | * Public-key encryption only
| `exclude` | | Global exclude patterns (applied to all snapshots) | * SQLite-based local and snapshot metadata
| `chunk_size` | `10MB` | Average chunk size for content-defined chunking | * Fully stream-processed storage
| `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 |
--- ---
## limitations
* **No extended attributes (xattrs).** ACLs, macOS Finder metadata,
quarantine flags, SELinux labels, and other extended attributes are not
backed up or restored.
* **No hard link detection.** Two hard links to the same inode are backed
up as independent files. Content deduplication means the data is stored
once, but the hard link relationship is lost on restore.
* **No sparse file support.** Sparse files are fully materialized during
backup. A 100 GB sparse VM disk that is mostly zeros will consume the
full (compressed) size in storage.
* **No bandwidth limiting.** Uploads and downloads use whatever bandwidth
is available. There is no `--bwlimit` flag yet.
* **No parallel blob downloads during restore.** Blobs are fetched
sequentially. Restore speed is bound by single-stream throughput.
* **Device nodes, named pipes, and sockets are silently skipped.** Only
regular files, directories, and symlinks are backed up.
* **No database migrations.** If the local SQLite schema changes between
versions, delete the local database (`vaultik database delete`) and run
a full backup. Remote storage is unaffected.
* **Files that change during backup may be inconsistent.** There is no
filesystem snapshot or freeze. If a file is modified between the scan
and chunk phases, the backed-up copy may reflect a partial write.
* **Ownership restoration requires root.** File uid/gid are recorded
and restored, but `chown` requires elevated privileges. Without root,
files are restored with the current user's ownership.
---
## roadmap
Items still to do before / shortly after 1.0. Loosely ordered by
priority.
### correctness and operability
* **Security audit of the encryption implementation.** Pre-1.0
blocker if we're advertising "secure" at the top of this README.
age + zstd + content-defined chunking is mostly off-the-shelf
pieces, but the seams (key handling, recipient parsing, manifest
trust boundary, restore-time identity validation) need an outside
read.
* **Error-condition tests.** Today's coverage is the happy path
plus a few specific regressions. Need fault-injection coverage:
network failures mid-blob, disk-full during restore, corrupted /
truncated / missing blobs, partial uploads, kill -9 between
manifest and db.zst.age writes.
* **Verify restored content end-to-end in CI.** The current
integration test does this for a small synthetic snapshot but
not at scale. A nightly job against a multi-GB representative
snapshot would catch silent regressions in the chunker, packer,
or restore planner.
### performance
* **Parallel blob downloads during restore.** Single-stream right
now. With a fast S3 endpoint and a multi-core machine restore is
bound by per-blob fetch + decrypt + decompress; running N of
those in parallel against the disk cache would close most of the
remaining gap. Needs to interact correctly with the locality
planner and sweeper.
* **Bandwidth limiting (`--bwlimit`).** Both upload and download.
Useful for backing up over a shared link. Tricky to make work
correctly with the parallel-download story.
* **Restart of interrupted restore.** Today restore is restartable
in the sense that re-running it overwrites partial output; it
doesn't resume from where it stopped or skip already-present
files. A `--resume` mode that checks targets before fetching
blobs would matter for very large restores.
### usability
* **Man pages and richer `--help` examples.** Cobra generates
basic help; man pages would be a separate target.
* **`--bwlimit` style human-readable size flags** across the
command surface where they're currently raw integers.
* **`vaultik snapshot diff <a> <b>`** — show which files changed
between two snapshots without restoring either.
* **Status reporting hook for `--cron`.** When a backup fails
silently in cron, the user has no idea. A configurable
webhook / email / `notify-send` hook on completion (success and
failure) would close the loop.
### infrastructure
* **Cross-machine restore documentation.** The "restore from
another host" workflow works but isn't documented as a
first-class operation in this README. Worth a dedicated section
once it's settled.
* **Schema migrations.** Currently nonexistent — pre-1.0 schema
changes are handled by `vaultik database delete` plus a full
re-scan. Post-1.0 we'll need a migration story to keep existing
index databases usable across upgrades.
* **Storage backend coverage tests.** S3, file://, and rclone://
all share the Storer interface but the rclone path is the least
exercised in CI.
---
## output style
All user-facing output goes through helpers in `internal/ui` and conforms
to a uniform style. Color is enabled when stdout is a TTY and the
`NO_COLOR` environment variable is unset (https://no-color.org/).
Message classes:
| Class | Marker | Alignment | Use for |
|-------|--------|-----------|---------|
| Banner | none | column 0 | The startup line printed once per invocation |
| Begin | `》` (white) | column 0 | An operation is about to start (present-continuous verb) |
| Complete | `》` (green) | column 0 | An operation just finished (past-tense verb) |
| Info | `》` (white) | column 0 | Neutral status update |
| Notice | `》` (cyan) | column 0 | Important note that is not a warning |
| Warning | `⚠️ Warning:` (orange/yellow) | column 0 | Recoverable problem |
| Error | `🛑 ERROR:` (red) | column 0 | Operation aborted |
| Progress | ` 》` (white) | column 2 | Heartbeat or per-item status during a long-running operation |
| Detail | ` 》` (white) | column 2 | Continuation/sub-line of a preceding Complete (visually identical to Progress) |
Conventions:
* Messages are complete English sentences ending with a period.
* Fully qualify terms — say "backup destination store" instead of
"storage", "snapshot source files enumeration" instead of "scan",
"local index database" instead of "database".
* Every operation that emits a Complete also emits a corresponding
Begin. Operations that print only a Begin (because completion is
obvious from a later Begin) should be rare and intentional.
* Use natural verb tense to signal state: "Uploading" for Begin,
"Uploaded" for Complete. Never write the words "begin" or "complete"
in the body — the marker color already conveys that.
* All elapsed and remaining-time fields are explicitly scoped to their
subject: write "blob upload elapsed: 30s, blob upload ETA: 03:15:00
(est remain 14s)", never just "elapsed 30s, ETA 14s".
* "ETA" means an absolute clock time (when the operation will finish),
not a remaining-duration. Use `ui.Time()` for the former and
`ui.Duration()` for the latter, and label both.
* `ui.Time` formats same-day times as `HH:MM:SS` and other-day times as
`YYYY-MM-DD HH:MM:SS`. No timezone — local time is implied.
Value colorizers in `internal/ui` colorize specific value types
consistently. Compose messages from these helpers rather than embedding
ANSI escapes inline:
| Helper | Color | Use for |
|--------|-------|---------|
| `Hex` | cyan | Blob hashes, chunk hashes (truncated to 12 chars + `...`) |
| `Snapshot` | bold cyan | Snapshot IDs (untruncated) |
| `Path` | blue | Filesystem paths |
| `Size` | magenta | Byte counts (human-readable) |
| `Speed` | magenta | Bytes-per-second rates |
| `Duration` | yellow | Elapsed or remaining time |
| `Time` | yellow | Absolute clock times |
| `Count` | magenta | Integer counts with thousands separators |
| `Percent` | magenta | Percentages |
When `NO_COLOR` is set or output is not a TTY, all helpers return plain
text and the marker prefixes (`》`, `Warning:`, `ERROR:`) emit without
ANSI escapes. The emoji prefixes on Warning and Error are always emitted
regardless of color setting (emoji are not color).
## requirements ## requirements
* Go 1.26 or later * Go 1.24 or later
* S3-compatible object storage (or local filesystem, or rclone remote) * S3-compatible object storage
* Sufficient disk space for local index (typically <1GB)
## development workflow
All changes follow this workflow. No exceptions.
1. Create a feature branch off `main`.
2. Write tests.
3. Write the implementation.
4. Fix implementation errors until it compiles and tests pass.
5. Fix linting errors (`make lint`).
6. Update documentation and README as required by the change.
7. Format code (`make fmt`).
8. Run `make check` (lint + fmt-check + test). Fix any issues. Repeat until clean.
9. Commit on the branch.
10. Merge to `main`.
11. Push.
Do not commit directly to `main`. Do not skip steps.
Repository policies for AI agents are in [`AGENTS.md`](AGENTS.md).
## Entrypoints
This repository adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard: normalized scripts in `script/` are the entrypoints for the
development workflow, and the Makefile targets are thin shims that call
them. We provide:
* `script/bootstrap` — install all development dependencies (go,
golangci-lint, Go module download)
* `script/setup` — make a fresh clone ready for development: runs
`script/bootstrap`, then `script/install-precommit`
* `script/projectname` — print the project name (used for the Docker
image tag)
* `script/test` — run the test suite (verbose rerun on failure)
* `script/lint` — run `golangci-lint run ./...`
* `script/lint-fix` — apply the linter's autofixes (rewrites files)
* `script/fmt` — format all code (writes)
* `script/fmt-check` — check formatting (read-only)
* `script/check` — run `script/test`, `script/lint`, and
`script/fmt-check`
* `script/docker` — build the Docker image tagged via
`script/projectname`
* `script/cibuild` — CI entrypoint: `docker build .` (the Dockerfile
runs the checks)
* `script/precommit` — pre-commit gate: `go mod tidy` + `go fmt` (must
not change files), then `script/check`
* `script/install-precommit` — install the git pre-commit hook that
runs `script/precommit`
## license ## license

View File

@@ -1,408 +0,0 @@
---
title: Repository Policies
last_modified: 2026-07-06
---
This document covers repository structure, tooling, and workflow standards. Code
style conventions are in separate documents:
- [Code Styleguide](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE.md)
(general, bash, Docker)
- [Go](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_GO.md)
- [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md)
- [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md)
- [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md)
---
- Cross-project documentation (such as this file) must include
`last_modified: YYYY-MM-DD` in the YAML front matter so it can be kept in sync
with the authoritative source as policies evolve.
- **ALL external references must be pinned by cryptographic hash.** This
includes Docker base images, Go modules, npm packages, GitHub Actions, and
anything else fetched from a remote source. Version tags (`@v4`, `@latest`,
`:3.21`, etc.) are server-mutable and therefore remote code execution
vulnerabilities. The ONLY acceptable way to reference an external dependency
is by its content hash (Docker `@sha256:...`, Go module hash in `go.sum`, npm
integrity hash in lockfile, GitHub Actions `@<commit-sha>`). No exceptions.
This also means never `curl | bash` to install tools like pyenv, nvm, rustup,
etc. Instead, download a specific release archive from GitHub, verify its hash
(hardcoded in the Dockerfile or script), and only then install. Unverified
install scripts are arbitrary remote code execution. This is the single most
important rule in this document. Double-check every external reference in
every file before committing. There are zero exceptions to this rule.
- Every repo with software must have a root `Makefile` with these targets:
`make bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes),
`make fmt-check` (read-only), `make check` (runs `test`, `lint`, `fmt-check`),
`make docker`, and `make hooks` (installs pre-commit hook). A model Makefile
is at `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
- Repos follow the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
pattern: the implementation of each Makefile target lives in an executable
script in `script/` (`script/bootstrap`, `script/setup`, `script/test`,
`script/lint`, `script/fmt`, `script/fmt-check`, `script/check`,
`script/docker`), and the Makefile targets are thin shims that call them. The
scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run in
minimal containers (e.g. alpine images have no bash); locate the repo root
with `$(cd "$(dirname "$0")/.." && pwd -P)` and `cd` there before acting. From
the standard's canonical set we use `bootstrap`, `setup` (make the repo ready
for development after a fresh clone: runs `bootstrap`, then
`install-precommit`, plus any repo-specific initialization), `test`, and
`cibuild`. `script/bootstrap` installs all dependencies idempotently and
assumes nothing is present: base tools come from nix, apt, brew, or apk
(detected in that order; apt runs noninteractive). For node it uses the
installed node if present; otherwise it installs a PINNED node version via
nvm, first installing nvm itself if missing — from a hash-verified GitHub
release archive (never `curl | sh`), with bash installed as an explicit
prerequisite since nvm requires bash. yarn is then pinned via
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
always exact versions. `script/cibuild` runs the CI build: it changes to the
repo root and runs `docker build .`; the Gitea workflow calls it. Four further
scripts are our own extensions to the standard: `script/check` runs
`script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is
what the git pre-commit hook runs, and it calls `script/check`;
`script/install-precommit` installs the git pre-commit hook (the `make hooks`
target shims to it); and `script/projectname` (literally that filename) simply
outputs the project's name. Scripts that need the name call
`script/projectname` — e.g. `script/docker` assembles its image tag from it —
so those scripts stay byte-identical across all repos. Repo-type-specific
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
`script/precommit`, not in the hook itself. Model scripts are at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
must document the provided scripts in an **Entrypoints** section (see the
README requirements below).
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
instead of invoking the underlying tools directly. The Makefile is the single
source of truth for how these operations are run.
- The Makefile is authoritative documentation for how the repo is used. Beyond
the required targets above, it should have targets for every common operation:
running a local development server (`make run`, `make dev`), re-initializing
or migrating the database (`make db-reset`, `make migrate`), building
artifacts (`make build`), generating code, seeding data, or anything else a
developer would do regularly. If someone checks out the repo and types
`make<tab>`, they should see every meaningful operation available. A new
contributor should be able to understand the entire development workflow by
reading the Makefile.
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
as a build step so the build fails if the branch is not green. For non-server
repos, the Dockerfile should bring up a development environment and run
`make check`. For server repos, `make check` should run as an early build
stage before the final image is assembled. Dockerfiles install development
prerequisites by running `script/bootstrap` rather than duplicating installs
inline; COPY `script/` and the dependency manifests (`package.json` +
`yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap
layer stays cached until dependencies change.
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
repos use a multistage build where linting runs in an independent stage based
on the `golangci/golangci-lint` image (pinned by hash). This stage runs
`make fmt-check` and `make lint` before the full build begins. The build stage
then declares an explicit dependency on the lint stage via
`COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete
linting before proceeding to compilation and tests. This ensures lint failures
surface in seconds rather than minutes, without blocking on dependency
download or compilation in the build stage.
The standard pattern for a Go repo Dockerfile is:
```dockerfile
# Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
FROM golangci/golangci-lint@sha256:... AS lint
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make fmt-check
RUN make lint
# Build stage
# golang:1.x-alpine, YYYY-MM-DD
FROM golang@sha256:... AS builder
WORKDIR /src
# Force BuildKit to run the lint stage before proceeding
COPY --from=lint /src/go.sum /dev/null
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make test
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o /app ./cmd/app/
# Runtime stage
FROM alpine@sha256:...
COPY --from=builder /app /usr/local/bin/app
ENTRYPOINT ["app"]
```
Key points:
- The lint stage uses the `golangci/golangci-lint` image directly (it
includes both Go and the linter), so there is no need to install the
linter separately.
- `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates
a stage dependency. BuildKit runs stages in parallel by default; without
this line, the build stage would not wait for lint to finish and a lint
failure might not fail the overall build.
- If the project uses `//go:embed` directives that reference build artifacts
(e.g. a web frontend compiled in a separate stage), the lint stage must
create placeholder files so the embed directives resolve. Example:
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
The lint stage should not depend on the actual build output — it exists to
fail fast.
- If the project requires CGO or system libraries for linting (e.g.
`vips-dev`), install them in the lint stage with `apk add`.
- The build stage runs `make test` after compilation setup. Tests run in the
build stage, not the lint stage, because they may require compiled
artifacts or heavier dependencies.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `script/cibuild` (which runs `docker build .`) on push. Since the
Dockerfile already runs `make check`, a successful build implies all checks
pass.
- Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
two exceptions: four-space indents (except Go), and `proseWrap: always` for
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
- Pre-commit hook: runs `script/precommit`, which calls `script/check`. If local
testing is not possible in the repo, `script/precommit` may skip `script/test`
and run only `script/lint` and `script/fmt-check`. The hook is installed by
`script/install-precommit`; the Makefile must provide a `make hooks` target
that shims to it.
- All repos with software must have tests that run via the platform-standard
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
tests exist yet, add the most minimal test possible — e.g. importing the
module under test to verify it compiles/parses. There is no excuse for
`make test` to be a no-op.
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
Makefile.
- **`make test` should use the conditional verbose rerun pattern.** Run tests
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
show full output. This keeps CI logs and `docker build` output clean on
success (just package/suite summaries) while providing full diagnostic detail
on failure (every test case, every assertion). The general shell pattern:
```makefile
test:
@<test-command> || \
{ echo "--- Rerunning with -v for details ---"; \
<test-command-with-v>; exit 1; }
```
Go example:
```makefile
test:
@go test -timeout 30s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -timeout 30s -race -v ./...; exit 1; }
```
Python example:
```makefile
test:
@python -m pytest || \
{ echo "--- Rerunning with -v for details ---"; \
python -m pytest -v; exit 1; }
```
The `exit 1` ensures the target always fails after a rerun — the first run
already proved the tests are broken, so the build must not pass even if a
flaky test happens to succeed on the second attempt. The rerun exists solely
for diagnostic output.
- Docker builds must complete in under 5 minutes.
- `make check` must not modify any files in the repo. Tests may use temporary
directories.
- `main` must always pass `make check`, no exceptions.
- Never commit secrets. `.env` files, credentials, API keys, and private keys
must be in `.gitignore`. No exceptions.
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
Fetch the standard `.gitignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
a new repo.
- **No build artifacts in version control.** Code-derived data (compiled
bundles, minified output, generated assets) must never be committed to the
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
should generate these at build time. Notable exception: Go protobuf generated
files (`.pb.go`) ARE committed because repos need to work with `go get`, which
downloads code but does not execute code generation.
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
- Never force-push to `main`.
- Make all changes on a feature branch. You can do whatever you want on a
feature branch.
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
manually by the user. Fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`.
- When pinning images or packages by hash, add a comment above the reference
with the version and date (YYYY-MM-DD).
- Use `yarn`, not `npm`.
- Write all dates as YYYY-MM-DD (ISO 8601).
- Simple projects should be configured with environment variables.
- Dockerized web services listen on port 8080 by default, overridable with
`PORT`.
- **HTTP/web services must be hardened for production internet exposure before
tagging 1.0.** This means full compliance with security best practices
including, without limitation, all of the following:
- **Security headers** on every response:
- `Strict-Transport-Security` (HSTS) with `max-age` of at least one year
and `includeSubDomains`.
- `Content-Security-Policy` (CSP) with a restrictive default policy
(`default-src 'self'` as a baseline, tightened per-resource as
needed). Never use `unsafe-inline` or `unsafe-eval` unless
unavoidable, and document the reason.
- `X-Frame-Options: DENY` (or `SAMEORIGIN` if framing is required).
Prefer the `frame-ancestors` CSP directive as the primary control.
- `X-Content-Type-Options: nosniff`.
- `Referrer-Policy: strict-origin-when-cross-origin` (or stricter).
- `Permissions-Policy` restricting access to browser features the
application does not use (camera, microphone, geolocation, etc.).
- **Request and response limits:**
- Maximum request body size enforced on all endpoints (e.g. Go
`http.MaxBytesReader`). Choose a sane default per-route; never accept
unbounded input.
- Maximum response body size where applicable (e.g. paginated APIs).
- `ReadTimeout` and `ReadHeaderTimeout` on the `http.Server` to defend
against slowloris attacks.
- `WriteTimeout` on the `http.Server`.
- `IdleTimeout` on the `http.Server`.
- Per-handler execution time limits via `context.WithTimeout` or
chi/stdlib `middleware.Timeout`.
- **Authentication and session security:**
- Rate limiting on password-based authentication endpoints. API keys are
high-entropy and not susceptible to brute force, so they are exempt.
- CSRF tokens on all state-mutating HTML forms. API endpoints
authenticated via `Authorization` header (Bearer token, API key) are
exempt because the browser does not attach these automatically.
- Passwords stored using bcrypt, scrypt, or argon2 — never plain-text,
MD5, or SHA.
- Session cookies set with `HttpOnly`, `Secure`, and `SameSite=Lax` (or
`Strict`) attributes.
- **Reverse proxy awareness:**
- True client IP detection when behind a reverse proxy
(`X-Forwarded-For`, `X-Real-IP`). The application must accept
forwarded headers only from a configured set of trusted proxy
addresses — never trust `X-Forwarded-For` unconditionally.
- **CORS:**
- Authenticated endpoints must restrict `Access-Control-Allow-Origin` to
an explicit allowlist of known origins. Wildcard (`*`) is acceptable
only for public, unauthenticated read-only APIs.
- **Error handling:**
- Internal errors must never leak stack traces, SQL queries, file paths,
or other implementation details to the client. Return generic error
messages in production; detailed errors only when `DEBUG` is enabled.
- **TLS:**
- Services never terminate TLS directly. They are always deployed behind
a TLS-terminating reverse proxy. The service itself listens on plain
HTTP. However, HSTS headers and `Secure` cookie flags must still be
set by the application so that the browser enforces HTTPS end-to-end.
This list is non-exhaustive. Apply defense-in-depth: if a standard security
hardening measure exists for HTTP services and is not listed here, it is
still expected. When in doubt, harden.
- `README.md` is the primary documentation. Required sections:
- **Description**: First line must include the project name, purpose,
category (web server, SPA, CLI tool, etc.), license, and author. Example:
"µPaaS is an MIT-licensed Go web application by @sneak that receives
git-frontend webhooks and deploys applications via Docker in realtime."
- **Getting Started**: Copy-pasteable install/usage code block.
- **Entrypoints**: Opens by stating that the repo adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard (with that link), then documents each provided `script/`
entrypoint and its purpose.
- **Rationale**: Why does this exist?
- **Design**: How is the program structured?
- **TODO**: Update meticulously, even between commits. When planning, put
the todo list in the README so a new agent can pick up where the last one
left off.
- **License**: MIT, GPL, or WTFPL. Ask the user for new projects. Include a
`LICENSE` file in the repo root and a License section in the README.
- **Author**: [@sneak](https://sneak.berlin).
- First commit of a new repo should contain only `README.md`.
- Go module root: `sneak.berlin/go/<name>`. Always run `go mod tidy` before
committing.
- Use SemVer.
- Database migrations live in `internal/db/migrations/` and must be embedded in
the binary.
- `000_migration.sql` — contains ONLY the creation of the migrations
tracking table itself. Nothing else.
- `001_schema.sql` — the full application schema.
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
There is no installed base to migrate. Edit `001_schema.sql` directly.
- **Post-1.0.0:** add new numbered migration files for each schema change.
Never edit existing migrations after release.
- All repos should have an `.editorconfig` enforcing the project's indentation
settings.
- Avoid putting files in the repo root unless necessary. Root should contain
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
language-specific config). Everything else goes in a subdirectory. Canonical
subdirectory names:
- `bin/` — executable scripts and tools
- `cmd/` — Go command entrypoints
- `configs/` — configuration templates and examples
- `deploy/` — deployment manifests (k8s, compose, terraform)
- `docs/` — documentation and markdown (README.md stays in root)
- `internal/` — Go internal packages
- `internal/db/migrations/` — database migrations
- `pkg/` — Go library packages
- `share/` — systemd units, data files
- `static/` — static assets (images, fonts, etc.)
- `web/` — web frontend source
- When setting up a new repo, files from the `prompts` repo may be used as
templates. Fetch them from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/<path>`.
- New repos must contain at minimum:
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
- `Makefile`
- `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`,
`lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`,
`install-precommit`)
- `Dockerfile`, `.dockerignore`
- `.gitea/workflows/check.yml`
- Go: `go.mod`, `go.sum`, `.golangci.yml`
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
- Python: `pyproject.toml`

180
TODO.md
View File

@@ -1,72 +1,126 @@
# Workflow # Vaultik 1.0 TODO
* branch (from `main`) Linear list of tasks to complete before 1.0 release.
* do the work in Next Step
* move Next Step to the top of Completed Steps
* move the top item of Future Steps into Next Step
* commit (`TODO.md` changes in the same commit as the work)
* merge to `main` if the branch is not protected, otherwise open a PR
* push
# Status ## Rclone Storage Backend (Complete)
pre-1.0 Add rclone as a storage backend via Go library import, allowing vaultik to use any of rclone's 70+ supported cloud storage providers.
# Next Step **Configuration:**
```yaml
storage_url: "rclone://myremote/path/to/backups"
```
User must have rclone configured separately (via `rclone config`).
Triage the stale remote branches (issue #71): for each, merge the work **Implementation Steps:**
or delete the branch. 1. [x] Add rclone dependency to go.mod
2. [x] Create `internal/storage/rclone.go` implementing `Storer` interface
- `NewRcloneStorer(remote, path)` - init with `configfile.Install()` and `fs.NewFs()`
- `Put` / `PutWithProgress` - use `operations.Rcat()`
- `Get` - use `fs.NewObject()` then `obj.Open()`
- `Stat` - use `fs.NewObject()` for size/metadata
- `Delete` - use `obj.Remove()`
- `List` / `ListStream` - use `operations.ListFn()`
- `Info` - return remote name
3. [x] Update `internal/storage/url.go` - parse `rclone://remote/path` URLs
4. [x] Update `internal/storage/module.go` - add rclone case to `storerFromURL()`
5. [x] Test with real rclone remote
# Completed Steps **Error Mapping:**
- `fs.ErrorObjectNotFound``ErrNotFound`
- `fs.ErrorDirNotFound``ErrNotFound`
- `fs.ErrorNotFoundInConfigFile``ErrRemoteNotFound` (new)
- 2026-08-09: Finished the lint remediation under the canonical ---
`.golangci.yml` (issue #61, which also unblocks issue #59). The
remaining findings were fixed behavior-preservingly: `wsl_v5`
whitespace, `sqlclosecheck`, and `prealloc`. The `sqlclosecheck` sites
now close `sql.Rows` in a deferred closure instead of via the
`CloseRows` helper, which the linter could not see through. Only the
`revive` package-name findings remain suppressed, with per-site
`//nolint` directives; the package-rename question behind them is
tracked in issue #76. Verified with `script/cibuild`, which exits 0 —
that is the only trustworthy gate, because `script/lint` runs whatever
`golangci-lint` happens to be on `PATH` rather than the pinned
v2.12.2 that CI and the `Dockerfile` use, so `make check` can report
green on findings CI still fails. That tooling gap is tracked in issue
#78.
- 2026-08-09: The earlier next step "reconcile the uncommitted
`ARCHITECTURE.md` edits on `main`" needed no work: the working tree is
clean and `ARCHITECTURE.md` is committed on `main`.
- 2026-08-07: Updated golangci-lint to v2.12.2 everywhere it is pinned
(`Dockerfile` lint stage, `Makefile` deps target), replaced
`.golangci.yml` with the canonical config (v2 schema, `default: all`),
and remediated the bulk of the lint findings it surfaced (issue #61):
behavior-preserving fixes across every package, 2,990 findings down to
80. `make test` and `make fmt-check` were green at that point but
`make lint` was still red; the commit message claiming `make check`
was green was wrong.
- 2026-08-07: Added the standard `.golangci.yml` and `.editorconfig`
(issue #59); lint findings under the new config are tracked in issue
#61. `script/bootstrap` now installs sqlite3 (needed by tests).
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
Makefile shims, README Entrypoints section
- 2026-07-02: Consolidated CLI verbs, retired overlapping commands; bound
the local index to its backup destination URL.
- 2026-06-28: snapshot rm now removes metadata only and prints the prune
command; restore skips chown when running as non-root.
- 2026-06-26: Snapshot IDs hashed at the storage boundary; snapshot list
made resilient to bad remote entries.
- 2026-06-24: Collapsed snapshot prune into vaultik prune; restore streams
blobs to disk and restores files in blob-locality order; cron output
fixes.
- 2026-06-17: Restore overhaul: ReadAt chunk reads from cached blobs,
reference-counted blob sweeper, integration tests; new internal/ui
output layer, banner, and progress lines.
- 2025-12-18: Added ARCHITECTURE.md and godoc coverage for exported API.
- 2025-07-26: End-to-end integration tests; manifest format refactor;
renamed backup to snapshot; afero filesystem abstraction.
- 2025-07-20: Initial design and implementation: cobra + fx CLI skeleton,
SQLite index database, UUID blob storage with streaming chunking.
# Future Steps ## CLI Polish (Priority)
- Define remaining scope for a first tagged release and cut v0.1.0. 1. Improve error messages throughout
- Ensure all errors include actionable context
- Add suggestions for common issues (e.g., "did you set VAULTIK_AGE_SECRET_KEY?")
## Security (Priority)
1. Audit encryption implementation
- Verify age encryption is used correctly
- Ensure no plaintext leaks in logs or errors
- Verify blob hashes are computed correctly
1. Secure memory handling for secrets
- Clear S3 credentials from memory after client init
- Document that age_secret_key is env-var only (already implemented)
## Testing
1. Write integration tests for restore command
1. Write end-to-end integration test
- Create backup
- Verify backup
- Restore backup
- Compare restored files to originals
1. Add tests for edge cases
- Empty directories
- Symlinks
- Special characters in filenames
- Very large files (multi-GB)
- Many small files (100k+)
1. Add tests for error conditions
- Network failures during upload
- Disk full during restore
- Corrupted blobs
- Missing blobs
## Performance
1. Profile and optimize restore performance
- Parallel blob downloads
- Streaming decompression/decryption
- Efficient chunk reassembly
1. Add bandwidth limiting option
- `--bwlimit` flag for upload/download speed limiting
## Documentation
1. Add man page or --help improvements
- Detailed help for each command
- Examples in help output
## Final Polish
1. Ensure version is set correctly in releases
1. Create release process
- Binary releases for supported platforms
- Checksums for binaries
- Release notes template
1. Final code review
- Remove debug statements
- Ensure consistent code style
1. Tag and release v1.0.0
---
## Daemon Mode (Complete)
1. [x] Implement cross-platform filesystem watcher (via fsnotify)
- Watches source directories for changes
- Tracks dirty paths in memory
- Automatically watches new directories
1. [x] Implement backup scheduler in daemon mode
- Respects backup_interval config
- Triggers backup when dirty paths exist and interval elapsed
- Implements full_scan_interval for periodic full scans
- Respects min_time_between_run to prevent excessive runs
1. [x] Add proper signal handling for daemon
- Graceful shutdown on SIGTERM/SIGINT
- Completes in-progress backup before exit
1. [x] Write tests for daemon mode

View File

@@ -1,4 +1,3 @@
// Package main is the vaultik command-line entry point.
package main package main
import ( import (
@@ -6,45 +5,37 @@ import (
"runtime" "runtime"
"runtime/pprof" "runtime/pprof"
"sneak.berlin/go/vaultik/internal/cli" "git.eeqj.de/sneak/vaultik/internal/cli"
) )
func main() { func main() {
// CPU profiling: set VAULTIK_CPUPROFILE=/path/to/cpu.prof // CPU profiling: set VAULTIK_CPUPROFILE=/path/to/cpu.prof
if cpuProfile := os.Getenv("VAULTIK_CPUPROFILE"); cpuProfile != "" { if cpuProfile := os.Getenv("VAULTIK_CPUPROFILE"); cpuProfile != "" {
f, err := os.Create(cpuProfile) //nolint:gosec // G304: operator-set path f, err := os.Create(cpuProfile)
if err != nil { if err != nil {
panic("could not create CPU profile: " + err.Error()) panic("could not create CPU profile: " + err.Error())
} }
defer func() { _ = f.Close() }() defer func() { _ = f.Close() }()
if err := pprof.StartCPUProfile(f); err != nil {
err = pprof.StartCPUProfile(f)
if err != nil {
panic("could not start CPU profile: " + err.Error()) panic("could not start CPU profile: " + err.Error())
} }
defer pprof.StopCPUProfile() defer pprof.StopCPUProfile()
} }
// Memory profiling: set VAULTIK_MEMPROFILE=/path/to/mem.prof // Memory profiling: set VAULTIK_MEMPROFILE=/path/to/mem.prof
if memProfile := os.Getenv("VAULTIK_MEMPROFILE"); memProfile != "" { if memProfile := os.Getenv("VAULTIK_MEMPROFILE"); memProfile != "" {
defer func() { defer func() {
f, err := os.Create(memProfile) //nolint:gosec // G304: operator-set path f, err := os.Create(memProfile)
if err != nil { if err != nil {
panic("could not create memory profile: " + err.Error()) panic("could not create memory profile: " + err.Error())
} }
defer func() { _ = f.Close() }() defer func() { _ = f.Close() }()
runtime.GC() // get up-to-date statistics runtime.GC() // get up-to-date statistics
if err := pprof.WriteHeapProfile(f); err != nil {
err = pprof.WriteHeapProfile(f)
if err != nil {
panic("could not write memory profile: " + err.Error()) panic("could not write memory profile: " + err.Error())
} }
}() }()
} }
cli.Entry() cli.CLIEntry()
} }

View File

@@ -291,6 +291,21 @@ storage_url: "rclone://las1stor1//srv/pool.2024.04/backups/heraklion"
# # Default: 5MB # # Default: 5MB
# #part_size: 5MB # #part_size: 5MB
# How often to run backups in daemon mode
# Format: 1h, 30m, 24h, etc
# Default: 1h
#backup_interval: 1h
# How often to do a full filesystem scan in daemon mode
# Between full scans, inotify is used to detect changes
# Default: 24h
#full_scan_interval: 24h
# Minimum time between backup runs in daemon mode
# Prevents backups from running too frequently
# Default: 15m
#min_time_between_run: 15m
# Path to local SQLite index database # Path to local SQLite index database
# This database tracks file state for incremental backups # This database tracks file state for incremental backups
# Default: /var/lib/vaultik/index.sqlite # Default: /var/lib/vaultik/index.sqlite

View File

@@ -5,14 +5,8 @@
Vaultik uses a local SQLite database to track file metadata, chunk mappings, and blob associations during the backup process. This database serves as an index for incremental backups and enables efficient deduplication. Vaultik uses a local SQLite database to track file metadata, chunk mappings, and blob associations during the backup process. This database serves as an index for incremental backups and enables efficient deduplication.
**Important Notes:** **Important Notes:**
- **No Migration Support (pre-1.0)**: Vaultik does not support database schema - **No Migration Support**: Vaultik does not support database schema migrations. If the schema changes, the local database must be deleted and recreated by performing a full backup.
migrations. The local index is treated as disposable — if the schema changes, - **Version Compatibility**: In rare cases, you may need to use the same version of Vaultik to restore a backup as was used to create it. This ensures compatibility with the metadata format stored in S3.
delete the local SQLite database (`vaultik database delete`) and run a full
backup. The remote storage is unaffected; the new index will re-deduplicate
against existing remote blobs.
- **Version Compatibility**: In rare cases, you may need to use the same version
of Vaultik to restore a backup as was used to create it. This ensures
compatibility with the metadata format stored in S3.
## Database Tables ## Database Tables

View File

@@ -43,19 +43,18 @@ Blobs contain the actual file data from backups and must be encrypted for securi
Each snapshot has its own subdirectory named with the snapshot ID. Each snapshot has its own subdirectory named with the snapshot ID.
### Snapshot ID Format ### Snapshot ID Format
- **Format**: `<hostname>_<snapshot-name>_<RFC3339>` (or `<hostname>_<RFC3339>` if no - **Format**: `<hostname>-<YYYYMMDD>-<HHMMSSZ>`
name was specified) - **Example**: `laptop-20240115-143052Z`
- **Example**: `laptop_home_2024-01-15T14:30:52Z`
- **Components**: - **Components**:
- Short hostname (everything before the first dot is stripped from the FQDN) - Hostname (may contain hyphens)
- Snapshot name from the configured `snapshots:` map (optional) - Date in YYYYMMDD format
- RFC3339 UTC timestamp - Time in HHMMSSZ format (Z indicates UTC)
### Files in Each Snapshot Directory ### Files in Each Snapshot Directory
#### `db.zst.age` - Encrypted Database #### `db.zst.age` - Encrypted Database Dump
- **What it contains**: Pruned binary SQLite database for this snapshot - **What it contains**: Complete SQLite database dump for this snapshot
- **Format**: Binary SQLite → Zstandard compressed → Age encrypted - **Format**: SQL dump → Zstandard compressed → Age encrypted
- **Encryption**: Encrypted with Age - **Encryption**: Encrypted with Age
- **Purpose**: Contains full file metadata, chunk mappings, and all relationships - **Purpose**: Contains full file metadata, chunk mappings, and all relationships
- **Why encrypted**: Contains sensitive metadata like file paths, permissions, and ownership - **Why encrypted**: Contains sensitive metadata like file paths, permissions, and ownership
@@ -68,7 +67,7 @@ Each snapshot has its own subdirectory named with the snapshot ID.
- **Structure**: - **Structure**:
```json ```json
{ {
"snapshot_id": "laptop_home_2024-01-15T14:30:52Z", "snapshot_id": "laptop-20240115-143052Z",
"timestamp": "2024-01-15T14:30:52Z", "timestamp": "2024-01-15T14:30:52Z",
"blob_count": 42, "blob_count": 42,
"blobs": [ "blobs": [

7
go.mod
View File

@@ -1,4 +1,4 @@
module sneak.berlin/go/vaultik module git.eeqj.de/sneak/vaultik
go 1.26.1 go 1.26.1
@@ -13,11 +13,14 @@ require (
github.com/aws/aws-sdk-go-v2/service/s3 v1.90.0 github.com/aws/aws-sdk-go-v2/service/s3 v1.90.0
github.com/aws/smithy-go v1.23.2 github.com/aws/smithy-go v1.23.2
github.com/dustin/go-humanize v1.0.1 github.com/dustin/go-humanize v1.0.1
github.com/fsnotify/fsnotify v1.9.0
github.com/gobwas/glob v0.2.3 github.com/gobwas/glob v0.2.3
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/johannesboyne/gofakes3 v0.0.0-20250603205740-ed9094be7668 github.com/johannesboyne/gofakes3 v0.0.0-20250603205740-ed9094be7668
github.com/klauspost/compress v1.18.1 github.com/klauspost/compress v1.18.1
github.com/mattn/go-sqlite3 v1.14.29
github.com/rclone/rclone v1.72.1 github.com/rclone/rclone v1.72.1
github.com/schollz/progressbar/v3 v3.19.0
github.com/spf13/afero v1.15.0 github.com/spf13/afero v1.15.0
github.com/spf13/cobra v1.10.1 github.com/spf13/cobra v1.10.1
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
@@ -185,6 +188,7 @@ require (
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
@@ -215,6 +219,7 @@ require (
github.com/relvacode/iso8601 v1.7.0 // indirect github.com/relvacode/iso8601 v1.7.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rfjakob/eme v1.1.2 // indirect github.com/rfjakob/eme v1.1.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 // indirect github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 // indirect
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect

14
go.sum
View File

@@ -202,6 +202,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cevatbarisyilmaz/ara v0.0.4 h1:SGH10hXpBJhhTlObuZzTuFn1rrdmjQImITXnZVPSodc= github.com/cevatbarisyilmaz/ara v0.0.4 h1:SGH10hXpBJhhTlObuZzTuFn1rrdmjQImITXnZVPSodc=
github.com/cevatbarisyilmaz/ara v0.0.4/go.mod h1:BfFOxnUd6Mj6xmcvRxHN3Sr21Z1T3U2MYkYOmoQe4Ts= github.com/cevatbarisyilmaz/ara v0.0.4/go.mod h1:BfFOxnUd6Mj6xmcvRxHN3Sr21Z1T3U2MYkYOmoQe4Ts=
github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM=
github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY=
github.com/chilts/sid v0.0.0-20190607042430-660e94789ec9 h1:z0uK8UQqjMVYzvk4tiiu3obv2B44+XBsvgEJREQfnO8= github.com/chilts/sid v0.0.0-20190607042430-660e94789ec9 h1:z0uK8UQqjMVYzvk4tiiu3obv2B44+XBsvgEJREQfnO8=
github.com/chilts/sid v0.0.0-20190607042430-660e94789ec9/go.mod h1:Jl2neWsQaDanWORdqZ4emBl50J4/aRBBS4FyyG9/PFo= github.com/chilts/sid v0.0.0-20190607042430-660e94789ec9/go.mod h1:Jl2neWsQaDanWORdqZ4emBl50J4/aRBBS4FyyG9/PFo=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
@@ -284,8 +286,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/flynn/noise v1.1.0 h1:KjPQoQCEFdZDiP03phOvGi11+SVVhBG2wOWAorLsstg= github.com/flynn/noise v1.1.0 h1:KjPQoQCEFdZDiP03phOvGi11+SVVhBG2wOWAorLsstg=
github.com/flynn/noise v1.1.0/go.mod h1:xbMo+0i6+IGbYdJhF31t2eR1BIU0CYc12+BNAKwUTag= github.com/flynn/noise v1.1.0/go.mod h1:xbMo+0i6+IGbYdJhF31t2eR1BIU0CYc12+BNAKwUTag=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik= github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
@@ -591,12 +593,16 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-sqlite3 v1.14.29 h1:1O6nRLJKvsi1H2Sj0Hzdfojwt8GiGKm+LOfLaBFaouQ=
github.com/mattn/go-sqlite3 v1.14.29/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
github.com/miekg/dns v1.1.41 h1:WMszZWJG0XmzbK9FEmzH2TVcqYzFesusSIB41b8KHxY= github.com/miekg/dns v1.1.41 h1:WMszZWJG0XmzbK9FEmzH2TVcqYzFesusSIB41b8KHxY=
github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI=
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI= github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo=
@@ -701,6 +707,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rfjakob/eme v1.1.2 h1:SxziR8msSOElPayZNFfQw4Tjx/Sbaeeh3eRvrHVMUs4= github.com/rfjakob/eme v1.1.2 h1:SxziR8msSOElPayZNFfQw4Tjx/Sbaeeh3eRvrHVMUs4=
github.com/rfjakob/eme v1.1.2/go.mod h1:cVvpasglm/G3ngEfcfT/Wt0GwhkuO32pf/poW6Nyk1k= github.com/rfjakob/eme v1.1.2/go.mod h1:cVvpasglm/G3ngEfcfT/Wt0GwhkuO32pf/poW6Nyk1k=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
@@ -715,6 +723,8 @@ github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDj
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs= github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw= github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/schollz/progressbar/v3 v3.19.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc=
github.com/schollz/progressbar/v3 v3.19.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=

View File

@@ -2,18 +2,5 @@ package blob
import "errors" import "errors"
// ErrBlobSizeLimitExceeded is returned when adding a chunk would exceed // ErrBlobSizeLimitExceeded is returned when adding a chunk would exceed the blob size limit
// the blob size limit.
var ErrBlobSizeLimitExceeded = errors.New("adding chunk would exceed blob size limit") var ErrBlobSizeLimitExceeded = errors.New("adding chunk would exceed blob size limit")
// ErrNoRecipients is returned when a Packer is created without any age
// recipients; blobs must always be encrypted.
var ErrNoRecipients = errors.New("recipients are required - blobs must be encrypted")
// ErrInvalidMaxBlobSize is returned when the configured maximum blob size
// is zero or negative.
var ErrInvalidMaxBlobSize = errors.New("max blob size must be positive")
// ErrNoFilesystem is returned when a Packer is created without a filesystem
// for temporary files.
var ErrNoFilesystem = errors.New("filesystem is required")

View File

@@ -18,42 +18,34 @@ import (
"context" "context"
"database/sql" "database/sql"
"encoding/hex" "encoding/hex"
"errors"
"fmt" "fmt"
"io" "io"
"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"
) )
// Handler is a callback function invoked when a blob is finalized and // BlobHandler is a callback function invoked when a blob is finalized and ready for upload.
// ready for upload. The handler receives a WithReader containing the // The handler receives a BlobWithReader containing the blob metadata and a reader for
// blob metadata and a reader for the compressed and encrypted blob content. // the compressed and encrypted blob content. The handler is responsible for uploading
// The handler is responsible for uploading the blob to storage and cleaning // the blob to storage and cleaning up any temporary files.
// up any temporary files. type BlobHandler func(blob *BlobWithReader) error
type Handler func(blob *WithReader) error
// PackerConfig holds configuration for creating a Packer. // PackerConfig holds configuration for creating a Packer.
// All fields except BlobHandler are required. // All fields except BlobHandler are required.
type PackerConfig struct { type PackerConfig struct {
// MaxBlobSize is the maximum size of a blob before forcing finalization. MaxBlobSize int64 // Maximum size of a blob before forcing finalization
MaxBlobSize int64 CompressionLevel int // Zstd compression level (1-19, higher = better compression)
// CompressionLevel is the zstd level (1-19, higher = better compression). Recipients []string // Age recipients for encryption
CompressionLevel int Repositories *database.Repositories // Database repositories for tracking blob metadata
// Recipients holds the age recipients for encryption. BlobHandler BlobHandler // Optional callback when blob is ready for upload
Recipients []string Fs afero.Fs // Filesystem for temporary files
// Repositories provides database access for tracking blob metadata.
Repositories *database.Repositories
// BlobHandler is an optional callback when a blob is ready for upload.
BlobHandler Handler
// Fs is the filesystem used for temporary files.
Fs afero.Fs
} }
// PendingChunk represents a chunk waiting to be inserted into the database. // PendingChunk represents a chunk waiting to be inserted into the database.
@@ -69,7 +61,7 @@ type Packer struct {
maxBlobSize int64 maxBlobSize int64
compressionLevel int compressionLevel int
recipients []string // Age recipients for encryption recipients []string // Age recipients for encryption
blobHandler Handler // Called when blob is ready blobHandler BlobHandler // Called when blob is ready
repos *database.Repositories // For creating blob records repos *database.Repositories // For creating blob records
fs afero.Fs // Filesystem for temporary files fs afero.Fs // Filesystem for temporary files
@@ -116,23 +108,22 @@ type FinishedBlob struct {
ID string ID string
Hash string Hash string
Data []byte // Compressed data Data []byte // Compressed data
Chunks []*ChunkPosition Chunks []*BlobChunkRef
CreatedTS time.Time CreatedTS time.Time
Uncompressed int64 Uncompressed int64
Compressed int64 Compressed int64
} }
// ChunkPosition represents a chunk's position within a blob // BlobChunkRef represents a chunk's position within a blob
type ChunkPosition struct { type BlobChunkRef struct {
ChunkHash string ChunkHash string
Offset int64 Offset int64
Length int64 Length int64
} }
// WithReader wraps a FinishedBlob with its data reader // BlobWithReader wraps a FinishedBlob with its data reader
type WithReader struct { type BlobWithReader struct {
*FinishedBlob *FinishedBlob
Reader io.ReadSeeker Reader io.ReadSeeker
TempFile afero.File // Optional, only set for disk-based blobs TempFile afero.File // Optional, only set for disk-based blobs
InsertedChunkHashes []string // Chunk hashes that were inserted to DB with this blob InsertedChunkHashes []string // Chunk hashes that were inserted to DB with this blob
@@ -143,17 +134,14 @@ type WithReader struct {
// Returns an error if required configuration fields are missing or invalid. // Returns an error if required configuration fields are missing or invalid.
func NewPacker(cfg PackerConfig) (*Packer, error) { func NewPacker(cfg PackerConfig) (*Packer, error) {
if len(cfg.Recipients) == 0 { if len(cfg.Recipients) == 0 {
return nil, ErrNoRecipients return nil, fmt.Errorf("recipients are required - blobs must be encrypted")
} }
if cfg.MaxBlobSize <= 0 { if cfg.MaxBlobSize <= 0 {
return nil, ErrInvalidMaxBlobSize return nil, fmt.Errorf("max blob size must be positive")
} }
if cfg.Fs == nil { if cfg.Fs == nil {
return nil, ErrNoFilesystem return nil, fmt.Errorf("filesystem is required")
} }
return &Packer{ return &Packer{
maxBlobSize: cfg.MaxBlobSize, maxBlobSize: cfg.MaxBlobSize,
compressionLevel: cfg.CompressionLevel, compressionLevel: cfg.CompressionLevel,
@@ -169,10 +157,9 @@ func NewPacker(cfg PackerConfig) (*Packer, error) {
// The handler is responsible for uploading the blob to storage. // The handler is responsible for uploading the blob to storage.
// If no handler is set, finalized blobs are stored in memory and can be // If no handler is set, finalized blobs are stored in memory and can be
// retrieved with GetFinishedBlobs(). // retrieved with GetFinishedBlobs().
func (p *Packer) SetBlobHandler(handler Handler) { func (p *Packer) SetBlobHandler(handler BlobHandler) {
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock() defer p.mu.Unlock()
p.blobHandler = handler p.blobHandler = handler
} }
@@ -182,7 +169,6 @@ func (p *Packer) SetBlobHandler(handler Handler) {
func (p *Packer) AddPendingChunk(hash string, size int64) { func (p *Packer) AddPendingChunk(hash string, size int64) {
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock() defer p.mu.Unlock()
p.pendingChunks = append(p.pendingChunks, PendingChunk{Hash: hash, Size: size}) p.pendingChunks = append(p.pendingChunks, PendingChunk{Hash: hash, Size: size})
} }
@@ -191,14 +177,13 @@ func (p *Packer) AddPendingChunk(hash string, size int64) {
// In this case, the caller should finalize the current blob and retry. // In this case, the caller should finalize the current blob and retry.
// The chunk data is written immediately and can be garbage collected after this call. // The chunk data is written immediately and can be garbage collected after this call.
// Thread-safe. // Thread-safe.
func (p *Packer) AddChunk(ctx context.Context, chunk *ChunkRef) error { func (p *Packer) AddChunk(chunk *ChunkRef) error {
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock() defer p.mu.Unlock()
// Initialize new blob if needed // Initialize new blob if needed
if p.currentBlob == nil { if p.currentBlob == nil {
err := p.startNewBlob(ctx) if err := p.startNewBlob(); err != nil {
if err != nil {
return fmt.Errorf("starting new blob: %w", err) return fmt.Errorf("starting new blob: %w", err)
} }
} }
@@ -217,8 +202,7 @@ func (p *Packer) AddChunk(ctx context.Context, chunk *ChunkRef) error {
} }
// Add chunk to current blob // Add chunk to current blob
err := p.addChunkToCurrentBlob(chunk) if err := p.addChunkToCurrentBlob(chunk); err != nil {
if err != nil {
return err return err
} }
@@ -229,13 +213,12 @@ func (p *Packer) AddChunk(ctx context.Context, chunk *ChunkRef) error {
// This should be called after all chunks have been added to ensure no data is lost. // This should be called after all chunks have been added to ensure no data is lost.
// If a BlobHandler is set, it will be called with the finalized blob. // If a BlobHandler is set, it will be called with the finalized blob.
// Thread-safe. // Thread-safe.
func (p *Packer) Flush(ctx context.Context) error { func (p *Packer) Flush() error {
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock() defer p.mu.Unlock()
if p.currentBlob != nil && len(p.currentBlob.chunks) > 0 { if p.currentBlob != nil && len(p.currentBlob.chunks) > 0 {
err := p.finalizeCurrentBlob(ctx) if err := p.finalizeCurrentBlob(); err != nil {
if err != nil {
return fmt.Errorf("finalizing blob: %w", err) return fmt.Errorf("finalizing blob: %w", err)
} }
} }
@@ -249,7 +232,7 @@ func (p *Packer) Flush(ctx context.Context) error {
// BlobHandler (if set) or stored internally. // BlobHandler (if set) or stored internally.
// Caller must handle retrying any chunk that triggered size limit exceeded. // Caller must handle retrying any chunk that triggered size limit exceeded.
// Not thread-safe - caller must hold the lock. // Not thread-safe - caller must hold the lock.
func (p *Packer) FinalizeBlob(ctx context.Context) error { func (p *Packer) FinalizeBlob() error {
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock() defer p.mu.Unlock()
@@ -257,7 +240,7 @@ func (p *Packer) FinalizeBlob(ctx context.Context) error {
return nil return nil
} }
return p.finalizeCurrentBlob(ctx) return p.finalizeCurrentBlob()
} }
// GetFinishedBlobs returns all completed blobs and clears the internal list. // GetFinishedBlobs returns all completed blobs and clears the internal list.
@@ -270,37 +253,11 @@ func (p *Packer) GetFinishedBlobs() []*FinishedBlob {
blobs := p.finishedBlobs blobs := p.finishedBlobs
p.finishedBlobs = make([]*FinishedBlob, 0) p.finishedBlobs = make([]*FinishedBlob, 0)
return blobs return blobs
} }
// PackChunks is a convenience method to pack multiple chunks at once.
func (p *Packer) PackChunks(ctx context.Context, chunks []*ChunkRef) error {
for _, chunk := range chunks {
err := p.AddChunk(ctx, chunk)
if errors.Is(err, ErrBlobSizeLimitExceeded) {
// Finalize current blob and retry
err = p.FinalizeBlob(ctx)
if err != nil {
return fmt.Errorf("finalizing blob before retry: %w", err)
}
// Retry the chunk
err = p.AddChunk(ctx, chunk)
if err != nil {
return fmt.Errorf(
"adding chunk %s after finalize: %w", chunk.Hash, err)
}
} else if err != nil {
return fmt.Errorf("adding chunk %s: %w", chunk.Hash, err)
}
}
return p.Flush(ctx)
}
// startNewBlob initializes a new blob (must be called with lock held) // startNewBlob initializes a new blob (must be called with lock held)
func (p *Packer) startNewBlob(ctx context.Context) error { func (p *Packer) startNewBlob() error {
// Generate UUID for the blob // Generate UUID for the blob
blobID := uuid.New().String() blobID := uuid.New().String()
@@ -310,24 +267,18 @@ func (p *Packer) startNewBlob(ctx context.Context) error {
if err != nil { if err != nil {
return fmt.Errorf("parsing blob ID: %w", err) return fmt.Errorf("parsing blob ID: %w", err)
} }
blob := &database.Blob{ blob := &database.Blob{
ID: blobIDTyped, ID: blobIDTyped,
// Temporary placeholder hash until finalized. Hash: types.BlobHash("temp-placeholder-" + blobID), // Temporary placeholder until finalized
Hash: types.BlobHash("temp-placeholder-" + blobID),
CreatedTS: time.Now().UTC(), CreatedTS: time.Now().UTC(),
FinishedTS: nil, FinishedTS: nil,
UncompressedSize: 0, UncompressedSize: 0,
CompressedSize: 0, CompressedSize: 0,
UploadedTS: nil, UploadedTS: nil,
} }
if err := p.repos.WithTx(context.Background(), func(ctx context.Context, tx *sql.Tx) error {
err = p.repos.WithTx( return p.repos.Blobs.Create(ctx, tx, blob)
ctx, }); err != nil {
func(txCtx context.Context, tx *sql.Tx) error {
return p.repos.Blobs.Create(txCtx, tx, blob)
})
if err != nil {
return fmt.Errorf("creating blob record: %w", err) return fmt.Errorf("creating blob record: %w", err)
} }
} }
@@ -343,7 +294,6 @@ func (p *Packer) startNewBlob(ctx context.Context) error {
if err != nil { if err != nil {
_ = tempFile.Close() _ = tempFile.Close()
_ = p.fs.Remove(tempFile.Name()) _ = p.fs.Remove(tempFile.Name())
return fmt.Errorf("creating blobgen writer: %w", err) return fmt.Errorf("creating blobgen writer: %w", err)
} }
@@ -357,20 +307,15 @@ func (p *Packer) startNewBlob(ctx context.Context) error {
size: 0, size: 0,
} }
log.Debug("Created new blob container", log.Debug("Created new blob container", "blob_id", blobID, "temp_file", tempFile.Name())
"blob_id", blobID, "temp_file", tempFile.Name())
return nil return nil
} }
// addChunkToCurrentBlob adds a chunk to the current blob (must be called // addChunkToCurrentBlob adds a chunk to the current blob (must be called with lock held)
// with lock held).
func (p *Packer) addChunkToCurrentBlob(chunk *ChunkRef) error { func (p *Packer) addChunkToCurrentBlob(chunk *ChunkRef) error {
// Skip if chunk already in current blob // Skip if chunk already in current blob
if p.currentBlob.chunkSet[chunk.Hash] { if p.currentBlob.chunkSet[chunk.Hash] {
log.Debug("Skipping duplicate chunk already in current blob", log.Debug("Skipping duplicate chunk already in current blob", "chunk_hash", chunk.Hash)
"chunk_hash", chunk.Hash)
return nil return nil
} }
@@ -378,8 +323,7 @@ func (p *Packer) addChunkToCurrentBlob(chunk *ChunkRef) error {
offset := p.currentBlob.size offset := p.currentBlob.size
// Write to the blobgen writer (compression -> encryption -> disk) // Write to the blobgen writer (compression -> encryption -> disk)
_, err := p.currentBlob.writer.Write(chunk.Data) if _, err := p.currentBlob.writer.Write(chunk.Data); err != nil {
if err != nil {
return fmt.Errorf("writing to blob stream: %w", err) return fmt.Errorf("writing to blob stream: %w", err)
} }
@@ -412,7 +356,7 @@ func (p *Packer) addChunkToCurrentBlob(chunk *ChunkRef) error {
} }
// finalizeCurrentBlob completes the current blob (must be called with lock held) // finalizeCurrentBlob completes the current blob (must be called with lock held)
func (p *Packer) finalizeCurrentBlob(ctx context.Context) error { func (p *Packer) finalizeCurrentBlob() error {
if p.currentBlob == nil { if p.currentBlob == nil {
return nil return nil
} }
@@ -427,8 +371,7 @@ func (p *Packer) finalizeCurrentBlob(ctx context.Context) error {
chunksToInsert := p.pendingChunks chunksToInsert := p.pendingChunks
p.pendingChunks = nil p.pendingChunks = nil
err = p.commitBlobToDatabase(ctx, blobHash, finalSize, chunksToInsert) if err := p.commitBlobToDatabase(blobHash, finalSize, chunksToInsert); err != nil {
if err != nil {
return err return err
} }
@@ -456,59 +399,44 @@ func (p *Packer) finalizeCurrentBlob(ctx context.Context) error {
return p.deliverFinishedBlob(finished, insertedChunkHashes) return p.deliverFinishedBlob(finished, insertedChunkHashes)
} }
// closeBlobWriter closes the writer, syncs to disk, and returns the blob // closeBlobWriter closes the writer, syncs to disk, and returns the blob hash and final size
// hash and final size.
func (p *Packer) closeBlobWriter() (string, int64, error) { func (p *Packer) closeBlobWriter() (string, int64, error) {
err := p.currentBlob.writer.Close() if err := p.currentBlob.writer.Close(); err != nil {
if err != nil {
p.cleanupTempFile() p.cleanupTempFile()
return "", 0, fmt.Errorf("closing blobgen writer: %w", err) return "", 0, fmt.Errorf("closing blobgen writer: %w", err)
} }
if err := p.currentBlob.tempFile.Sync(); err != nil {
err = p.currentBlob.tempFile.Sync()
if err != nil {
p.cleanupTempFile() p.cleanupTempFile()
return "", 0, fmt.Errorf("syncing temp file: %w", err) return "", 0, fmt.Errorf("syncing temp file: %w", err)
} }
finalSize, err := p.currentBlob.tempFile.Seek(0, io.SeekCurrent) finalSize, err := p.currentBlob.tempFile.Seek(0, io.SeekCurrent)
if err != nil { if err != nil {
p.cleanupTempFile() p.cleanupTempFile()
return "", 0, fmt.Errorf("getting file size: %w", err) return "", 0, fmt.Errorf("getting file size: %w", err)
} }
if _, err := p.currentBlob.tempFile.Seek(0, io.SeekStart); err != nil {
_, err = p.currentBlob.tempFile.Seek(0, io.SeekStart)
if err != nil {
p.cleanupTempFile() p.cleanupTempFile()
return "", 0, fmt.Errorf("seeking to start: %w", err) return "", 0, fmt.Errorf("seeking to start: %w", err)
} }
finalHash := p.currentBlob.writer.Sum256() finalHash := p.currentBlob.writer.Sum256()
return hex.EncodeToString(finalHash), finalSize, nil return hex.EncodeToString(finalHash), finalSize, nil
} }
// buildChunkRefs creates ChunkPosition entries from the current blob's chunks // buildChunkRefs creates BlobChunkRef entries from the current blob's chunks
func (p *Packer) buildChunkRefs() []*ChunkPosition { func (p *Packer) buildChunkRefs() []*BlobChunkRef {
refs := make([]*ChunkPosition, 0, len(p.currentBlob.chunks)) refs := make([]*BlobChunkRef, 0, len(p.currentBlob.chunks))
for _, chunk := range p.currentBlob.chunks { for _, chunk := range p.currentBlob.chunks {
refs = append(refs, &ChunkPosition{ refs = append(refs, &BlobChunkRef{
ChunkHash: chunk.Hash, Offset: chunk.Offset, Length: chunk.Size, ChunkHash: chunk.Hash, Offset: chunk.Offset, Length: chunk.Size,
}) })
} }
return refs return refs
} }
// commitBlobToDatabase inserts pending chunks, blob_chunks, and updates the blob record // commitBlobToDatabase inserts pending chunks, blob_chunks, and updates the blob record
func (p *Packer) commitBlobToDatabase( func (p *Packer) commitBlobToDatabase(blobHash string, finalSize int64, chunksToInsert []PendingChunk) error {
ctx context.Context,
blobHash string, finalSize int64, chunksToInsert []PendingChunk,
) error {
if p.repos == nil { if p.repos == nil {
return nil return nil
} }
@@ -516,46 +444,13 @@ func (p *Packer) commitBlobToDatabase(
blobIDTyped, parseErr := types.ParseBlobID(p.currentBlob.id) blobIDTyped, parseErr := types.ParseBlobID(p.currentBlob.id)
if parseErr != nil { if parseErr != nil {
p.cleanupTempFile() p.cleanupTempFile()
return fmt.Errorf("parsing blob ID: %w", parseErr) return fmt.Errorf("parsing blob ID: %w", parseErr)
} }
err := p.repos.WithTx( err := p.repos.WithTx(context.Background(), func(ctx context.Context, tx *sql.Tx) error {
ctx,
func(txCtx context.Context, tx *sql.Tx) error {
return p.insertBlobRecords(txCtx, tx, blobIDTyped, blobHash,
finalSize, chunksToInsert)
})
if err != nil {
p.cleanupTempFile()
return fmt.Errorf("finalizing blob transaction: %w", err)
}
log.Debug("Committed blob transaction",
"chunks_inserted", len(chunksToInsert),
"blob_chunks_inserted", len(p.currentBlob.chunks))
return nil
}
// insertBlobRecords inserts pending chunks and blob_chunk rows, then marks
// the blob finished, all within the supplied transaction.
func (p *Packer) insertBlobRecords(
ctx context.Context,
tx *sql.Tx,
blobIDTyped types.BlobID,
blobHash string,
finalSize int64,
chunksToInsert []PendingChunk,
) error {
for _, chunk := range chunksToInsert { for _, chunk := range chunksToInsert {
dbChunk := &database.Chunk{ dbChunk := &database.Chunk{ChunkHash: types.ChunkHash(chunk.Hash), Size: chunk.Size}
ChunkHash: types.ChunkHash(chunk.Hash), Size: chunk.Size, if err := p.repos.Chunks.Create(ctx, tx, dbChunk); err != nil {
}
err := p.repos.Chunks.Create(ctx, tx, dbChunk)
if err != nil {
return fmt.Errorf("creating chunk: %w", err) return fmt.Errorf("creating chunk: %w", err)
} }
} }
@@ -565,70 +460,62 @@ func (p *Packer) insertBlobRecords(
BlobID: blobIDTyped, ChunkHash: types.ChunkHash(chunk.Hash), BlobID: blobIDTyped, ChunkHash: types.ChunkHash(chunk.Hash),
Offset: chunk.Offset, Length: chunk.Size, Offset: chunk.Offset, Length: chunk.Size,
} }
if err := p.repos.BlobChunks.Create(ctx, tx, blobChunk); err != nil {
err := p.repos.BlobChunks.Create(ctx, tx, blobChunk)
if err != nil {
return fmt.Errorf("creating blob_chunk: %w", err) return fmt.Errorf("creating blob_chunk: %w", err)
} }
} }
return p.repos.Blobs.UpdateFinished(ctx, tx, p.currentBlob.id, blobHash, return p.repos.Blobs.UpdateFinished(ctx, tx, p.currentBlob.id, blobHash, p.currentBlob.size, finalSize)
p.currentBlob.size, finalSize) })
if err != nil {
p.cleanupTempFile()
return fmt.Errorf("finalizing blob transaction: %w", err)
}
log.Debug("Committed blob transaction",
"chunks_inserted", len(chunksToInsert), "blob_chunks_inserted", len(p.currentBlob.chunks))
return nil
} }
// deliverFinishedBlob passes the blob to the handler or stores it internally // deliverFinishedBlob passes the blob to the handler or stores it internally
func (p *Packer) deliverFinishedBlob( func (p *Packer) deliverFinishedBlob(finished *FinishedBlob, insertedChunkHashes []string) error {
finished *FinishedBlob, insertedChunkHashes []string,
) error {
if p.blobHandler != nil { if p.blobHandler != nil {
_, err := p.currentBlob.tempFile.Seek(0, io.SeekStart) if _, err := p.currentBlob.tempFile.Seek(0, io.SeekStart); err != nil {
if err != nil {
p.cleanupTempFile() p.cleanupTempFile()
return fmt.Errorf("seeking for handler: %w", err) return fmt.Errorf("seeking for handler: %w", err)
} }
blobWithReader := &WithReader{ blobWithReader := &BlobWithReader{
FinishedBlob: finished, FinishedBlob: finished,
Reader: p.currentBlob.tempFile, Reader: p.currentBlob.tempFile,
TempFile: p.currentBlob.tempFile, TempFile: p.currentBlob.tempFile,
InsertedChunkHashes: insertedChunkHashes, InsertedChunkHashes: insertedChunkHashes,
} }
err = p.blobHandler(blobWithReader) if err := p.blobHandler(blobWithReader); err != nil {
if err != nil {
p.cleanupTempFile() p.cleanupTempFile()
return fmt.Errorf("blob handler failed: %w", err) return fmt.Errorf("blob handler failed: %w", err)
} }
p.currentBlob = nil p.currentBlob = nil
return nil return nil
} }
// No handler - read data for legacy behavior // No handler - read data for legacy behavior
log.Debug("No blob handler callback configured", "blob_hash", finished.Hash[:8]+"...") log.Debug("No blob handler callback configured", "blob_hash", finished.Hash[:8]+"...")
if _, err := p.currentBlob.tempFile.Seek(0, io.SeekStart); err != nil {
_, err := p.currentBlob.tempFile.Seek(0, io.SeekStart)
if err != nil {
p.cleanupTempFile() p.cleanupTempFile()
return fmt.Errorf("seeking to read data: %w", err) return fmt.Errorf("seeking to read data: %w", err)
} }
data, err := io.ReadAll(p.currentBlob.tempFile) data, err := io.ReadAll(p.currentBlob.tempFile)
if err != nil { if err != nil {
p.cleanupTempFile() p.cleanupTempFile()
return fmt.Errorf("reading blob data: %w", err) return fmt.Errorf("reading blob data: %w", err)
} }
finished.Data = data finished.Data = data
p.finishedBlobs = append(p.finishedBlobs, finished) p.finishedBlobs = append(p.finishedBlobs, finished)
p.cleanupTempFile() p.cleanupTempFile()
p.currentBlob = nil p.currentBlob = nil
return nil return nil
} }
@@ -640,3 +527,24 @@ func (p *Packer) cleanupTempFile() {
_ = p.fs.Remove(name) _ = p.fs.Remove(name)
} }
} }
// PackChunks is a convenience method to pack multiple chunks at once
func (p *Packer) PackChunks(chunks []*ChunkRef) error {
for _, chunk := range chunks {
err := p.AddChunk(chunk)
if err == ErrBlobSizeLimitExceeded {
// Finalize current blob and retry
if err := p.FinalizeBlob(); err != nil {
return fmt.Errorf("finalizing blob before retry: %w", err)
}
// Retry the chunk
if err := p.AddChunk(chunk); err != nil {
return fmt.Errorf("adding chunk %s after finalize: %w", chunk.Hash, err)
}
} else if err != nil {
return fmt.Errorf("adding chunk %s: %w", chunk.Hash, err)
}
}
return p.Flush()
}

View File

@@ -1,4 +1,4 @@
package blob_test package blob
import ( import (
"bytes" "bytes"
@@ -6,113 +6,107 @@ import (
"crypto/sha256" "crypto/sha256"
"database/sql" "database/sql"
"encoding/hex" "encoding/hex"
"errors"
"io" "io"
"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/blob"
"sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/types"
) )
const ( const (
// Test key from test/insecure-integration-test.key // Test key from test/insecure-integration-test.key
testPrivateKey = "AGE-SECRET-KEY-19CR5YSFW59HM4TLD6GXVEDMZFTVVF7PPHKUT68TXSFPK7A" + testPrivateKey = "AGE-SECRET-KEY-19CR5YSFW59HM4TLD6GXVEDMZFTVVF7PPHKUT68TXSFPK7APHXA2QS2NJA5"
"PHXA2QS2NJA5"
testPublicKey = "age1ezrjmfpwsc95svdg0y54mums3zevgzu0x0ecq2f7tp8a05gl0sjq9q9wjg" testPublicKey = "age1ezrjmfpwsc95svdg0y54mums3zevgzu0x0ecq2f7tp8a05gl0sjq9q9wjg"
defaultMaxBlobSize = 10 * 1024 * 1024 // 10MB
testChunkSize = 1000
testChunkCount = 10
) )
// parseTestIdentity parses the fixed test age identity. func TestPacker(t *testing.T) {
func parseTestIdentity(t *testing.T) *age.X25519Identity { // Initialize logger for tests
t.Helper() log.Initialize(log.Config{})
// Parse test identity
identity, err := age.ParseX25519Identity(testPrivateKey) identity, err := age.ParseX25519Identity(testPrivateKey)
if err != nil { if err != nil {
t.Fatalf("failed to parse test identity: %v", err) t.Fatalf("failed to parse test identity: %v", err)
} }
return identity t.Run("single chunk creates single blob", func(t *testing.T) {
} // Create test database
// newTestPacker creates a test database and a Packer backed by it.
func newTestPacker(
t *testing.T, maxBlobSize int64,
) (*database.Repositories, *blob.Packer) {
t.Helper()
db, err := database.NewTestDB() db, err := database.NewTestDB()
if err != nil { if err != nil {
t.Fatalf("failed to create test db: %v", err) t.Fatalf("failed to create test db: %v", err)
} }
defer func() { _ = db.Close() }()
t.Cleanup(func() { _ = db.Close() })
repos := database.NewRepositories(db) repos := database.NewRepositories(db)
packer, err := blob.NewPacker(blob.PackerConfig{ cfg := PackerConfig{
MaxBlobSize: maxBlobSize, MaxBlobSize: 10 * 1024 * 1024, // 10MB
CompressionLevel: 3, CompressionLevel: 3,
Recipients: []string{testPublicKey}, Recipients: []string{testPublicKey},
Repositories: repos, Repositories: repos,
Fs: afero.NewMemMapFs(), Fs: afero.NewMemMapFs(),
}) }
packer, err := NewPacker(cfg)
if err != nil { if err != nil {
t.Fatalf("failed to create packer: %v", err) t.Fatalf("failed to create packer: %v", err)
} }
return repos, packer // Create a chunk
} data := []byte("Hello, World!")
// makeChunk creates a ChunkRef for data and registers the chunk in the
// database.
func makeChunk(
t *testing.T, repos *database.Repositories, data []byte,
) *blob.ChunkRef {
t.Helper()
hash := sha256.Sum256(data) hash := sha256.Sum256(data)
hashStr := hex.EncodeToString(hash[:]) hashStr := hex.EncodeToString(hash[:])
// Create chunk in database first
dbChunk := &database.Chunk{ dbChunk := &database.Chunk{
ChunkHash: types.ChunkHash(hashStr), ChunkHash: types.ChunkHash(hashStr),
Size: int64(len(data)), Size: int64(len(data)),
} }
err = repos.WithTx(context.Background(), func(ctx context.Context, tx *sql.Tx) error {
err := repos.WithTx(
context.Background(),
func(ctx context.Context, tx *sql.Tx) error {
return repos.Chunks.Create(ctx, tx, dbChunk) return repos.Chunks.Create(ctx, tx, dbChunk)
}) })
if err != nil { if err != nil {
t.Fatalf("failed to create chunk in db: %v", err) t.Fatalf("failed to create chunk in db: %v", err)
} }
return &blob.ChunkRef{ chunk := &ChunkRef{
Hash: hashStr, Hash: hashStr,
Data: data, Data: data,
} }
}
// decryptAndDecompress reverses the blob pipeline: age decrypt, then zstd // Add chunk
// decompress. if err := packer.AddChunk(chunk); err != nil {
func decryptAndDecompress( t.Fatalf("failed to add chunk: %v", err)
t *testing.T, blobData []byte, identity *age.X25519Identity, }
) []byte {
t.Helper()
decrypted, err := age.Decrypt(bytes.NewReader(blobData), identity) // Flush
if err := packer.Flush(); err != nil {
t.Fatalf("failed to flush: %v", err)
}
// Get finished blobs
blobs := packer.GetFinishedBlobs()
if len(blobs) != 1 {
t.Fatalf("expected 1 blob, got %d", len(blobs))
}
blob := blobs[0]
if len(blob.Chunks) != 1 {
t.Errorf("expected 1 chunk in blob, got %d", len(blob.Chunks))
}
// Note: Very small data may not compress well
t.Logf("Compression: %d -> %d bytes", blob.Uncompressed, blob.Compressed)
// Decrypt the blob data
decrypted, err := age.Decrypt(bytes.NewReader(blob.Data), identity)
if err != nil { if err != nil {
t.Fatalf("failed to decrypt blob: %v", err) t.Fatalf("failed to decrypt blob: %v", err)
} }
// Decompress the decrypted data
reader, err := zstd.NewReader(decrypted) reader, err := zstd.NewReader(decrypted)
if err != nil { if err != nil {
t.Fatalf("failed to create decompressor: %v", err) t.Fatalf("failed to create decompressor: %v", err)
@@ -120,144 +114,157 @@ func decryptAndDecompress(
defer reader.Close() defer reader.Close()
var decompressed bytes.Buffer var decompressed bytes.Buffer
if _, err := io.Copy(&decompressed, reader); err != nil {
_, err = io.Copy(&decompressed, reader)
if err != nil {
t.Fatalf("failed to decompress: %v", err) t.Fatalf("failed to decompress: %v", err)
} }
return decompressed.Bytes() if !bytes.Equal(decompressed.Bytes(), data) {
}
func TestPackerSingleChunk(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
identity := parseTestIdentity(t)
repos, packer := newTestPacker(t, defaultMaxBlobSize)
ctx := context.Background()
data := []byte("Hello, World!")
chunk := makeChunk(t, repos, data)
err := packer.AddChunk(ctx, chunk)
if err != nil {
t.Fatalf("failed to add chunk: %v", err)
}
err = packer.Flush(ctx)
if err != nil {
t.Fatalf("failed to flush: %v", err)
}
blobs := packer.GetFinishedBlobs()
if len(blobs) != 1 {
t.Fatalf("expected 1 blob, got %d", len(blobs))
}
finished := blobs[0]
if len(finished.Chunks) != 1 {
t.Errorf("expected 1 chunk in blob, got %d", len(finished.Chunks))
}
// Note: Very small data may not compress well
t.Logf("Compression: %d -> %d bytes",
finished.Uncompressed, finished.Compressed)
decompressed := decryptAndDecompress(t, finished.Data, identity)
if !bytes.Equal(decompressed, data) {
t.Error("decompressed data doesn't match original") t.Error("decompressed data doesn't match original")
} }
} })
func TestPackerMultipleChunks(t *testing.T) { t.Run("multiple chunks packed together", func(t *testing.T) {
log.Initialize(log.Config{}) // Create test database
t.Parallel() db, err := database.NewTestDB()
if err != nil {
t.Fatalf("failed to create test db: %v", err)
}
defer func() { _ = db.Close() }()
repos := database.NewRepositories(db)
repos, packer := newTestPacker(t, defaultMaxBlobSize) cfg := PackerConfig{
ctx := context.Background() MaxBlobSize: 10 * 1024 * 1024, // 10MB
CompressionLevel: 3,
chunks := make([]*blob.ChunkRef, testChunkCount) Recipients: []string{testPublicKey},
for i := range testChunkCount { Repositories: repos,
data := bytes.Repeat([]byte{byte(i)}, testChunkSize) Fs: afero.NewMemMapFs(),
chunks[i] = makeChunk(t, repos, data) }
packer, err := NewPacker(cfg)
if err != nil {
t.Fatalf("failed to create packer: %v", err)
} }
// Create multiple small chunks
chunks := make([]*ChunkRef, 10)
for i := 0; i < 10; i++ {
data := bytes.Repeat([]byte{byte(i)}, 1000)
hash := sha256.Sum256(data)
hashStr := hex.EncodeToString(hash[:])
// Create chunk in database first
dbChunk := &database.Chunk{
ChunkHash: types.ChunkHash(hashStr),
Size: int64(len(data)),
}
err = repos.WithTx(context.Background(), func(ctx context.Context, tx *sql.Tx) error {
return repos.Chunks.Create(ctx, tx, dbChunk)
})
if err != nil {
t.Fatalf("failed to create chunk in db: %v", err)
}
chunks[i] = &ChunkRef{
Hash: hashStr,
Data: data,
}
}
// Add all chunks
for _, chunk := range chunks { for _, chunk := range chunks {
err := packer.AddChunk(ctx, chunk) err := packer.AddChunk(chunk)
if err != nil { if err != nil {
t.Fatalf("failed to add chunk: %v", err) t.Fatalf("failed to add chunk: %v", err)
} }
} }
err := packer.Flush(ctx) // Flush
if err != nil { if err := packer.Flush(); err != nil {
t.Fatalf("failed to flush: %v", err) t.Fatalf("failed to flush: %v", err)
} }
// Should have one blob with all chunks
blobs := packer.GetFinishedBlobs() blobs := packer.GetFinishedBlobs()
if len(blobs) != 1 { if len(blobs) != 1 {
t.Fatalf("expected 1 blob, got %d", len(blobs)) t.Fatalf("expected 1 blob, got %d", len(blobs))
} }
if len(blobs[0].Chunks) != testChunkCount { if len(blobs[0].Chunks) != 10 {
t.Errorf("expected %d chunks in blob, got %d", t.Errorf("expected 10 chunks in blob, got %d", len(blobs[0].Chunks))
testChunkCount, len(blobs[0].Chunks))
} }
// Verify offsets are correct // Verify offsets are correct
expectedOffset := int64(0) expectedOffset := int64(0)
for i, chunkRef := range blobs[0].Chunks { for i, chunkRef := range blobs[0].Chunks {
if chunkRef.Offset != expectedOffset { if chunkRef.Offset != expectedOffset {
t.Errorf("chunk %d: expected offset %d, got %d", t.Errorf("chunk %d: expected offset %d, got %d", i, expectedOffset, chunkRef.Offset)
i, expectedOffset, chunkRef.Offset)
} }
if chunkRef.Length != 1000 {
if chunkRef.Length != testChunkSize { t.Errorf("chunk %d: expected length 1000, got %d", i, chunkRef.Length)
t.Errorf("chunk %d: expected length %d, got %d",
i, testChunkSize, chunkRef.Length)
} }
expectedOffset += chunkRef.Length expectedOffset += chunkRef.Length
} }
} })
func TestPackerSizeLimit(t *testing.T) { t.Run("blob size limit enforced", func(t *testing.T) {
log.Initialize(log.Config{}) // Create test database
t.Parallel() db, err := database.NewTestDB()
if err != nil {
t.Fatalf("failed to create test db: %v", err)
}
defer func() { _ = db.Close() }()
repos := database.NewRepositories(db)
const ( // Small blob size limit to force multiple blobs
maxBlobSize = 5000 // 5KB max, forces multiple blobs cfg := PackerConfig{
maxBlobawoOverhead = 6000 // allow some overhead over the limit MaxBlobSize: 5000, // 5KB max
) CompressionLevel: 3,
Recipients: []string{testPublicKey},
Repositories: repos,
Fs: afero.NewMemMapFs(),
}
packer, err := NewPacker(cfg)
if err != nil {
t.Fatalf("failed to create packer: %v", err)
}
repos, packer := newTestPacker(t, maxBlobSize) // Create chunks that will exceed the limit
ctx := context.Background() chunks := make([]*ChunkRef, 10)
for i := 0; i < 10; i++ {
data := bytes.Repeat([]byte{byte(i)}, 1000) // 1KB each
hash := sha256.Sum256(data)
hashStr := hex.EncodeToString(hash[:])
chunks := make([]*blob.ChunkRef, testChunkCount) // Create chunk in database first
for i := range testChunkCount { dbChunk := &database.Chunk{
data := bytes.Repeat([]byte{byte(i)}, testChunkSize) // 1KB each ChunkHash: types.ChunkHash(hashStr),
chunks[i] = makeChunk(t, repos, data) Size: int64(len(data)),
}
err = repos.WithTx(context.Background(), func(ctx context.Context, tx *sql.Tx) error {
return repos.Chunks.Create(ctx, tx, dbChunk)
})
if err != nil {
t.Fatalf("failed to create chunk in db: %v", err)
}
chunks[i] = &ChunkRef{
Hash: hashStr,
Data: data,
}
} }
blobCount := 0 blobCount := 0
// Add chunks and handle size limit errors // Add chunks and handle size limit errors
for _, chunk := range chunks { for _, chunk := range chunks {
err := packer.AddChunk(ctx, chunk) err := packer.AddChunk(chunk)
if errors.Is(err, blob.ErrBlobSizeLimitExceeded) { if err == ErrBlobSizeLimitExceeded {
// Finalize current blob // Finalize current blob
err = packer.FinalizeBlob(ctx) if err := packer.FinalizeBlob(); err != nil {
if err != nil {
t.Fatalf("failed to finalize blob: %v", err) t.Fatalf("failed to finalize blob: %v", err)
} }
blobCount++ blobCount++
// Retry adding the chunk // Retry adding the chunk
err = packer.AddChunk(ctx, chunk) if err := packer.AddChunk(chunk); err != nil {
if err != nil {
t.Fatalf("failed to add chunk after finalize: %v", err) t.Fatalf("failed to add chunk after finalize: %v", err)
} }
} else if err != nil { } else if err != nil {
@@ -265,54 +272,114 @@ func TestPackerSizeLimit(t *testing.T) {
} }
} }
err := packer.Flush(ctx) // Flush remaining
if err != nil { if err := packer.Flush(); err != nil {
t.Fatalf("failed to flush: %v", err) t.Fatalf("failed to flush: %v", err)
} }
// Get all blobs
blobs := packer.GetFinishedBlobs() blobs := packer.GetFinishedBlobs()
totalBlobs := blobCount + len(blobs) totalBlobs := blobCount + len(blobs)
// Should have multiple blobs due to size limit
if totalBlobs < 2 { if totalBlobs < 2 {
t.Errorf("expected multiple blobs due to size limit, got %d", totalBlobs) t.Errorf("expected multiple blobs due to size limit, got %d", totalBlobs)
} }
// Verify each blob respects size limit (approximately) // Verify each blob respects size limit (approximately)
for _, finished := range blobs { for _, blob := range blobs {
if finished.Compressed > maxBlobawoOverhead { if blob.Compressed > 6000 { // Allow some overhead
t.Errorf("blob size %d exceeds limit", finished.Compressed) t.Errorf("blob size %d exceeds limit", blob.Compressed)
} }
} }
} })
func TestPackerEncryption(t *testing.T) { t.Run("with encryption", func(t *testing.T) {
log.Initialize(log.Config{}) // Create test database
t.Parallel() db, err := database.NewTestDB()
identity := parseTestIdentity(t)
repos, packer := newTestPacker(t, defaultMaxBlobSize)
ctx := context.Background()
data := bytes.Repeat([]byte("Test data for encryption!"), 100)
chunk := makeChunk(t, repos, data)
err := packer.AddChunk(ctx, chunk)
if err != nil { if err != nil {
t.Fatalf("failed to create test db: %v", err)
}
defer func() { _ = db.Close() }()
repos := database.NewRepositories(db)
// Generate test identity (using the one from parent test)
cfg := PackerConfig{
MaxBlobSize: 10 * 1024 * 1024, // 10MB
CompressionLevel: 3,
Recipients: []string{testPublicKey},
Repositories: repos,
Fs: afero.NewMemMapFs(),
}
packer, err := NewPacker(cfg)
if err != nil {
t.Fatalf("failed to create packer: %v", err)
}
// Create test data
data := bytes.Repeat([]byte("Test data for encryption!"), 100)
hash := sha256.Sum256(data)
hashStr := hex.EncodeToString(hash[:])
// Create chunk in database first
dbChunk := &database.Chunk{
ChunkHash: types.ChunkHash(hashStr),
Size: int64(len(data)),
}
err = repos.WithTx(context.Background(), func(ctx context.Context, tx *sql.Tx) error {
return repos.Chunks.Create(ctx, tx, dbChunk)
})
if err != nil {
t.Fatalf("failed to create chunk in db: %v", err)
}
chunk := &ChunkRef{
Hash: hashStr,
Data: data,
}
// Add chunk and flush
if err := packer.AddChunk(chunk); err != nil {
t.Fatalf("failed to add chunk: %v", err) t.Fatalf("failed to add chunk: %v", err)
} }
if err := packer.Flush(); err != nil {
err = packer.Flush(ctx)
if err != nil {
t.Fatalf("failed to flush: %v", err) t.Fatalf("failed to flush: %v", err)
} }
// Get blob
blobs := packer.GetFinishedBlobs() blobs := packer.GetFinishedBlobs()
if len(blobs) != 1 { if len(blobs) != 1 {
t.Fatalf("expected 1 blob, got %d", len(blobs)) t.Fatalf("expected 1 blob, got %d", len(blobs))
} }
decompressed := decryptAndDecompress(t, blobs[0].Data, identity) blob := blobs[0]
if !bytes.Equal(decompressed, data) {
// Decrypt the blob
decrypted, err := age.Decrypt(bytes.NewReader(blob.Data), identity)
if err != nil {
t.Fatalf("failed to decrypt blob: %v", err)
}
var decryptedData bytes.Buffer
if _, err := decryptedData.ReadFrom(decrypted); err != nil {
t.Fatalf("failed to read decrypted data: %v", err)
}
// Decompress
reader, err := zstd.NewReader(&decryptedData)
if err != nil {
t.Fatalf("failed to create decompressor: %v", err)
}
defer reader.Close()
var decompressed bytes.Buffer
if _, err := decompressed.ReadFrom(reader); err != nil {
t.Fatalf("failed to decompress: %v", err)
}
// Verify data
if !bytes.Equal(decompressed.Bytes(), data) {
t.Error("decrypted and decompressed data doesn't match original") t.Error("decrypted and decompressed data doesn't match original")
} }
})
} }

View File

@@ -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 package blobgen
import ( import (
@@ -19,9 +16,7 @@ type CompressResult struct {
} }
// CompressData compresses and encrypts data, returning the result with hash // CompressData compresses and encrypts data, returning the result with hash
func CompressData( func CompressData(data []byte, compressionLevel int, recipients []string) (*CompressResult, error) {
data []byte, compressionLevel int, recipients []string,
) (*CompressResult, error) {
var buf bytes.Buffer var buf bytes.Buffer
// Create writer // Create writer
@@ -31,16 +26,13 @@ func CompressData(
} }
// Write data // Write data
_, err = w.Write(data) if _, err := w.Write(data); err != nil {
if err != nil {
_ = w.Close() _ = w.Close()
return nil, fmt.Errorf("writing data: %w", err) return nil, fmt.Errorf("writing data: %w", err)
} }
// Close to flush // Close to flush
err = w.Close() if err := w.Close(); err != nil {
if err != nil {
return nil, fmt.Errorf("closing writer: %w", err) return nil, fmt.Errorf("closing writer: %w", err)
} }
@@ -52,11 +44,8 @@ func CompressData(
}, nil }, nil
} }
// CompressStream compresses and encrypts from reader to writer, returning // CompressStream compresses and encrypts from reader to writer, returning hash
// the number of uncompressed bytes written and the content hash. func CompressStream(dst io.Writer, src io.Reader, compressionLevel int, recipients []string) (written int64, hash string, err error) {
func CompressStream(
dst io.Writer, src io.Reader, compressionLevel int, recipients []string,
) (int64, string, error) {
// Create writer // Create writer
w, err := NewWriter(dst, compressionLevel, recipients) w, err := NewWriter(dst, compressionLevel, recipients)
if err != nil { if err != nil {
@@ -64,7 +53,6 @@ func CompressStream(
} }
closed := false closed := false
defer func() { defer func() {
if !closed { if !closed {
_ = w.Close() _ = w.Close()
@@ -72,17 +60,14 @@ func CompressStream(
}() }()
// Copy data // Copy data
_, err = io.Copy(w, src) if _, err := io.Copy(w, src); err != nil {
if err != nil {
return 0, "", fmt.Errorf("copying data: %w", err) return 0, "", fmt.Errorf("copying data: %w", err)
} }
// Close to flush // Close to flush
err = w.Close() if err := w.Close(); err != nil {
if err != nil {
return 0, "", fmt.Errorf("closing writer: %w", err) return 0, "", fmt.Errorf("closing writer: %w", err)
} }
closed = true closed = true
return w.BytesWritten(), hex.EncodeToString(w.Sum256()), nil return w.BytesWritten(), hex.EncodeToString(w.Sum256()), nil

View File

@@ -1,4 +1,4 @@
package blobgen_test package blobgen
import ( import (
"bytes" "bytes"
@@ -8,7 +8,6 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/blobgen"
) )
// testRecipient is a static age recipient for tests. // testRecipient is a static age recipient for tests.
@@ -20,47 +19,35 @@ const testRecipient = "age1cplgrwj77ta54dnmydvvmzn64ltk83ankxl5sww04mrtmu62kv3s8
// the explicit Close() on the happy path combined with defer Close() would // the explicit Close() on the happy path combined with defer Close() would
// cause a double close. // cause a double close.
func TestCompressStreamNoDoubleClose(t *testing.T) { func TestCompressStreamNoDoubleClose(t *testing.T) {
t.Parallel()
input := []byte("regression test data for issue #28 double-close fix") input := []byte("regression test data for issue #28 double-close fix")
var buf bytes.Buffer var buf bytes.Buffer
written, hash, err := blobgen.CompressStream( written, hash, err := CompressStream(&buf, bytes.NewReader(input), 3, []string{testRecipient})
&buf, bytes.NewReader(input), 3, []string{testRecipient})
require.NoError(t, err, "CompressStream should not return an error") require.NoError(t, err, "CompressStream should not return an error")
assert.Positive(t, written, "expected bytes written > 0") assert.True(t, written > 0, "expected bytes written > 0")
assert.NotEmpty(t, hash, "expected non-empty hash") assert.NotEmpty(t, hash, "expected non-empty hash")
assert.Positive(t, buf.Len(), "expected non-empty output") assert.True(t, buf.Len() > 0, "expected non-empty output")
} }
// TestCompressStreamLargeInput exercises CompressStream with a larger payload // TestCompressStreamLargeInput exercises CompressStream with a larger payload
// to ensure no double-close issues surface under heavier I/O. // to ensure no double-close issues surface under heavier I/O.
func TestCompressStreamLargeInput(t *testing.T) { func TestCompressStreamLargeInput(t *testing.T) {
t.Parallel()
data := make([]byte, 512*1024) // 512 KB data := make([]byte, 512*1024) // 512 KB
_, err := rand.Read(data) _, err := rand.Read(data)
require.NoError(t, err) require.NoError(t, err)
var buf bytes.Buffer var buf bytes.Buffer
written, hash, err := CompressStream(&buf, bytes.NewReader(data), 3, []string{testRecipient})
written, hash, err := blobgen.CompressStream(
&buf, bytes.NewReader(data), 3, []string{testRecipient})
require.NoError(t, err) require.NoError(t, err)
assert.Positive(t, written) assert.True(t, written > 0)
assert.NotEmpty(t, hash) assert.NotEmpty(t, hash)
} }
// TestCompressStreamEmptyInput verifies CompressStream handles empty input // TestCompressStreamEmptyInput verifies CompressStream handles empty input
// without double-close issues. // without double-close issues.
func TestCompressStreamEmptyInput(t *testing.T) { func TestCompressStreamEmptyInput(t *testing.T) {
t.Parallel()
var buf bytes.Buffer var buf bytes.Buffer
_, hash, err := CompressStream(&buf, strings.NewReader(""), 3, []string{testRecipient})
_, hash, err := blobgen.CompressStream(
&buf, strings.NewReader(""), 3, []string{testRecipient})
require.NoError(t, err) require.NoError(t, err)
assert.NotEmpty(t, hash) assert.NotEmpty(t, hash)
} }
@@ -68,13 +55,10 @@ func TestCompressStreamEmptyInput(t *testing.T) {
// TestCompressDataNoDoubleClose mirrors the stream test for CompressData, // TestCompressDataNoDoubleClose mirrors the stream test for CompressData,
// ensuring the explicit Close + error-path Close pattern is also safe. // ensuring the explicit Close + error-path Close pattern is also safe.
func TestCompressDataNoDoubleClose(t *testing.T) { func TestCompressDataNoDoubleClose(t *testing.T) {
t.Parallel()
input := []byte("CompressData regression test for double-close") input := []byte("CompressData regression test for double-close")
result, err := CompressData(input, 3, []string{testRecipient})
result, err := blobgen.CompressData(input, 3, []string{testRecipient})
require.NoError(t, err) require.NoError(t, err)
assert.Positive(t, result.CompressedSize) assert.True(t, result.CompressedSize > 0)
assert.Equal(t, result.UncompressedSize, int64(len(input))) assert.True(t, result.UncompressedSize == int64(len(input)))
assert.NotEmpty(t, result.SHA256) assert.NotEmpty(t, result.SHA256)
} }

View File

@@ -50,17 +50,15 @@ func NewReader(r io.Reader, identity age.Identity) (*Reader, error) {
} }
// Read implements io.Reader // Read implements io.Reader
func (r *Reader) Read(p []byte) (int, error) { func (r *Reader) Read(p []byte) (n int, err error) {
n, err := r.teeReader.Read(p) n, err = r.teeReader.Read(p)
r.bytesRead += int64(n) r.bytesRead += int64(n)
return n, err return n, err
} }
// Close closes the decompressor // Close closes the decompressor
func (r *Reader) Close() error { func (r *Reader) Close() error {
r.decompressor.Close() r.decompressor.Close()
return nil return nil
} }

View File

@@ -2,7 +2,6 @@ package blobgen
import ( import (
"crypto/sha256" "crypto/sha256"
"errors"
"fmt" "fmt"
"hash" "hash"
"io" "io"
@@ -12,21 +11,6 @@ import (
"github.com/klauspost/compress/zstd" "github.com/klauspost/compress/zstd"
) )
// Zstd compression level bounds accepted by NewWriter.
const (
minCompressionLevel = 1
maxCompressionLevel = 19
)
// reservedCompressionCPUs is how many CPUs are left free of zstd
// compression work for I/O and hashing.
const reservedCompressionCPUs = 2
// ErrInvalidCompressionLevel is returned when the zstd compression level
// is outside the accepted 1-19 range.
var ErrInvalidCompressionLevel = errors.New(
"invalid compression level: must be between 1 and 19")
// Writer wraps compression and encryption with SHA256 hashing. // Writer wraps compression and encryption with SHA256 hashing.
// Data flows: input -> tee(hasher, compressor -> encryptor -> destination) // Data flows: input -> tee(hasher, compressor -> encryptor -> destination)
// The hash is computed on the uncompressed input for deterministic content-addressing. // The hash is computed on the uncompressed input for deterministic content-addressing.
@@ -39,15 +23,11 @@ type Writer struct {
bytesWritten int64 bytesWritten int64
} }
// NewWriter creates a new Writer that compresses, encrypts, and hashes // NewWriter creates a new Writer that compresses, encrypts, and hashes data.
// data. The hash is computed on the uncompressed input for deterministic // The hash is computed on the uncompressed input for deterministic content-addressing.
// content-addressing. func NewWriter(w io.Writer, compressionLevel int, recipients []string) (*Writer, error) {
func NewWriter(
w io.Writer, compressionLevel int, recipients []string,
) (*Writer, error) {
// Validate compression level // Validate compression level
err := validateCompressionLevel(compressionLevel) if err := validateCompressionLevel(compressionLevel); err != nil {
if err != nil {
return nil, err return nil, err
} }
@@ -56,13 +36,11 @@ func NewWriter(
// Parse recipients // Parse recipients
var ageRecipients []age.Recipient var ageRecipients []age.Recipient
for _, recipient := range recipients { for _, recipient := range recipients {
r, err := age.ParseX25519Recipient(recipient) r, err := age.ParseX25519Recipient(recipient)
if err != nil { if err != nil {
return nil, fmt.Errorf("parsing recipient %s: %w", recipient, err) return nil, fmt.Errorf("parsing recipient %s: %w", recipient, err)
} }
ageRecipients = append(ageRecipients, r) ageRecipients = append(ageRecipients, r)
} }
@@ -73,7 +51,10 @@ func NewWriter(
} }
// Calculate compression concurrency: CPUs - 2, minimum 1 // Calculate compression concurrency: CPUs - 2, minimum 1
concurrency := max(runtime.NumCPU()-reservedCompressionCPUs, 1) concurrency := runtime.NumCPU() - 2
if concurrency < 1 {
concurrency = 1
}
// Create compression writer with encryption as destination // Create compression writer with encryption as destination
compressor, err := zstd.NewWriter(encWriter, compressor, err := zstd.NewWriter(encWriter,
@@ -82,7 +63,6 @@ func NewWriter(
) )
if err != nil { if err != nil {
_ = encWriter.Close() _ = encWriter.Close()
return nil, fmt.Errorf("creating compression writer: %w", err) return nil, fmt.Errorf("creating compression writer: %w", err)
} }
@@ -99,24 +79,21 @@ func NewWriter(
} }
// Write implements io.Writer // Write implements io.Writer
func (w *Writer) Write(p []byte) (int, error) { func (w *Writer) Write(p []byte) (n int, err error) {
n, err := w.teeWriter.Write(p) n, err = w.teeWriter.Write(p)
w.bytesWritten += int64(n) w.bytesWritten += int64(n)
return n, err return n, err
} }
// Close closes all layers and returns any errors // Close closes all layers and returns any errors
func (w *Writer) Close() error { func (w *Writer) Close() error {
// Close compressor first // Close compressor first
err := w.compressor.Close() if err := w.compressor.Close(); err != nil {
if err != nil {
return fmt.Errorf("closing compressor: %w", err) return fmt.Errorf("closing compressor: %w", err)
} }
// Then close encryptor // Then close encryptor
err = w.encryptor.Close() if err := w.encryptor.Close(); err != nil {
if err != nil {
return fmt.Errorf("closing encryptor: %w", err) return fmt.Errorf("closing encryptor: %w", err)
} }
@@ -132,7 +109,6 @@ func (w *Writer) Sum256() []byte {
firstHash := w.hasher.Sum(nil) firstHash := w.hasher.Sum(nil)
// Second hash: SHA256(firstHash) - this is the blob ID // Second hash: SHA256(firstHash) - this is the blob ID
secondHash := sha256.Sum256(firstHash) secondHash := sha256.Sum256(firstHash)
return secondHash[:] return secondHash[:]
} }
@@ -143,11 +119,9 @@ func (w *Writer) BytesWritten() int64 {
func validateCompressionLevel(level int) error { func validateCompressionLevel(level int) error {
// Zstd compression levels: 1-19 (default is 3) // Zstd compression levels: 1-19 (default is 3)
// SpeedFastest = 1, SpeedDefault = 3, SpeedBetterCompression = 7, // SpeedFastest = 1, SpeedDefault = 3, SpeedBetterCompression = 7, SpeedBestCompression = 11
// SpeedBestCompression = 11 if level < 1 || level > 19 {
if level < minCompressionLevel || level > maxCompressionLevel { return fmt.Errorf("invalid compression level %d: must be between 1 and 19", level)
return fmt.Errorf("%w: got %d", ErrInvalidCompressionLevel, level)
} }
return nil return nil
} }

View File

@@ -1,4 +1,4 @@
package blobgen_test package blobgen
import ( import (
"bytes" "bytes"
@@ -9,15 +9,12 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/blobgen"
) )
// TestWriterHashIsDoubleHash verifies that Writer.Sum256() returns // TestWriterHashIsDoubleHash verifies that Writer.Sum256() returns
// the double hash SHA256(SHA256(plaintext)) for security. // the double hash SHA256(SHA256(plaintext)) for security.
// Double hashing prevents attackers from confirming existence of known content. // Double hashing prevents attackers from confirming existence of known content.
func TestWriterHashIsDoubleHash(t *testing.T) { func TestWriterHashIsDoubleHash(t *testing.T) {
t.Parallel()
// Test data - random data that doesn't compress well // Test data - random data that doesn't compress well
testData := make([]byte, 1024*1024) // 1MB testData := make([]byte, 1024*1024) // 1MB
_, err := rand.Read(testData) _, err := rand.Read(testData)
@@ -30,7 +27,7 @@ func TestWriterHashIsDoubleHash(t *testing.T) {
var encryptedBuf bytes.Buffer var encryptedBuf bytes.Buffer
// Create blobgen writer // Create blobgen writer
writer, err := blobgen.NewWriter(&encryptedBuf, 3, []string{testRecipient}) writer, err := NewWriter(&encryptedBuf, 3, []string{testRecipient})
require.NoError(t, err) require.NoError(t, err)
// Write test data // Write test data
@@ -70,8 +67,6 @@ func TestWriterHashIsDoubleHash(t *testing.T) {
// TestWriterDeterministicHash verifies that the same input always produces // TestWriterDeterministicHash verifies that the same input always produces
// the same hash, even with non-deterministic encryption. // the same hash, even with non-deterministic encryption.
func TestWriterDeterministicHash(t *testing.T) { func TestWriterDeterministicHash(t *testing.T) {
t.Parallel()
// Test data // Test data
testData := []byte("Hello, World! This is test data for deterministic hashing.") testData := []byte("Hello, World! This is test data for deterministic hashing.")
@@ -81,13 +76,13 @@ func TestWriterDeterministicHash(t *testing.T) {
// Create two writers and verify they produce the same hash // Create two writers and verify they produce the same hash
var buf1, buf2 bytes.Buffer var buf1, buf2 bytes.Buffer
writer1, err := blobgen.NewWriter(&buf1, 3, []string{testRecipient}) writer1, err := NewWriter(&buf1, 3, []string{testRecipient})
require.NoError(t, err) require.NoError(t, err)
_, err = writer1.Write(testData) _, err = writer1.Write(testData)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, writer1.Close()) require.NoError(t, writer1.Close())
writer2, err := blobgen.NewWriter(&buf2, 3, []string{testRecipient}) writer2, err := NewWriter(&buf2, 3, []string{testRecipient})
require.NoError(t, err) require.NoError(t, err)
_, err = writer2.Write(testData) _, err = writer2.Write(testData)
require.NoError(t, err) require.NoError(t, err)

View File

@@ -1,21 +1,16 @@
// Package chunker splits input data into content-defined chunks using the
// FastCDC algorithm so that identical data sequences produce identical
// chunks regardless of their position in the file.
package chunker package chunker
import ( import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"errors"
"fmt" "fmt"
"io" "io"
"os" "os"
) )
// Chunk represents a single chunk of data produced by the content-defined // Chunk represents a single chunk of data produced by the content-defined chunking algorithm.
// chunking algorithm. Each chunk is identified by its SHA256 hash and // Each chunk is identified by its SHA256 hash and contains the raw data along with
// contains the raw data along with its position and size information from // its position and size information from the original file.
// the original file.
type Chunk struct { type Chunk struct {
Hash string // Content hash of the chunk Hash string // Content hash of the chunk
Data []byte // Chunk data Data []byte // Chunk data
@@ -33,10 +28,6 @@ type Chunker struct {
maxChunkSize int maxChunkSize int
} }
// chunkSizeSpread is the FastCDC-recommended factor between the average
// chunk size and the minimum (avg/spread) and maximum (avg*spread) sizes.
const chunkSizeSpread = 4
// NewChunker creates a new chunker with the specified average chunk size. // NewChunker creates a new chunker with the specified average chunk size.
// The actual chunk sizes will vary between avgChunkSize/4 and avgChunkSize*4 // The actual chunk sizes will vary between avgChunkSize/4 and avgChunkSize*4
// as recommended by the FastCDC algorithm. Typical values for avgChunkSize // as recommended by the FastCDC algorithm. Typical values for avgChunkSize
@@ -45,31 +36,27 @@ func NewChunker(avgChunkSize int64) *Chunker {
// FastCDC recommends min = avg/4 and max = avg*4 // FastCDC recommends min = avg/4 and max = avg*4
return &Chunker{ return &Chunker{
avgChunkSize: int(avgChunkSize), avgChunkSize: int(avgChunkSize),
minChunkSize: int(avgChunkSize / chunkSizeSpread), minChunkSize: int(avgChunkSize / 4),
maxChunkSize: int(avgChunkSize * chunkSizeSpread), maxChunkSize: int(avgChunkSize * 4),
} }
} }
// ChunkReader splits the reader into content-defined chunks and returns all // ChunkReader splits the reader into content-defined chunks and returns all chunks at once.
// chunks at once. This method loads all chunk data into memory, so it should // This method loads all chunk data into memory, so it should only be used for
// only be used for reasonably sized inputs. For large files or streams, use // reasonably sized inputs. For large files or streams, use ChunkReaderStreaming instead.
// ChunkReaderStreaming instead.
// Returns an error if chunking fails or if reading from the input fails. // Returns an error if chunking fails or if reading from the input fails.
func (c *Chunker) ChunkReader(r io.Reader) ([]Chunk, error) { func (c *Chunker) ChunkReader(r io.Reader) ([]Chunk, error) {
chunker := AcquireReusableChunker( chunker := AcquireReusableChunker(r, c.minChunkSize, c.avgChunkSize, c.maxChunkSize)
r, c.minChunkSize, c.avgChunkSize, c.maxChunkSize)
defer chunker.Release() defer chunker.Release()
var chunks []Chunk var chunks []Chunk
offset := int64(0) offset := int64(0)
for { for {
chunk, err := chunker.Next() chunk, err := chunker.Next()
if errors.Is(err, io.EOF) { if err == io.EOF {
break break
} }
if err != nil { if err != nil {
return nil, fmt.Errorf("reading chunk: %w", err) return nil, fmt.Errorf("reading chunk: %w", err)
} }
@@ -96,36 +83,30 @@ func (c *Chunker) ChunkReader(r io.Reader) ([]Chunk, error) {
// ChunkCallback is a function called for each chunk as it's processed. // ChunkCallback is a function called for each chunk as it's processed.
// The callback receives a Chunk containing the hash, data, offset, and size. // The callback receives a Chunk containing the hash, data, offset, and size.
// If the callback returns an error, chunk processing stops and the error is // If the callback returns an error, chunk processing stops and the error is propagated.
// propagated.
type ChunkCallback func(chunk Chunk) error type ChunkCallback func(chunk Chunk) error
// ChunkReaderStreaming splits the reader into chunks and calls the callback // ChunkReaderStreaming splits the reader into chunks and calls the callback for each chunk.
// for each chunk. This is the preferred method for processing large files or // This is the preferred method for processing large files or streams as it doesn't
// streams as it doesn't accumulate all chunks in memory. The callback is // accumulate all chunks in memory. The callback is invoked for each chunk as it's
// invoked for each chunk as it's produced, allowing for streaming processing // produced, allowing for streaming processing and immediate storage or transmission.
// and immediate storage or transmission. // Returns the SHA256 hash of the entire file content and an error if chunking fails,
// Returns the SHA256 hash of the entire file content and an error if // reading fails, or if the callback returns an error.
// chunking fails, reading fails, or if the callback returns an error. func (c *Chunker) ChunkReaderStreaming(r io.Reader, callback ChunkCallback) (string, error) {
func (c *Chunker) ChunkReaderStreaming(
r io.Reader, callback ChunkCallback,
) (string, error) {
// Create a tee reader to calculate full file hash while chunking // Create a tee reader to calculate full file hash while chunking
fileHasher := sha256.New() fileHasher := sha256.New()
teeReader := io.TeeReader(r, fileHasher) teeReader := io.TeeReader(r, fileHasher)
chunker := AcquireReusableChunker( chunker := AcquireReusableChunker(teeReader, c.minChunkSize, c.avgChunkSize, c.maxChunkSize)
teeReader, c.minChunkSize, c.avgChunkSize, c.maxChunkSize)
defer chunker.Release() defer chunker.Release()
offset := int64(0) offset := int64(0)
for { for {
chunk, err := chunker.Next() chunk, err := chunker.Next()
if errors.Is(err, io.EOF) { if err == io.EOF {
break break
} }
if err != nil { if err != nil {
return "", fmt.Errorf("reading chunk: %w", err) return "", fmt.Errorf("reading chunk: %w", err)
} }
@@ -133,17 +114,15 @@ func (c *Chunker) ChunkReaderStreaming(
// Calculate chunk hash // Calculate chunk hash
hash := sha256.Sum256(chunk.Data) hash := sha256.Sum256(chunk.Data)
// Pass the data directly - caller must process it before we call // Pass the data directly - caller must process it before we call Next() again
// Next() again (chunker reuses its internal buffer, but since we // (chunker reuses its internal buffer, but since we process synchronously
// process synchronously and completely before continuing, no copy // and completely before continuing, no copy is needed)
// is needed) if err := callback(Chunk{
err = callback(Chunk{
Hash: hex.EncodeToString(hash[:]), Hash: hex.EncodeToString(hash[:]),
Data: chunk.Data, Data: chunk.Data,
Offset: offset, Offset: offset,
Size: int64(len(chunk.Data)), Size: int64(len(chunk.Data)),
}) }); err != nil {
if err != nil {
return "", fmt.Errorf("callback error: %w", err) return "", fmt.Errorf("callback error: %w", err)
} }
@@ -159,14 +138,12 @@ func (c *Chunker) ChunkReaderStreaming(
// For large files, consider using ChunkReaderStreaming with a file handle instead. // For large files, consider using ChunkReaderStreaming with a file handle instead.
// Returns an error if the file cannot be opened or if chunking fails. // Returns an error if the file cannot be opened or if chunking fails.
func (c *Chunker) ChunkFile(path string) ([]Chunk, error) { func (c *Chunker) ChunkFile(path string) ([]Chunk, error) {
file, err := os.Open(path) //nolint:gosec // G304: path is caller-supplied by design file, err := os.Open(path)
if err != nil { if err != nil {
return nil, fmt.Errorf("opening file: %w", err) return nil, fmt.Errorf("opening file: %w", err)
} }
defer func() { defer func() {
err := file.Close() if err := file.Close(); err != nil && err.Error() != "invalid argument" {
if err != nil && err.Error() != "invalid argument" {
// Log error or handle as needed // Log error or handle as needed
_ = err _ = err
} }

View File

@@ -1,15 +1,11 @@
package chunker_test package chunker
import ( import (
"bytes" "bytes"
"testing" "testing"
"sneak.berlin/go/vaultik/internal/chunker"
) )
func TestChunkerExpectedChunkCount(t *testing.T) { func TestChunkerExpectedChunkCount(t *testing.T) {
t.Parallel()
tests := []struct { tests := []struct {
name string name string
fileSize int fileSize int
@@ -42,19 +38,16 @@ func TestChunkerExpectedChunkCount(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel() chunker := NewChunker(tt.avgChunkSize)
c := chunker.NewChunker(tt.avgChunkSize)
// Create data with some variation to trigger chunk boundaries // Create data with some variation to trigger chunk boundaries
data := make([]byte, tt.fileSize) data := make([]byte, tt.fileSize)
for i := range data { for i := 0; i < len(data); i++ {
// Use a pattern that should create boundaries // Use a pattern that should create boundaries
//nolint:gosec // G115: intentional byte truncation
data[i] = byte((i * 17) ^ (i >> 5)) data[i] = byte((i * 17) ^ (i >> 5))
} }
chunks, err := c.ChunkReader(bytes.NewReader(data)) chunks, err := chunker.ChunkReader(bytes.NewReader(data))
if err != nil { if err != nil {
t.Fatalf("chunking failed: %v", err) t.Fatalf("chunking failed: %v", err)
} }
@@ -66,7 +59,6 @@ func TestChunkerExpectedChunkCount(t *testing.T) {
t.Errorf("too few chunks: got %d, expected at least %d", t.Errorf("too few chunks: got %d, expected at least %d",
len(chunks), tt.minExpected) len(chunks), tt.minExpected)
} }
if len(chunks) > tt.maxExpected { if len(chunks) > tt.maxExpected {
t.Errorf("too many chunks: got %d, expected at most %d", t.Errorf("too many chunks: got %d, expected at most %d",
len(chunks), tt.maxExpected) len(chunks), tt.maxExpected)
@@ -77,7 +69,6 @@ func TestChunkerExpectedChunkCount(t *testing.T) {
for _, chunk := range chunks { for _, chunk := range chunks {
reconstructed = append(reconstructed, chunk.Data...) reconstructed = append(reconstructed, chunk.Data...)
} }
if !bytes.Equal(data, reconstructed) { if !bytes.Equal(data, reconstructed) {
t.Error("reconstructed data doesn't match original") t.Error("reconstructed data doesn't match original")
} }

View File

@@ -1,20 +1,17 @@
package chunker_test package chunker
import ( import (
"bytes" "bytes"
"crypto/rand" "crypto/rand"
"testing" "testing"
"sneak.berlin/go/vaultik/internal/chunker"
) )
func TestChunkerSmallFileSingleChunk(t *testing.T) { func TestChunker(t *testing.T) {
t.Parallel() t.Run("small file produces single chunk", func(t *testing.T) {
chunker := NewChunker(1024 * 1024) // 1MB average
c := chunker.NewChunker(1024 * 1024) // 1MB average
data := bytes.Repeat([]byte("hello"), 100) // 500 bytes data := bytes.Repeat([]byte("hello"), 100) // 500 bytes
chunks, err := c.ChunkReader(bytes.NewReader(data)) chunks, err := chunker.ChunkReader(bytes.NewReader(data))
if err != nil { if err != nil {
t.Fatalf("chunking failed: %v", err) t.Fatalf("chunking failed: %v", err)
} }
@@ -26,34 +23,29 @@ func TestChunkerSmallFileSingleChunk(t *testing.T) {
if chunks[0].Size != int64(len(data)) { if chunks[0].Size != int64(len(data)) {
t.Errorf("expected chunk size %d, got %d", len(data), chunks[0].Size) t.Errorf("expected chunk size %d, got %d", len(data), chunks[0].Size)
} }
} })
func TestChunkerLargeFileMultipleChunks(t *testing.T) { t.Run("large file produces multiple chunks", func(t *testing.T) {
t.Parallel() chunker := NewChunker(256 * 1024) // 256KB average chunk size
c := chunker.NewChunker(256 * 1024) // 256KB average chunk size
// Generate 2MB of random data // Generate 2MB of random data
data := make([]byte, 2*1024*1024) data := make([]byte, 2*1024*1024)
if _, err := rand.Read(data); err != nil {
_, err := rand.Read(data)
if err != nil {
t.Fatalf("failed to generate random data: %v", err) t.Fatalf("failed to generate random data: %v", err)
} }
chunks, err := c.ChunkReader(bytes.NewReader(data)) chunks, err := chunker.ChunkReader(bytes.NewReader(data))
if err != nil { if err != nil {
t.Fatalf("chunking failed: %v", err) t.Fatalf("chunking failed: %v", err)
} }
// Should produce multiple chunks - with FastCDC we expect around 8 // Should produce multiple chunks - with FastCDC we expect around 8 chunks for 2MB with 256KB average
// chunks for 2MB with 256KB average
if len(chunks) < 4 || len(chunks) > 16 { if len(chunks) < 4 || len(chunks) > 16 {
t.Errorf("expected 4-16 chunks, got %d", len(chunks)) t.Errorf("expected 4-16 chunks, got %d", len(chunks))
} }
// Verify chunks reconstruct original data // Verify chunks reconstruct original data
reconstructed := make([]byte, 0, len(data)) var reconstructed []byte
for _, chunk := range chunks { for _, chunk := range chunks {
reconstructed = append(reconstructed, chunk.Data...) reconstructed = append(reconstructed, chunk.Data...)
} }
@@ -64,22 +56,17 @@ func TestChunkerLargeFileMultipleChunks(t *testing.T) {
// Verify offsets // Verify offsets
var expectedOffset int64 var expectedOffset int64
for i, chunk := range chunks { for i, chunk := range chunks {
if chunk.Offset != expectedOffset { if chunk.Offset != expectedOffset {
t.Errorf("chunk %d: expected offset %d, got %d", t.Errorf("chunk %d: expected offset %d, got %d", i, expectedOffset, chunk.Offset)
i, expectedOffset, chunk.Offset)
} }
expectedOffset += chunk.Size expectedOffset += chunk.Size
} }
} })
func TestChunkerDeterministic(t *testing.T) { t.Run("deterministic chunking", func(t *testing.T) {
t.Parallel() chunker1 := NewChunker(256 * 1024)
chunker2 := NewChunker(256 * 1024)
chunker1 := chunker.NewChunker(256 * 1024)
chunker2 := chunker.NewChunker(256 * 1024)
// Use deterministic data // Use deterministic data
data := bytes.Repeat([]byte("abcdefghijklmnopqrstuvwxyz"), 20000) // ~520KB data := bytes.Repeat([]byte("abcdefghijklmnopqrstuvwxyz"), 20000) // ~520KB
@@ -96,25 +83,22 @@ func TestChunkerDeterministic(t *testing.T) {
// Should produce same chunks // Should produce same chunks
if len(chunks1) != len(chunks2) { if len(chunks1) != len(chunks2) {
t.Fatalf("different number of chunks: %d vs %d", t.Fatalf("different number of chunks: %d vs %d", len(chunks1), len(chunks2))
len(chunks1), len(chunks2))
} }
for i := range chunks1 { for i := range chunks1 {
if chunks1[i].Hash != chunks2[i].Hash { if chunks1[i].Hash != chunks2[i].Hash {
t.Errorf("chunk %d: different hashes", i) t.Errorf("chunk %d: different hashes", i)
} }
if chunks1[i].Size != chunks2[i].Size { if chunks1[i].Size != chunks2[i].Size {
t.Errorf("chunk %d: different sizes", i) t.Errorf("chunk %d: different sizes", i)
} }
} }
})
} }
func TestChunkBoundaries(t *testing.T) { func TestChunkBoundaries(t *testing.T) {
t.Parallel() chunker := NewChunker(256 * 1024) // 256KB average
c := chunker.NewChunker(256 * 1024) // 256KB average
// FastCDC uses avg/4 for min and avg*4 for max // FastCDC uses avg/4 for min and avg*4 for max
avgSize := int64(256 * 1024) avgSize := int64(256 * 1024)
@@ -123,13 +107,11 @@ func TestChunkBoundaries(t *testing.T) {
// Test that minimum chunk size is respected // Test that minimum chunk size is respected
data := make([]byte, minSize+1024) data := make([]byte, minSize+1024)
if _, err := rand.Read(data); err != nil {
_, err := rand.Read(data)
if err != nil {
t.Fatalf("failed to generate random data: %v", err) t.Fatalf("failed to generate random data: %v", err)
} }
chunks, err := c.ChunkReader(bytes.NewReader(data)) chunks, err := chunker.ChunkReader(bytes.NewReader(data))
if err != nil { if err != nil {
t.Fatalf("chunking failed: %v", err) t.Fatalf("chunking failed: %v", err)
} }
@@ -137,13 +119,10 @@ func TestChunkBoundaries(t *testing.T) {
for i, chunk := range chunks { for i, chunk := range chunks {
// Last chunk can be smaller than minimum // Last chunk can be smaller than minimum
if i < len(chunks)-1 && chunk.Size < minSize { if i < len(chunks)-1 && chunk.Size < minSize {
t.Errorf("chunk %d size %d is below minimum %d", t.Errorf("chunk %d size %d is below minimum %d", i, chunk.Size, minSize)
i, chunk.Size, minSize)
} }
if chunk.Size > maxSize { if chunk.Size > maxSize {
t.Errorf("chunk %d size %d exceeds maximum %d", t.Errorf("chunk %d size %d exceeds maximum %d", i, chunk.Size, maxSize)
i, chunk.Size, maxSize)
} }
} }
} }

View File

@@ -1,7 +1,6 @@
package chunker package chunker
import ( import (
"errors"
"io" "io"
"math" "math"
"sync" "sync"
@@ -28,52 +27,32 @@ type ReusableChunker struct {
} }
// reusableChunkerPool pools ReusableChunker instances to avoid allocations. // reusableChunkerPool pools ReusableChunker instances to avoid allocations.
//
//nolint:gochecknoglobals // process-wide object pool by design
var reusableChunkerPool = sync.Pool{ var reusableChunkerPool = sync.Pool{
New: func() any { New: func() interface{} {
return &ReusableChunker{} return &ReusableChunker{}
}, },
} }
// bufferPools contains pools for different buffer sizes. // bufferPools contains pools for different buffer sizes.
// Key is the buffer size. // Key is the buffer size.
//
//nolint:gochecknoglobals // process-wide buffer pools by design
var bufferPools = sync.Map{} var bufferPools = sync.Map{}
func getBuffer(size int) []byte { func getBuffer(size int) []byte {
poolI, _ := bufferPools.LoadOrStore(size, &sync.Pool{ poolI, _ := bufferPools.LoadOrStore(size, &sync.Pool{
New: func() any { New: func() interface{} {
buf := make([]byte, size) buf := make([]byte, size)
return &buf return &buf
}, },
}) })
pool := poolI.(*sync.Pool)
pool, ok := poolI.(*sync.Pool) return *pool.Get().(*[]byte)
if !ok {
panic("bufferPools holds a non-pool value")
}
buf, ok := pool.Get().(*[]byte)
if !ok {
panic("buffer pool holds a non-buffer value")
}
return *buf
} }
func putBuffer(buf []byte) { func putBuffer(buf []byte) {
size := cap(buf) size := cap(buf)
poolI, ok := bufferPools.Load(size) poolI, ok := bufferPools.Load(size)
if ok { if ok {
pool, isPool := poolI.(*sync.Pool) pool := poolI.(*sync.Pool)
if !isPool {
panic("bufferPools holds a non-pool value")
}
b := buf[:size] b := buf[:size]
pool.Put(&b) pool.Put(&b)
} }
@@ -87,28 +66,17 @@ type FastCDCChunk struct {
Fingerprint uint64 Fingerprint uint64
} }
// bufSizeFactor sizes the internal read buffer relative to the maximum // AcquireReusableChunker gets a chunker from the pool and initializes it for the given reader.
// chunk size so a full chunk plus read-ahead always fits. func AcquireReusableChunker(rd io.Reader, minSize, avgSize, maxSize int) *ReusableChunker {
const bufSizeFactor = 2 c := reusableChunkerPool.Get().(*ReusableChunker)
// AcquireReusableChunker gets a chunker from the pool and initializes it bufSize := maxSize * 2
// for the given reader.
func AcquireReusableChunker(
rd io.Reader, minSize, avgSize, maxSize int,
) *ReusableChunker {
c, ok := reusableChunkerPool.Get().(*ReusableChunker)
if !ok {
panic("reusableChunkerPool holds a non-chunker value")
}
bufSize := maxSize * bufSizeFactor
// Reuse buffer if it's the right size, otherwise get a new one // Reuse buffer if it's the right size, otherwise get a new one
if c.buf == nil || cap(c.buf) != bufSize { if c.buf == nil || cap(c.buf) != bufSize {
if c.buf != nil { if c.buf != nil {
putBuffer(c.buf) putBuffer(c.buf)
} }
c.buf = getBuffer(bufSize) c.buf = getBuffer(bufSize)
} else { } else {
// Restore buffer to full capacity (may have been truncated by previous EOF) // Restore buffer to full capacity (may have been truncated by previous EOF)
@@ -140,14 +108,41 @@ func (c *ReusableChunker) Release() {
reusableChunkerPool.Put(c) reusableChunkerPool.Put(c)
} }
func (c *ReusableChunker) fillBuffer() error {
n := len(c.buf) - c.cursor
if n >= c.maxSize {
return nil
}
// Move all data after the cursor to the start of the buffer
copy(c.buf[:n], c.buf[c.cursor:])
c.cursor = 0
if c.eof {
c.buf = c.buf[:n]
return nil
}
// Restore buffer to full capacity for reading
c.buf = c.buf[:c.bufSize]
// Fill the rest of the buffer
m, err := io.ReadFull(c.rd, c.buf[n:])
if err == io.EOF || err == io.ErrUnexpectedEOF {
c.buf = c.buf[:n+m]
c.eof = true
} else if err != nil {
return err
}
return nil
}
// Next returns the next chunk or io.EOF when done. // Next returns the next chunk or io.EOF when done.
// The returned Data slice is only valid until the next call to Next. // The returned Data slice is only valid until the next call to Next.
func (c *ReusableChunker) Next() (FastCDCChunk, error) { func (c *ReusableChunker) Next() (FastCDCChunk, error) {
err := c.fillBuffer() if err := c.fillBuffer(); err != nil {
if err != nil {
return FastCDCChunk{}, err return FastCDCChunk{}, err
} }
if len(c.buf) == 0 { if len(c.buf) == 0 {
return FastCDCChunk{}, io.EOF return FastCDCChunk{}, io.EOF
} }
@@ -167,37 +162,6 @@ func (c *ReusableChunker) Next() (FastCDCChunk, error) {
return chunk, nil return chunk, nil
} }
func (c *ReusableChunker) fillBuffer() error {
n := len(c.buf) - c.cursor
if n >= c.maxSize {
return nil
}
// Move all data after the cursor to the start of the buffer
copy(c.buf[:n], c.buf[c.cursor:])
c.cursor = 0
if c.eof {
c.buf = c.buf[:n]
return nil
}
// Restore buffer to full capacity for reading
c.buf = c.buf[:c.bufSize]
// Fill the rest of the buffer
m, err := io.ReadFull(c.rd, c.buf[n:])
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
c.buf = c.buf[:n+m]
c.eof = true
} else if err != nil {
return err
}
return nil
}
func (c *ReusableChunker) nextChunk(data []byte) (int, uint64) { func (c *ReusableChunker) nextChunk(data []byte) (int, uint64) {
fp := uint64(0) fp := uint64(0)
i := c.minSize i := c.minSize
@@ -225,9 +189,14 @@ func (c *ReusableChunker) nextChunk(data []byte) (int, uint64) {
return i, fp return i, fp
} }
func min(a, b int) int {
if a < b {
return a
}
return b
}
// 256 random uint64s for the rolling hash function (from FastCDC paper) // 256 random uint64s for the rolling hash function (from FastCDC paper)
//
//nolint:gochecknoglobals // immutable FastCDC gear lookup table
var table = [256]uint64{ var table = [256]uint64{
0xe80e8d55032474b3, 0x11b25b61f5924e15, 0x03aa5bd82a9eb669, 0xc45a153ef107a38c, 0xe80e8d55032474b3, 0x11b25b61f5924e15, 0x03aa5bd82a9eb669, 0xc45a153ef107a38c,
0xeac874b86f0f57b9, 0xa5ccedec95ec79c7, 0xe15a3320ad42ac0a, 0x5ed3583fa63cec15, 0xeac874b86f0f57b9, 0xa5ccedec95ec79c7, 0xe15a3320ad42ac0a, 0x5ed3583fa63cec15,

View File

@@ -1,6 +1,3 @@
// Package cli implements the vaultik command-line interface: cobra
// commands, fx application wiring, and process-level concerns such as
// signal handling and the PID lock.
package cli package cli
import ( import (
@@ -10,79 +7,48 @@ import (
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"strings"
"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"
"github.com/spf13/cobra"
"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/ui"
"sneak.berlin/go/vaultik/internal/vaultik"
) )
// shutdownTimeout bounds how long a signal-triggered graceful shutdown
// may take before we give up.
const shutdownTimeout = 30 * time.Second
// AppOptions contains common options for creating the fx application. // AppOptions contains common options for creating the fx application.
// It includes the configuration file path, logging options, and additional // It includes the configuration file path, logging options, and additional
// fx modules and invocations that should be included in the application. // fx modules and invocations that should be included in the application.
type AppOptions struct { type AppOptions struct {
ConfigPath string ConfigPath string
LogOptions log.Options LogOptions log.LogOptions
Modules []fx.Option Modules []fx.Option
Invokes []fx.Option Invokes []fx.Option
} }
// setupGlobals records the startup time and, when an output-suppression // setupGlobals sets up the globals with application startup time
// flag is active, marks the UI writer quiet so that Begin/Complete/ func setupGlobals(lc fx.Lifecycle, g *globals.Globals) {
// Info/Notice/Detail/Progress are silenced. Warning and Error are NOT
// silenced — per the documented convention that --quiet suppresses
// non-error output only. The startup banner is printed by Entry
// before cobra parses arguments, gated by the same arg-level check.
func setupGlobals(
lc fx.Lifecycle, g *globals.Globals, v *vaultik.Vaultik, opts log.Options,
) {
lc.Append(fx.Hook{ lc.Append(fx.Hook{
OnStart: func(_ context.Context) error { OnStart: func(ctx context.Context) error {
g.StartTime = time.Now().UTC() g.StartTime = time.Now().UTC()
if opts.Cron || opts.Quiet {
v.UI.SetQuiet(true)
}
return nil return nil
}, },
}) })
} }
// writeStartupBanner prints the two-line application banner followed by a
// blank line. Used both from the fx hook (for subcommand invocations) and
// from the root cobra Run handler (for `vaultik` with no subcommand).
func writeStartupBanner(w *ui.Writer, startTime time.Time, shortCommit string) {
w.Bannerf("%s %s by %s (commit %s, built on %s) starting up at %s.",
globals.Appname, globals.Version, globals.Author,
shortCommit, globals.CommitDate,
startTime.Format(time.RFC3339))
w.Bannerf("%s", globals.Homepage)
w.Bannerf("")
}
// NewApp creates a new fx application with common modules. // NewApp creates a new fx application with common modules.
// It sets up the base modules (config, database, logging, globals) and // It sets up the base modules (config, database, logging, globals) and
// combines them with any additional modules specified in the options. // combines them with any additional modules specified in the options.
// The returned fx.App is ready to be started with RunApp. // The returned fx.App is ready to be started with RunApp.
func NewApp(opts AppOptions) *fx.App { func NewApp(opts AppOptions) *fx.App {
baseModules := []fx.Option{ baseModules := []fx.Option{
fx.Supply(config.Path(opts.ConfigPath)), fx.Supply(config.ConfigPath(opts.ConfigPath)),
fx.Supply(opts.LogOptions), fx.Supply(opts.LogOptions),
fx.Provide(globals.New), fx.Provide(globals.New),
fx.Provide(log.New), fx.Provide(log.New),
@@ -96,46 +62,12 @@ func NewApp(opts AppOptions) *fx.App {
fx.NopLogger, fx.NopLogger,
} }
capacity := len(baseModules) + len(opts.Modules) + len(opts.Invokes) allOptions := append(baseModules, opts.Modules...)
allOptions := make([]fx.Option, 0, capacity)
allOptions = append(allOptions, baseModules...)
allOptions = append(allOptions, opts.Modules...)
allOptions = append(allOptions, opts.Invokes...) allOptions = append(allOptions, opts.Invokes...)
return fx.New(allOptions...) return fx.New(allOptions...)
} }
// startupError carries a startup failure message that has been cleaned
// of fx dependency-injection noise. A distinct type (rather than
// errors.New) keeps the dynamic message out of err113's sight while
// preserving the exact user-facing text.
type startupError struct {
msg string
}
func (e *startupError) Error() string {
return e.msg
}
// cleanStartupError strips fx's dependency-injection call-chain noise from
// startup errors. fx wraps the underlying error with messages like
//
// could not build arguments for function "X" (file:line): failed to build T:
// could not build arguments for function "Y" (file:line): failed to build U:
// received non-nil error from function "Z" (file:line): <real error>
//
// Users care about the real error, not the DI plumbing. We strip everything
// up through the last "): " (which is always the close-paren of an fx
// function-location annotation followed by the wrapped error).
func cleanStartupError(err error) error {
msg := err.Error()
if idx := strings.LastIndex(msg, "): "); idx >= 0 {
msg = msg[idx+3:]
}
return &startupError{msg: msg}
}
// RunApp starts and stops the fx application within the given context. // RunApp starts and stops the fx application within the given context.
// It handles graceful shutdown on interrupt signals (SIGINT, SIGTERM) and // It handles graceful shutdown on interrupt signals (SIGINT, SIGTERM) and
// ensures the application stops cleanly. The function blocks until the // ensures the application stops cleanly. The function blocks until the
@@ -150,45 +82,36 @@ func RunApp(ctx context.Context, app *fx.App) error {
defer cancel() defer cancel()
// Start the app // Start the app
err := app.Start(ctx) if err := app.Start(ctx); err != nil {
if err != nil { return fmt.Errorf("failed to start app: %w", err)
return cleanStartupError(err)
} }
// Handle shutdown // Handle shutdown
shutdownComplete := make(chan struct{}) shutdownComplete := make(chan struct{})
go func() { go func() {
defer close(shutdownComplete) defer close(shutdownComplete)
<-sigChan <-sigChan
log.Notice("Received interrupt signal, shutting down gracefully...") log.Notice("Received interrupt signal, shutting down gracefully...")
// Create a timeout context for shutdown. The parent ctx is being // Create a timeout context for shutdown
// cancelled, so detach from its cancellation but keep its values. shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
shutdownCtx, shutdownCancel := context.WithTimeout(
context.WithoutCancel(ctx), shutdownTimeout)
defer shutdownCancel() defer shutdownCancel()
err := app.Stop(shutdownCtx) if err := app.Stop(shutdownCtx); err != nil {
if err != nil {
log.Error("Error during shutdown", "error", err) log.Error("Error during shutdown", "error", err)
} }
}() }()
// Wait for the signal handler to complete shutdown or the app to // Wait for either the signal handler to complete shutdown or the app to request shutdown
// request shutdown.
select { select {
case <-shutdownComplete: case <-shutdownComplete:
// Shutdown completed via signal // Shutdown completed via signal
return nil return nil
case <-ctx.Done(): case <-ctx.Done():
// Context cancelled (shouldn't happen in normal operation) // Context cancelled (shouldn't happen in normal operation)
err := app.Stop(context.WithoutCancel(ctx)) if err := app.Stop(context.Background()); err != nil {
if err != nil {
log.Error("Error stopping app", "error", err) log.Error("Error stopping app", "error", err)
} }
return ctx.Err() return ctx.Err()
case <-app.Done(): case <-app.Done():
// App finished running (e.g., backup completed) // App finished running (e.g., backup completed)
@@ -196,93 +119,26 @@ func RunApp(ctx context.Context, app *fx.App) error {
} }
} }
// runVaultikApp runs the standard single-operation command lifecycle
// shared by the list/purge/verify/remove/remote-info subcommands:
// resolve the config, start the fx app, run op against the Vaultik
// instance in a goroutine, report a failure prefixed with failMsg
// (suppressed while suppressErrors is true, e.g. under --json), then
// trigger shutdown. The operation is cancelled when the app stops.
// extraQuiet is OR-ed into LogOptions.Quiet (e.g. --json output modes).
func runVaultikApp(
cmd *cobra.Command, extraQuiet, suppressErrors bool,
failMsg string, op func(v *vaultik.Vaultik) error,
) error {
configPath, err := ResolveConfigPath()
if err != nil {
return err
}
rootFlags := GetRootFlags()
return RunWithApp(cmd.Context(), AppOptions{
ConfigPath: configPath,
LogOptions: log.Options{
Verbose: rootFlags.Verbose,
Debug: rootFlags.Debug,
Quiet: rootFlags.Quiet || extraQuiet,
},
Modules: []fx.Option{},
Invokes: []fx.Option{
fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) {
lc.Append(fx.Hook{
OnStart: func(_ context.Context) error {
go func() {
err := op(v)
if err != nil {
if !errors.Is(err, context.Canceled) {
if !suppressErrors {
log.Error(failMsg, "error", err)
ReportErrorf("%s: %v", failMsg, err)
}
os.Exit(1)
}
}
err = v.Shutdowner.Shutdown()
if err != nil {
log.Error("Failed to shutdown", "error", err)
}
}()
return nil
},
OnStop: func(_ context.Context) error {
v.Cancel()
return nil
},
})
}),
},
})
}
// RunWithApp is a helper that creates and runs an fx app with the given options. // 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 // 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. // preferred way to run CLI commands that need the full application context.
// 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, "vaultik") lockDir := filepath.Join(xdg.DataHome, "berlin.sneak.app.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) {
return fmt.Errorf("cannot start: %w", err) return fmt.Errorf("cannot start: %w", err)
} }
return fmt.Errorf("failed to acquire lock: %w", err) return fmt.Errorf("failed to acquire lock: %w", err)
} }
defer func() { defer func() {
err := lock.Release() if err := lock.Release(); err != nil {
if err != nil {
log.Warn("Failed to release PID lock", "error", err) log.Warn("Failed to release PID lock", "error", err)
} }
}() }()
app := NewApp(opts) app := NewApp(opts)
return RunApp(ctx, app) return RunApp(ctx, app)
} }

View File

@@ -1,55 +0,0 @@
package cli //nolint:testpackage // needs access to unexported cleanStartupError
import (
"errors"
"testing"
)
func TestCleanStartupError(t *testing.T) {
t.Parallel()
tests := []struct {
name string
in string
want string
}{
{
name: "real fx error chain",
in: `could not build arguments for function ` +
`"sneak.berlin/go/vaultik/internal/cli".newSnapshotCreateCommand.func1.1 ` +
`(/Users/user/dev/vaultik/internal/cli/snapshot.go:71): ` +
`failed to build *vaultik.Vaultik: ` +
`could not build arguments for function ` +
`"sneak.berlin/go/vaultik/internal/vaultik".New ` +
`(/Users/user/dev/vaultik/internal/vaultik/vaultik.go:59): ` +
`failed to build storage.Storer: ` +
`received non-nil error from function ` +
`"sneak.berlin/go/vaultik/internal/storage".NewStorer ` +
`(/Users/user/dev/vaultik/internal/storage/module.go:23): ` +
`creating base path: mkdir /Volumes/BACKUPS: permission denied`,
want: `creating base path: mkdir /Volumes/BACKUPS: permission denied`,
},
{
name: "no fx wrapping",
in: "plain error",
want: "plain error",
},
{
name: "single fx wrapping",
in: `received non-nil error from function "foo" (file.go:1): underlying problem`,
want: "underlying problem",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
//nolint:err113 // test constructs errors from table input
got := cleanStartupError(errors.New(tt.in)).Error()
if got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
}

View File

@@ -1,613 +0,0 @@
package cli
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
)
// configFileMode is the permission set for freshly written config files;
// configs may hold S3 credentials, so keep them owner-only.
const configFileMode = 0o600
// configSetArgs is the argument count of `config set <key> <value>`.
const configSetArgs = 2
// configDirMode is the permission set for created config directories;
// parent config dirs (e.g. ~/.config) are conventionally traversable.
const configDirMode = 0o755
var (
errConfigExists = errors.New("config file already exists")
errEmptyConfig = errors.New("empty config file")
errKeyNotFound = errors.New("key not found")
errNeedNumericIndex = errors.New("key is a list; use a numeric index")
errIndexOutOfRange = errors.New("index out of range")
errNotMapOrList = errors.New("key is not a map or list")
)
const defaultConfigTemplate = `# vaultik configuration
# Documentation: https://sneak.berlin/go/vaultik
# ─── REQUIRED ────────────────────────────────────────────────────────────────
# Age recipient public keys for encryption.
# 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:
- age1REPLACE_WITH_YOUR_PUBLIC_KEY
# Named snapshots. Each snapshot backs up one or more paths and can have its
# own exclude patterns in addition to the global excludes below.
#
# Exclude pattern semantics:
# - Patterns starting with / are anchored to the snapshot path root
# (e.g. "/Library/Caches" matches only ~/Library/Caches in a ~ snapshot)
# - Patterns without a leading / match anywhere in the tree
# (e.g. ".cache" matches any directory named .cache at any depth)
# - Globs are supported: *, **, ?
snapshots:
home:
paths:
- "~"
exclude:
# Trash, temp, and filesystem metadata
- "/.Trash"
- "/.Trashes"
- "/.fseventsd"
- "/.Spotlight-V100"
- "/.TemporaryItems"
- "/tmp"
- "/.rnd"
- ".DS_Store"
# Caches and package manager state (rebuildable)
- ".cache"
- ".bundle"
- "/.cpan/build"
- "/.cpan/sources"
- "/.gradle/caches"
- "/.dropbox"
- "/.minikube/cache"
- "/.local/share/containers/podman/machine"
- "/.persepolis"
- "/Library/Caches"
- "/Library/Logs"
- "/Library/Cookies"
- "/Library/Metadata"
- "/Library/Suggestions"
- "/Library/PubSub"
- "/Library/Homebrew"
- "/Library/Developer"
- "/Library/Google/GoogleSoftwareUpdate"
- "/Library/Preferences/Macromedia/Flash Player"
- "/Library/Preferences/SDMHelpData"
- "/Library/VoiceTrigger/SAT"
# Language/toolchain package caches (rebuildable from registries)
- "/.npm"
- "/.cargo/registry"
- "/.cargo/git"
- "/.rustup/toolchains"
- "/go/pkg/mod"
- "/.m2/repository"
- "/.vagrant.d/boxes"
- "node_modules"
- "__pycache__"
- ".venv"
# Virtual machine disk images (huge; remove these lines to back them up)
- "/Parallels"
- "/Virtual Machines.localized"
- "/VirtualBox VMs"
- "/.orbstack"
- "/Library/Containers/com.utmapp.UTM"
# Downloaded LLM models (huge, re-downloadable)
- "/.ollama/models"
- "/.lmstudio/models"
# Cloud-synced storage. These are synced to a provider already, and on
# modern macOS may contain dataless placeholder files that the backup
# would force-download in full.
- "/Library/CloudStorage"
- "/Library/Mobile Documents"
# Android SDK and emulator images (re-downloadable)
- "/Library/Android/sdk"
- "/.android/avd"
# Cloud-synced or restorable-from-server data
- "/Library/Mail"
- "/Library/Mail Downloads"
- "/Library/Safari"
- "/Library/Application Support/Evernote"
- "/Library/Application Support/MobileSync"
- "/Library/Application Support/SyncServices"
- "/Library/Application Support/protonmail/bridge/cache"
- "/Library/Application Support/Syncthing/index-*"
- "/Library/Syncthing/folders"
- "/Documents/Dropbox/.dropbox.cache"
# Large rebuildable app data (games, media caches, device backups)
- "/Applications/Fortnite"
- "/Documents/Steam Content"
- "/Library/Application Support/Ableton"
- "/Library/Application Support/CrossOver Games"
- "/Library/Application Support/SecondLife/cache"
- "/Library/Application Support/Steam/SteamApps"
- "/Library/Containers/com.docker.docker"
- "/Library/Group Containers/group.com.apple.secure-control-center-preferences"
- "/Library/iTunes/iPad Software Updates"
- "/Library/iTunes/iPhone Software Updates"
- "/Movies/CacheClip"
- "/Movies/ProxyMedia"
- "/Music/iTunes/Album Artwork"
- "/Pictures/iPod Photo Cache"
# Third-party applications. OS-provided apps live in /System/Applications
# on modern macOS and are never in /Applications, but Apple-installed
# App Store apps (Safari, GarageBand, iWork, iMovie) are excluded since
# they are re-downloadable.
apps:
paths:
- /Applications
exclude:
- ".DS_Store"
- "/Safari.app"
- "/GarageBand.app"
- "/iMovie.app"
- "/Keynote.app"
- "/Numbers.app"
- "/Pages.app"
- "/Xcode.app"
- "/Spotify.app"
- "/Steam.app"
- "/VirtualBox.app"
- "/Utilities/Adobe Installers"
# Storage backend (pick ONE of the three forms below).
#
# S3-compatible:
# storage_url: "s3://mybucket/backups?endpoint=s3.example.com&region=us-east-1"
# (also set s3.access_key_id and s3.secret_access_key below)
#
# Local filesystem:
# storage_url: "file:///mnt/backups/vaultik"
#
# Rclone (requires rclone configured separately):
# storage_url: "rclone://myremote/path/to/backups"
storage_url: ""
# ─── S3 CREDENTIALS (required for s3:// storage_url) ────────────────────────
# s3:
# 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
# ─── OPTIONAL ────────────────────────────────────────────────────────────────
# Global exclude patterns applied to ALL snapshots.
# Snapshot-specific excludes are additive.
# exclude:
# - "*.log"
# - "*.tmp"
# - ".git"
# - "node_modules"
# Average chunk size for content-defined chunking (FastCDC).
# Smaller = better deduplication but more metadata overhead.
# Accepts: 1MB, 10M, 64KB, etc.
# Default: 10MB
# chunk_size: 10MB
# Maximum blob size before splitting into a new blob.
# Accepts: 1GB, 10G, 500MB, etc.
# Default: 10GB
# blob_size_limit: 10GB
# Zstd compression level (1-19). Higher = better ratio but slower.
# Default: 3
# compression_level: 3
# Hostname used in snapshot IDs. Default: system hostname.
# hostname: myserver
# Path to the local SQLite index database.
# 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
`
// NewConfigCommand creates the config command group.
func NewConfigCommand() *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",
Short: "Write a default config file",
Long: `Creates a default configuration file with commented explanations
for every setting. If a config file already exists at the target path,
the command refuses to overwrite it.
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(_ *cobra.Command, _ []string) error {
path := configPathForInit()
_, err := os.Stat(path)
if err == nil {
return fmt.Errorf("%w: %s", errConfigExists, path)
}
dir := filepath.Dir(path)
err = os.MkdirAll(dir, configDirMode)
if err != nil {
return fmt.Errorf("creating config directory %s: %w", dir, err)
}
err = os.WriteFile(path, []byte(defaultConfigTemplate), configFileMode)
if err != nil {
return fmt.Errorf("writing config file: %w", err)
}
_, _ = 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
},
}
}
// 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, _ []string) error {
path, err := ResolveConfigPath()
if err != nil {
return err
}
editor := os.Getenv("EDITOR")
if editor == "" {
editor = "vi"
}
//nolint:gosec // G204: launching the operator's own $EDITOR is the point
ed := exec.CommandContext(cmd.Context(), 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. storage_url, compression_level)",
Args: cobra.ExactArgs(1),
RunE: func(_ *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.Fprintln(os.Stdout, node.Value)
return nil
}
out, err := yaml.Marshal(node)
if err != nil {
return fmt.Errorf("marshaling value: %w", err)
}
_, _ = fmt.Fprint(os.Stdout, 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 storage_url "file:///mnt/backups"
vaultik config set storage_url "s3://bucket/prefix?endpoint=host&region=us-east-1"
vaultik config set compression_level 9
vaultik config set s3.bucket mybucket # legacy S3 fields still supported`,
Args: cobra.ExactArgs(configSetArgs),
RunE: func(_ *cobra.Command, args []string) error {
path, err := ResolveConfigPath()
if err != nil {
return err
}
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 := yaml.Marshal(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
},
}
}
// 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) //nolint:gosec // G304: config path is operator-supplied
if err != nil {
return nil, fmt.Errorf("reading config file: %w", err)
}
var root yaml.Node
err = yaml.Unmarshal(data, &root)
if 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, errEmptyConfig
}
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("%w: %s",
errKeyNotFound, strings.Join(keys[:i+1], "."))
}
case yaml.SequenceNode:
idx, err := strconv.Atoi(key)
if err != nil {
return nil, fmt.Errorf("%w: %s",
errNeedNumericIndex, strings.Join(keys[:i], "."))
}
if idx < 0 || idx >= len(node.Content) {
return nil, fmt.Errorf("%w: index %d for %s (len %d)",
errIndexOutOfRange, idx, strings.Join(keys[:i], "."),
len(node.Content))
}
node = node.Content[idx]
case yaml.DocumentNode, yaml.ScalarNode, yaml.AliasNode:
return nil, fmt.Errorf("%w: %s",
errNotMapOrList, strings.Join(keys[:i], "."))
default:
return nil, fmt.Errorf("%w: %s",
errNotMapOrList, 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:
node = yamlSetInMapping(node, key, value, last)
case yaml.SequenceNode:
next, err := yamlSetInSequence(node, keys, i, value, last)
if err != nil {
return err
}
node = next
case yaml.DocumentNode, yaml.ScalarNode, yaml.AliasNode:
return fmt.Errorf("%w: %s",
errNotMapOrList, strings.Join(keys[:i], "."))
default:
return fmt.Errorf("%w: %s",
errNotMapOrList, strings.Join(keys[:i], "."))
}
}
return nil
}
// yamlSetInMapping resolves (creating if needed) the value node for key
// within a mapping node, setting it to value when it is the final path
// element, and returns the node to descend into.
func yamlSetInMapping(node *yaml.Node, key, value string, last bool) *yaml.Node {
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)
}
return valueNode
}
// yamlSetInSequence indexes (or appends to) a sequence node using the
// numeric path element keys[i], setting the element to value when it is
// the final path element, and returns the node to descend into.
func yamlSetInSequence(
node *yaml.Node, keys []string, i int, value string, last bool,
) (*yaml.Node, error) {
idx, err := strconv.Atoi(keys[i])
if err != nil {
return nil, fmt.Errorf("%w: %s",
errNeedNumericIndex, strings.Join(keys[:i], "."))
}
if idx < 0 || idx > len(node.Content) {
return nil, fmt.Errorf("%w: index %d for %s (len %d)",
errIndexOutOfRange, 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)
}
return node.Content[idx], 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,
// VAULTIK_CONFIG env, and the platform default.
func configPathForInit() string {
if rootFlags.ConfigPath != "" {
return rootFlags.ConfigPath
}
if envPath := os.Getenv("VAULTIK_CONFIG"); envPath != "" {
return envPath
}
return DefaultConfigPath()
}

View File

@@ -1,197 +0,0 @@
package cli //nolint:testpackage // exercises unexported yamlPathGet/yamlPathSet
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) {
t.Parallel()
var cfg config.Config
err := yaml.Unmarshal([]byte(defaultConfigTemplate), &cfg)
if 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
err := yaml.Unmarshal([]byte(testYAML), &root)
if err != nil {
t.Fatalf("parsing test yaml: %v", err)
}
return &root
}
func TestYAMLPathGet(t *testing.T) {
t.Parallel()
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) {
t.Parallel()
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) {
t.Parallel()
root := parseTestYAML(t)
// Overwrite existing nested value
err := yamlPathSet(root, splitPath("s3.bucket"), "newbucket")
if err != nil {
t.Fatalf("set s3.bucket: %v", err)
}
// Create new nested key with intermediate map
err = yamlPathSet(root, splitPath("s3.endpoint"), "s3.example.com")
if err != nil {
t.Fatalf("set s3.endpoint: %v", err)
}
err = yamlPathSet(root, splitPath("newmap.newkey"), "val")
if err != nil {
t.Fatalf("set newmap.newkey: %v", err)
}
// Overwrite a sequence element and append a new one
err = yamlPathSet(root, splitPath("age_recipients.0"), "age1bbb")
if err != nil {
t.Fatalf("set age_recipients.0: %v", err)
}
err = yamlPathSet(root, splitPath("age_recipients.1"), "age1ccc")
if err != nil {
t.Fatalf("append age_recipients.1: %v", err)
}
err = yamlPathSet(root, splitPath("age_recipients.5"), "age1ddd")
if 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)
wants := []string{
"newbucket", "s3.example.com", "newkey: val",
"# top comment", "# inline comment", "age1bbb", "age1ccc",
}
for _, want := range wants {
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)
}

View File

@@ -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,37 +18,32 @@ func NewDatabaseCommand() *cobra.Command {
} }
cmd.AddCommand( cmd.AddCommand(
newDatabaseDeleteCommand(), newDatabasePurgeCommand(),
) )
return cmd return cmd
} }
// newDatabaseDeleteCommand creates the database delete command. // newDatabasePurgeCommand creates the database purge command
// (Renamed from "purge"; the operation removes the SQLite file func newDatabasePurgeCommand() *cobra.Command {
// entirely, which is a delete, not a purge of content.)
func newDatabaseDeleteCommand() *cobra.Command {
var force bool var force bool
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "delete", Use: "purge",
Short: "Delete the local state database file", Short: "Delete the local state database",
Long: `Completely removes the local SQLite state database. Long: `Completely removes the local SQLite state database.
This will erase all local tracking of: This will erase all local tracking of:
- File metadata and change detection state - File metadata and change detection state
- Chunk and blob mappings - Chunk and blob mappings
- Local snapshot records - Local snapshot records
- The storage-binding record
The remote storage is NOT affected. After deletion, the next backup The remote storage is NOT affected. After purging, the next backup will
will perform a full scan and re-deduplicate against existing remote perform a full scan and re-deduplicate against existing remote blobs.
blobs, and the local index will re-bind to the currently configured
storage destination on that run.
Use --force to skip the confirmation prompt.`, Use --force to skip the confirmation prompt.`,
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: func(_ *cobra.Command, _ []string) error { RunE: func(cmd *cobra.Command, args []string) error {
// Resolve config path // Resolve config path
configPath, err := ResolveConfigPath() configPath, err := ResolveConfigPath()
if err != nil { if err != nil {
@@ -64,33 +59,24 @@ Use --force to skip the confirmation prompt.`,
dbPath := cfg.IndexPath dbPath := cfg.IndexPath
// Check if database exists // Check if database exists
_, err = os.Stat(dbPath) if _, err := os.Stat(dbPath); os.IsNotExist(err) {
if os.IsNotExist(err) { fmt.Printf("Database does not exist: %s\n", dbPath)
_, _ = fmt.Fprintf(os.Stdout, "Database does not exist: %s\n", dbPath)
return nil return nil
} }
// Confirm unless --force // Confirm unless --force
if !force { if !force {
_, _ = fmt.Fprintf(os.Stdout, fmt.Printf("This will delete the local state database at:\n %s\n\n", dbPath)
"This will delete the local state database at:\n %s\n\n", dbPath) fmt.Print("Are you sure? Type 'yes' to confirm: ")
_, _ = fmt.Fprint(os.Stdout, "Are you sure? Type 'yes' to confirm: ")
var confirm string var confirm string
if _, err := fmt.Scanln(&confirm); err != nil || confirm != "yes" {
_, err = fmt.Scanln(&confirm) fmt.Println("Aborted.")
if err != nil || confirm != "yes" {
_, _ = fmt.Fprintln(os.Stdout, "Aborted.")
//nolint:nilerr // a failed/aborted confirmation is a clean abort
return nil return nil
} }
} }
// Delete the database file // Delete the database file
err = os.Remove(dbPath) if err := os.Remove(dbPath); err != nil {
if err != nil {
return fmt.Errorf("failed to delete database: %w", err) return fmt.Errorf("failed to delete database: %w", err)
} }
@@ -102,11 +88,10 @@ Use --force to skip the confirmation prompt.`,
rootFlags := GetRootFlags() rootFlags := GetRootFlags()
if !rootFlags.Quiet { if !rootFlags.Quiet {
_, _ = fmt.Fprintf(os.Stdout, "Database deleted: %s\n", dbPath) fmt.Printf("Database purged: %s\n", dbPath)
} }
log.Info("Local state database deleted", "path", dbPath) log.Info("Local state database purged", "path", dbPath)
return nil return nil
}, },
} }

View File

@@ -1,7 +1,6 @@
package cli package cli
import ( import (
"errors"
"fmt" "fmt"
"regexp" "regexp"
"strconv" "strconv"
@@ -9,21 +8,6 @@ import (
"time" "time"
) )
// Approximate lengths of the extended calendar units accepted by
// parseDuration.
const (
durationDay = 24 * time.Hour
durationWeek = 7 * durationDay
durationMonth = 30 * durationDay
durationYear = 365 * durationDay
)
var (
errNegativeDuration = errors.New("negative durations are not supported")
errInvalidDuration = errors.New("invalid duration format")
errUnknownTimeUnit = errors.New("unknown time unit")
)
// parseDuration parses duration strings. Supports standard Go duration format // parseDuration parses duration strings. Supports standard Go duration format
// (e.g., "3h30m", "1h45m30s") as well as extended units: // (e.g., "3h30m", "1h45m30s") as well as extended units:
// - d: days (e.g., "30d", "7d") // - d: days (e.g., "30d", "7d")
@@ -34,15 +18,14 @@ var (
// Can combine units: "1y6mo", "2w3d", "1d12h30m" // Can combine units: "1y6mo", "2w3d", "1d12h30m"
func parseDuration(s string) (time.Duration, error) { func parseDuration(s string) (time.Duration, error) {
// First try standard Go duration parsing // First try standard Go duration parsing
d, err := time.ParseDuration(s) if d, err := time.ParseDuration(s); err == nil {
if err == nil {
return d, nil return d, nil
} }
// Extended duration parsing // Extended duration parsing
// Check for negative values // Check for negative values
if strings.HasPrefix(strings.TrimSpace(s), "-") { if strings.HasPrefix(strings.TrimSpace(s), "-") {
return 0, errNegativeDuration return 0, fmt.Errorf("negative durations are not supported")
} }
// Pattern matches: number + unit, repeated // Pattern matches: number + unit, repeated
@@ -50,7 +33,7 @@ func parseDuration(s string) (time.Duration, error) {
matches := re.FindAllStringSubmatch(s, -1) matches := re.FindAllStringSubmatch(s, -1)
if len(matches) == 0 { if len(matches) == 0 {
return 0, fmt.Errorf("%w: %q", errInvalidDuration, s) return 0, fmt.Errorf("invalid duration format: %q", s)
} }
var total time.Duration var total time.Duration
@@ -64,9 +47,44 @@ func parseDuration(s string) (time.Duration, error) {
return 0, fmt.Errorf("invalid number %q: %w", valueStr, err) return 0, fmt.Errorf("invalid number %q: %w", valueStr, err)
} }
d, err := durationForUnit(value, unit) var d time.Duration
if err != nil { switch unit {
return 0, err // Standard time units
case "ns", "nanosecond", "nanoseconds":
d = time.Duration(value)
case "us", "µs", "microsecond", "microseconds":
d = time.Duration(value * float64(time.Microsecond))
case "ms", "millisecond", "milliseconds":
d = time.Duration(value * float64(time.Millisecond))
case "s", "sec", "second", "seconds":
d = time.Duration(value * float64(time.Second))
case "m", "min", "minute", "minutes":
d = time.Duration(value * float64(time.Minute))
case "h", "hr", "hour", "hours":
d = time.Duration(value * float64(time.Hour))
// Extended units
case "d", "day", "days":
d = time.Duration(value * float64(24*time.Hour))
case "w", "week", "weeks":
d = time.Duration(value * float64(7*24*time.Hour))
case "mo", "month", "months":
// Using 30 days as approximation
d = time.Duration(value * float64(30*24*time.Hour))
case "y", "year", "years":
// Using 365 days as approximation
d = time.Duration(value * float64(365*24*time.Hour))
default:
// Try parsing as standard Go duration unit
testStr := fmt.Sprintf("1%s", unit)
if _, err := time.ParseDuration(testStr); err == nil {
// It's a valid Go duration unit, parse the full value
fullStr := fmt.Sprintf("%g%s", value, unit)
if d, err = time.ParseDuration(fullStr); err != nil {
return 0, fmt.Errorf("invalid duration %q: %w", fullStr, err)
}
} else {
return 0, fmt.Errorf("unknown time unit %q", unit)
}
} }
total += d total += d
@@ -74,53 +92,3 @@ func parseDuration(s string) (time.Duration, error) {
return total, nil return total, nil
} }
// durationForUnit converts a value with a (case-normalized) unit suffix
// into a time.Duration, accepting Go's standard units plus the extended
// calendar units.
func durationForUnit(value float64, unit string) (time.Duration, error) {
switch unit {
// Standard time units
case "ns", "nanosecond", "nanoseconds":
return time.Duration(value), nil
case "us", "µs", "microsecond", "microseconds":
return time.Duration(value * float64(time.Microsecond)), nil
case "ms", "millisecond", "milliseconds":
return time.Duration(value * float64(time.Millisecond)), nil
case "s", "sec", "second", "seconds":
return time.Duration(value * float64(time.Second)), nil
case "m", "min", "minute", "minutes":
return time.Duration(value * float64(time.Minute)), nil
case "h", "hr", "hour", "hours":
return time.Duration(value * float64(time.Hour)), nil
// Extended units
case "d", "day", "days":
return time.Duration(value * float64(durationDay)), nil
case "w", "week", "weeks":
return time.Duration(value * float64(durationWeek)), nil
case "mo", "month", "months":
// Using 30 days as approximation
return time.Duration(value * float64(durationMonth)), nil
case "y", "year", "years":
// Using 365 days as approximation
return time.Duration(value * float64(durationYear)), nil
default:
// Try parsing as standard Go duration unit
testStr := "1" + unit
_, err := time.ParseDuration(testStr)
if err != nil {
return 0, fmt.Errorf("%w: %q", errUnknownTimeUnit, unit)
}
// It's a valid Go duration unit, parse the full value
fullStr := fmt.Sprintf("%g%s", value, unit)
d, err := time.ParseDuration(fullStr)
if err != nil {
return 0, fmt.Errorf("invalid duration %q: %w", fullStr, err)
}
return d, nil
}
}

View File

@@ -1,47 +1,20 @@
package cli //nolint:testpackage // needs access to unexported parseDuration package cli
import ( import (
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
type parseDurationCase struct { func TestParseDuration(t *testing.T) {
tests := []struct {
name string name string
input string input string
expected time.Duration expected time.Duration
wantErr bool wantErr bool
} }{
// Standard Go durations
// runParseDurationCases executes a table of parseDuration cases as
// parallel subtests.
func runParseDurationCases(t *testing.T, tests []parseDurationCase) {
t.Helper()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := parseDuration(tt.input)
if tt.wantErr {
require.Error(t, err, "expected error for input %q", tt.input)
return
}
require.NoError(t, err, "unexpected error for input %q", tt.input)
assert.Equal(t, tt.expected, got, "duration mismatch for input %q", tt.input)
})
}
}
func TestParseDurationStandard(t *testing.T) {
t.Parallel()
runParseDurationCases(t, []parseDurationCase{
{ {
name: "standard seconds", name: "standard seconds",
input: "30s", input: "30s",
@@ -72,13 +45,6 @@ func TestParseDurationStandard(t *testing.T) {
input: "1s500ms", input: "1s500ms",
expected: 1*time.Second + 500*time.Millisecond, expected: 1*time.Second + 500*time.Millisecond,
}, },
})
}
func TestParseDurationExtendedUnits(t *testing.T) {
t.Parallel()
runParseDurationCases(t, []parseDurationCase{
// Extended units - days // Extended units - days
{ {
name: "single day", name: "single day",
@@ -148,13 +114,6 @@ func TestParseDurationExtendedUnits(t *testing.T) {
input: "1year", input: "1year",
expected: 365 * 24 * time.Hour, expected: 365 * 24 * time.Hour,
}, },
})
}
func TestParseDurationCombinedAndErrors(t *testing.T) {
t.Parallel()
runParseDurationCases(t, []parseDurationCase{
// Combined extended units // Combined extended units
{ {
name: "weeks and days", name: "weeks and days",
@@ -174,9 +133,7 @@ func TestParseDurationCombinedAndErrors(t *testing.T) {
{ {
name: "complex combination", name: "complex combination",
input: "1y2mo3w4d5h6m7s", input: "1y2mo3w4d5h6m7s",
expected: 365*24*time.Hour + 2*30*24*time.Hour + expected: 365*24*time.Hour + 2*30*24*time.Hour + 3*7*24*time.Hour + 4*24*time.Hour + 5*time.Hour + 6*time.Minute + 7*time.Second,
3*7*24*time.Hour + 4*24*time.Hour +
5*time.Hour + 6*time.Minute + 7*time.Second,
}, },
{ {
name: "with spaces", name: "with spaces",
@@ -220,12 +177,24 @@ func TestParseDurationCombinedAndErrors(t *testing.T) {
input: "-5d", input: "-5d",
wantErr: true, wantErr: true,
}, },
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseDuration(tt.input)
if tt.wantErr {
assert.Error(t, err, "expected error for input %q", tt.input)
return
}
assert.NoError(t, err, "unexpected error for input %q", tt.input)
assert.Equal(t, tt.expected, got, "duration mismatch for input %q", tt.input)
}) })
}
} }
func TestParseDurationSpecialCases(t *testing.T) { func TestParseDurationSpecialCases(t *testing.T) {
t.Parallel()
// Test that standard Go durations work exactly as expected // Test that standard Go durations work exactly as expected
standardDurations := []string{ standardDurations := []string{
"300ms", "300ms",
@@ -239,17 +208,15 @@ func TestParseDurationSpecialCases(t *testing.T) {
for _, d := range standardDurations { for _, d := range standardDurations {
expected, err := time.ParseDuration(d) expected, err := time.ParseDuration(d)
require.NoError(t, err) assert.NoError(t, err)
got, err := parseDuration(d) got, err := parseDuration(d)
require.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, expected, got, "standard duration %q should parse identically", d) assert.Equal(t, expected, got, "standard duration %q should parse identically", d)
} }
} }
func TestParseDurationRealWorldExamples(t *testing.T) { func TestParseDurationRealWorldExamples(t *testing.T) {
t.Parallel()
// Test real-world snapshot purge scenarios // Test real-world snapshot purge scenarios
tests := []struct { tests := []struct {
description string description string
@@ -285,15 +252,12 @@ func TestParseDurationRealWorldExamples(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) { t.Run(tt.description, func(t *testing.T) {
t.Parallel()
got, err := parseDuration(tt.input) got, err := parseDuration(tt.input)
require.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, tt.olderThan, got) assert.Equal(t, tt.olderThan, got)
// Verify the duration makes sense for snapshot purging // Verify the duration makes sense for snapshot purging
assert.Greater(t, got, time.Hour, assert.Greater(t, got, time.Hour, "snapshot purge duration should be at least an hour")
"snapshot purge duration should be at least an hour")
}) })
} }
} }

View File

@@ -2,76 +2,14 @@ package cli
import ( import (
"os" "os"
"strings"
"time"
"sneak.berlin/go/vaultik/internal/globals"
"sneak.berlin/go/vaultik/internal/ui"
) )
// shortCommitLen is the number of git commit hash characters shown in // CLIEntry is the main entry point for the CLI application.
// the startup banner. // It creates the root command, executes it, and exits with status 1
const shortCommitLen = 12 // if an error occurs. This function should be called from main().
func CLIEntry() {
// Entry is the main entry point for the CLI application.
// It prints the startup banner (unless a quiet flag is present in os.Args),
// executes the root cobra command, and routes any returned error through
// the ui.Writer so the user sees a properly formatted "🛑 ERROR:" line.
func Entry() {
if !bannerSuppressedInArgs(os.Args[1:]) {
short := globals.Commit
if len(short) > shortCommitLen {
short = short[:shortCommitLen]
}
writeStartupBanner(ui.New(os.Stdout), time.Now().UTC(), short)
}
rootCmd := NewRootCommand() rootCmd := NewRootCommand()
rootCmd.SilenceErrors = true if err := rootCmd.Execute(); err != nil {
err := rootCmd.Execute()
if err != nil {
ReportErrorf("%s", err.Error())
os.Exit(1) os.Exit(1)
} }
} }
// ReportErrorf emits a user-facing error to stderr in the standard
// 🛑 ERROR: format. Use it from goroutine error paths (where returning
// an error to cobra isn't an option) and anywhere else a CLI command
// must surface a failure outside the normal RunE return path.
func ReportErrorf(format string, args ...any) {
ui.New(os.Stderr).Errorf(format, args...)
}
// bannerSuppressedInArgs reports whether any of args is a flag that
// should suppress the startup banner (--quiet/-q/--cron). Stops at the
// "--" argument terminator. Recognizes both long forms and short -q,
// including combined short flags like "-qv".
func bannerSuppressedInArgs(args []string) bool {
for _, a := range args {
if a == "--" {
return false
}
switch a {
case "--quiet", "-q", "--cron":
return true
}
if strings.HasPrefix(a, "--quiet=") || strings.HasPrefix(a, "--cron=") {
return true
}
// Combined short flags like -qv or -vq.
if len(a) > 1 && a[0] == '-' && a[1] != '-' {
for _, c := range a[1:] {
if c == 'q' {
return true
}
}
}
}
return false
}

View File

@@ -1,18 +1,14 @@
package cli_test package cli
import ( import (
"testing" "testing"
"sneak.berlin/go/vaultik/internal/cli"
) )
// TestCLIEntry ensures the CLI can be imported and basic initialization works // TestCLIEntry ensures the CLI can be imported and basic initialization works
func TestCLIEntry(t *testing.T) { func TestCLIEntry(t *testing.T) {
t.Parallel()
// This test primarily serves as a compilation test // This test primarily serves as a compilation test
// to ensure all imports resolve correctly // to ensure all imports resolve correctly
cmd := cli.NewRootCommand() cmd := NewRootCommand()
if cmd == nil { if cmd == nil {
t.Fatal("NewRootCommand() returned nil") t.Fatal("NewRootCommand() returned nil")
} }
@@ -22,20 +18,15 @@ func TestCLIEntry(t *testing.T) {
} }
// Verify all subcommands are registered // Verify all subcommands are registered
expectedCommands := []string{ expectedCommands := []string{"snapshot", "store", "restore", "prune", "verify", "info", "version"}
"config", "snapshot", "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() {
if cmd.Use == expected || cmd.Name() == expected { if cmd.Use == expected || cmd.Name() == expected {
found = true found = true
break break
} }
} }
if !found { if !found {
t.Errorf("Expected command '%s' not found", expected) t.Errorf("Expected command '%s' not found", expected)
} }
@@ -47,20 +38,15 @@ func TestCLIEntry(t *testing.T) {
t.Errorf("Failed to find snapshot command: %v", err) t.Errorf("Failed to find snapshot command: %v", err)
} else { } else {
// Check snapshot subcommands // Check snapshot subcommands
expectedSubCommands := []string{ expectedSubCommands := []string{"create", "list", "purge", "verify"}
"create", "list", "purge", "verify", "remove", "restore",
}
for _, expected := range expectedSubCommands { for _, expected := range expectedSubCommands {
found := false found := false
for _, subcmd := range snapshotCmd.Commands() { for _, subcmd := range snapshotCmd.Commands() {
if subcmd.Use == expected || subcmd.Name() == expected { if subcmd.Use == expected || subcmd.Name() == expected {
found = true found = true
break break
} }
} }
if !found { if !found {
t.Errorf("Expected snapshot subcommand '%s' not found", expected) t.Errorf("Expected snapshot subcommand '%s' not found", expected)
} }

View File

@@ -2,13 +2,12 @@ package cli
import ( import (
"context" "context"
"errors"
"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
@@ -23,7 +22,7 @@ func NewInfoCommand() *cobra.Command {
- Encryption configuration (recipients) - Encryption configuration (recipients)
- Local database statistics`, - Local database statistics`,
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error { RunE: func(cmd *cobra.Command, args []string) error {
// Use unified config resolution // Use unified config resolution
configPath, err := ResolveConfigPath() configPath, err := ResolveConfigPath()
if err != nil { if err != nil {
@@ -32,10 +31,9 @@ func NewInfoCommand() *cobra.Command {
// Use the app framework // Use the app framework
rootFlags := GetRootFlags() rootFlags := GetRootFlags()
return RunWithApp(cmd.Context(), AppOptions{ return RunWithApp(cmd.Context(), AppOptions{
ConfigPath: configPath, ConfigPath: configPath,
LogOptions: log.Options{ LogOptions: log.LogOptions{
Verbose: rootFlags.Verbose, Verbose: rootFlags.Verbose,
Debug: rootFlags.Debug, Debug: rootFlags.Debug,
Quiet: rootFlags.Quiet, Quiet: rootFlags.Quiet,
@@ -44,28 +42,22 @@ func NewInfoCommand() *cobra.Command {
Invokes: []fx.Option{ Invokes: []fx.Option{
fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) { fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) {
lc.Append(fx.Hook{ lc.Append(fx.Hook{
OnStart: func(_ context.Context) error { OnStart: func(ctx context.Context) error {
go func() { go func() {
err := v.ShowInfo() if err := v.ShowInfo(); err != nil {
if err != nil { if err != context.Canceled {
if !errors.Is(err, context.Canceled) {
log.Error("Failed to show info", "error", err) log.Error("Failed to show info", "error", err)
ReportErrorf("Failed to show info: %v", err)
os.Exit(1) os.Exit(1)
} }
} }
if err := v.Shutdowner.Shutdown(); err != nil {
err = v.Shutdowner.Shutdown()
if err != nil {
log.Error("Failed to shutdown", "error", err) log.Error("Failed to shutdown", "error", err)
} }
}() }()
return nil return nil
}, },
OnStop: func(_ context.Context) error { OnStop: func(ctx context.Context) error {
v.Cancel() v.Cancel()
return nil return nil
}, },
}) })

View File

@@ -2,13 +2,12 @@ package cli
import ( import (
"context" "context"
"errors"
"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
@@ -17,21 +16,16 @@ func NewPruneCommand() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "prune", Use: "prune",
Short: "Tidy local database and remote storage", Short: "Remove unreferenced blobs",
Long: `Removes orphaned data from both the local index database and Long: `Removes blobs that are not referenced by any snapshot.
unreferenced blobs from the backup destination store.
Local cleanup drops incomplete snapshots and any files, chunks, or This command scans all snapshots and their manifests to build a list of
blobs no longer referenced by a completed snapshot. Remote cleanup referenced blobs, then removes any blobs in storage that are not in this list.
scans every snapshot manifest in the destination store, builds the
set of still-referenced blob hashes, and deletes any blob not in that
set.
Snapshot create --prune and snapshot remove run the same cleanup Use this command after deleting snapshots with 'vaultik purge' to reclaim
automatically; this command is the manual entry point for the same storage space.`,
work (e.g. after a crashed backup or to reclaim storage).`,
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error { RunE: func(cmd *cobra.Command, args []string) error {
// Use unified config resolution // Use unified config resolution
configPath, err := ResolveConfigPath() configPath, err := ResolveConfigPath()
if err != nil { if err != nil {
@@ -40,10 +34,9 @@ work (e.g. after a crashed backup or to reclaim storage).`,
// Use the app framework like other commands // Use the app framework like other commands
rootFlags := GetRootFlags() rootFlags := GetRootFlags()
return RunWithApp(cmd.Context(), AppOptions{ return RunWithApp(cmd.Context(), AppOptions{
ConfigPath: configPath, ConfigPath: configPath,
LogOptions: log.Options{ LogOptions: log.LogOptions{
Verbose: rootFlags.Verbose, Verbose: rootFlags.Verbose,
Debug: rootFlags.Debug, Debug: rootFlags.Debug,
Quiet: rootFlags.Quiet || opts.JSON, Quiet: rootFlags.Quiet || opts.JSON,
@@ -52,35 +45,29 @@ work (e.g. after a crashed backup or to reclaim storage).`,
Invokes: []fx.Option{ Invokes: []fx.Option{
fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) { fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) {
lc.Append(fx.Hook{ lc.Append(fx.Hook{
OnStart: func(_ context.Context) error { OnStart: func(ctx context.Context) error {
// Start the prune operation in a goroutine // Start the prune operation in a goroutine
go func() { go func() {
// Run the prune operation // Run the prune operation
err := v.Prune(opts) if err := v.PruneBlobs(opts); err != nil {
if err != nil { if err != context.Canceled {
if !errors.Is(err, context.Canceled) {
if !opts.JSON { if !opts.JSON {
log.Error("Prune operation failed", "error", err) log.Error("Prune operation failed", "error", err)
ReportErrorf("Prune failed: %v", err)
} }
os.Exit(1) os.Exit(1)
} }
} }
// Shutdown the app when prune completes // Shutdown the app when prune completes
err = v.Shutdowner.Shutdown() if err := v.Shutdowner.Shutdown(); err != nil {
if err != nil {
log.Error("Failed to shutdown", "error", err) log.Error("Failed to shutdown", "error", err)
} }
}() }()
return nil return nil
}, },
OnStop: func(_ context.Context) error { OnStop: func(ctx context.Context) error {
log.Debug("Stopping prune operation") log.Debug("Stopping prune operation")
v.Cancel() v.Cancel()
return nil return nil
}, },
}) })

101
internal/cli/purge.go Normal file
View File

@@ -0,0 +1,101 @@
package cli
import (
"context"
"fmt"
"os"
"git.eeqj.de/sneak/vaultik/internal/log"
"git.eeqj.de/sneak/vaultik/internal/vaultik"
"github.com/spf13/cobra"
"go.uber.org/fx"
)
// NewPurgeCommand creates the purge command
func NewPurgeCommand() *cobra.Command {
opts := &vaultik.SnapshotPurgeOptions{}
cmd := &cobra.Command{
Use: "purge",
Short: "Purge old snapshots",
Long: `Removes snapshots based on age or count criteria.
This command allows you to:
- Keep only the latest snapshot per name (--keep-latest)
- Remove snapshots older than a specific duration (--older-than)
- Filter to a specific snapshot name (--name)
When --keep-latest is used, retention is applied per snapshot name. For example,
if you have snapshots named "home" and "system", --keep-latest keeps the most
recent of each.
Use --name to restrict the purge to a single snapshot name.
Config is located at /etc/vaultik/config.yml by default, but can be overridden by
specifying a path using --config or by setting VAULTIK_CONFIG to a path.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
// Validate flags
if !opts.KeepLatest && opts.OlderThan == "" {
return fmt.Errorf("must specify either --keep-latest or --older-than")
}
if opts.KeepLatest && opts.OlderThan != "" {
return fmt.Errorf("cannot specify both --keep-latest and --older-than")
}
// Use unified config resolution
configPath, err := ResolveConfigPath()
if err != nil {
return err
}
// Use the app framework like other commands
rootFlags := GetRootFlags()
return RunWithApp(cmd.Context(), AppOptions{
ConfigPath: configPath,
LogOptions: log.LogOptions{
Verbose: rootFlags.Verbose,
Debug: rootFlags.Debug,
Quiet: rootFlags.Quiet,
},
Modules: []fx.Option{},
Invokes: []fx.Option{
fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) {
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
// Start the purge operation in a goroutine
go func() {
// Run the purge operation
if err := v.PurgeSnapshotsWithOptions(opts); err != nil {
if err != context.Canceled {
log.Error("Purge operation failed", "error", err)
os.Exit(1)
}
}
// Shutdown the app when purge completes
if err := v.Shutdowner.Shutdown(); err != nil {
log.Error("Failed to shutdown", "error", err)
}
}()
return nil
},
OnStop: func(ctx context.Context) error {
log.Debug("Stopping purge operation")
v.Cancel()
return nil
},
})
}),
},
})
},
}
cmd.Flags().BoolVar(&opts.KeepLatest, "keep-latest", false, "Keep only the latest snapshot per name")
cmd.Flags().StringVar(&opts.OlderThan, "older-than", "", "Remove snapshots older than duration (e.g. 30d, 6m, 1y)")
cmd.Flags().BoolVar(&opts.Force, "force", false, "Skip confirmation prompts")
cmd.Flags().StringVar(&opts.Name, "name", "", "Filter purge to a specific snapshot name")
return cmd
}

View File

@@ -2,19 +2,14 @@ package cli
import ( import (
"context" "context"
"errors"
"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"
) )
// errNukeNeedsForce guards the destructive 'remote nuke' subcommand.
var errNukeNeedsForce = errors.New(
"remote nuke requires --force (this deletes ALL remote snapshots and blobs)")
// NewRemoteCommand creates the remote command and subcommands // NewRemoteCommand creates the remote command and subcommands
func NewRemoteCommand() *cobra.Command { func NewRemoteCommand() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
@@ -25,38 +20,6 @@ func NewRemoteCommand() *cobra.Command {
// Add subcommands // Add subcommands
cmd.AddCommand(newRemoteInfoCommand()) cmd.AddCommand(newRemoteInfoCommand())
cmd.AddCommand(newRemoteNukeCommand())
return cmd
}
// newRemoteNukeCommand creates the 'remote nuke' subcommand.
func newRemoteNukeCommand() *cobra.Command {
var force bool
cmd := &cobra.Command{
Use: "nuke",
Short: "Delete ALL snapshot metadata and blobs from the backup destination store",
Long: `Removes every snapshot's metadata and every blob from remote
storage. After this command completes successfully the bucket prefix is
empty and the next backup starts from scratch.
This is destructive and irreversible. Requires --force.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if !force {
return errNukeNeedsForce
}
return runVaultikApp(cmd, false, false, "Remote nuke failed",
func(v *vaultik.Vaultik) error {
return v.NukeRemote(true)
})
},
}
cmd.Flags().BoolVar(&force, "force", false,
"Required: confirm destruction of ALL remote data")
return cmd return cmd
} }
@@ -74,7 +37,7 @@ func newRemoteInfoCommand() *cobra.Command {
- Count and size of referenced blobs (from all manifests) - Count and size of referenced blobs (from all manifests)
- Count and size of orphaned blobs (not referenced by any manifest)`, - Count and size of orphaned blobs (not referenced by any manifest)`,
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error { RunE: func(cmd *cobra.Command, args []string) error {
// Use unified config resolution // Use unified config resolution
configPath, err := ResolveConfigPath() configPath, err := ResolveConfigPath()
if err != nil { if err != nil {
@@ -82,10 +45,9 @@ func newRemoteInfoCommand() *cobra.Command {
} }
rootFlags := GetRootFlags() rootFlags := GetRootFlags()
return RunWithApp(cmd.Context(), AppOptions{ return RunWithApp(cmd.Context(), AppOptions{
ConfigPath: configPath, ConfigPath: configPath,
LogOptions: log.Options{ LogOptions: log.LogOptions{
Verbose: rootFlags.Verbose, Verbose: rootFlags.Verbose,
Debug: rootFlags.Debug, Debug: rootFlags.Debug,
Quiet: rootFlags.Quiet || jsonOutput, Quiet: rootFlags.Quiet || jsonOutput,
@@ -94,31 +56,24 @@ func newRemoteInfoCommand() *cobra.Command {
Invokes: []fx.Option{ Invokes: []fx.Option{
fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) { fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) {
lc.Append(fx.Hook{ lc.Append(fx.Hook{
OnStart: func(_ context.Context) error { OnStart: func(ctx context.Context) error {
go func() { go func() {
err := v.RemoteInfo(jsonOutput) if err := v.RemoteInfo(jsonOutput); err != nil {
if err != nil { if err != context.Canceled {
if !errors.Is(err, context.Canceled) {
if !jsonOutput { if !jsonOutput {
log.Error("Failed to get remote info", "error", err) log.Error("Failed to get remote info", "error", err)
ReportErrorf("Failed to get remote info: %v", err)
} }
os.Exit(1) os.Exit(1)
} }
} }
if err := v.Shutdowner.Shutdown(); err != nil {
err = v.Shutdowner.Shutdown()
if err != nil {
log.Error("Failed to shutdown", "error", err) log.Error("Failed to shutdown", "error", err)
} }
}() }()
return nil return nil
}, },
OnStop: func(_ context.Context) error { OnStop: func(ctx context.Context) error {
v.Cancel() v.Cancel()
return nil return nil
}, },
}) })

View File

@@ -2,22 +2,16 @@ package cli
import ( import (
"context" "context"
"errors"
"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"
) )
// restoreMinArgs is the minimum positional argument count of
// `snapshot restore <snapshot-id> <target-dir> [paths...]`.
const restoreMinArgs = 2
// RestoreOptions contains options for the restore command // RestoreOptions contains options for the restore command
type RestoreOptions struct { type RestoreOptions struct {
TargetDir string TargetDir string
@@ -34,45 +28,40 @@ type RestoreApp struct {
Shutdowner fx.Shutdowner Shutdowner fx.Shutdowner
} }
// newSnapshotRestoreCommand creates the 'snapshot restore' subcommand // NewRestoreCommand creates the restore command
func newSnapshotRestoreCommand() *cobra.Command { func NewRestoreCommand() *cobra.Command {
opts := &RestoreOptions{} opts := &RestoreOptions{}
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "restore <snapshot-id> <target-dir> [paths...]", Use: "restore <snapshot-id> <target-dir> [paths...]",
Short: "Restore files from a snapshot", Short: "Restore files from backup",
Long: `Download and decrypt files from a backup snapshot. Long: `Download and decrypt files from a backup snapshot.
This command will restore files from the specified snapshot to the This command will restore files from the specified snapshot to the target directory.
target directory.
If no paths are specified, all files are restored. If no paths are specified, all files are restored.
If paths are specified, only matching files/directories are restored. If paths are specified, only matching files/directories are restored.
Requires the VAULTIK_AGE_SECRET_KEY environment variable to be set with Requires the VAULTIK_AGE_SECRET_KEY environment variable to be set with the age private key.
the age private key.
Examples: Examples:
# Restore entire snapshot # Restore entire snapshot
vaultik snapshot restore myhost_docs_2025-01-01T12:00:00Z /restore vaultik restore myhost_docs_2025-01-01T12:00:00Z /restore
# Restore specific file # Restore specific file
vaultik snapshot restore myhost_docs_2025-01-01T12:00:00Z /restore \ vaultik restore myhost_docs_2025-01-01T12:00:00Z /restore /home/user/important.txt
/home/user/important.txt
# Restore specific directory # Restore specific directory
vaultik snapshot restore myhost_docs_2025-01-01T12:00:00Z /restore \ vaultik restore myhost_docs_2025-01-01T12:00:00Z /restore /home/user/documents/
/home/user/documents/
# Restore and verify all files # Restore and verify all files
vaultik snapshot restore --verify myhost_docs_2025-01-01T12:00:00Z /restore`, vaultik restore --verify myhost_docs_2025-01-01T12:00:00Z /restore`,
Args: cobra.MinimumNArgs(restoreMinArgs), Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
return runRestore(cmd, args, opts) return runRestore(cmd, args, opts)
}, },
} }
cmd.Flags().BoolVar(&opts.Verify, "verify", false, cmd.Flags().BoolVar(&opts.Verify, "verify", false, "Verify restored files by checking chunk hashes")
"Verify restored files by checking chunk hashes")
return cmd return cmd
} }
@@ -80,10 +69,9 @@ Examples:
// runRestore parses arguments and runs the restore operation through the app framework // runRestore parses arguments and runs the restore operation through the app framework
func runRestore(cmd *cobra.Command, args []string, opts *RestoreOptions) error { func runRestore(cmd *cobra.Command, args []string, opts *RestoreOptions) error {
snapshotID := args[0] snapshotID := args[0]
opts.TargetDir = args[1] opts.TargetDir = args[1]
if len(args) > restoreMinArgs { if len(args) > 2 {
opts.Paths = args[restoreMinArgs:] opts.Paths = args[2:]
} }
// Use unified config resolution // Use unified config resolution
@@ -94,10 +82,9 @@ func runRestore(cmd *cobra.Command, args []string, opts *RestoreOptions) error {
// Use the app framework like other commands // Use the app framework like other commands
rootFlags := GetRootFlags() rootFlags := GetRootFlags()
return RunWithApp(cmd.Context(), AppOptions{ return RunWithApp(cmd.Context(), AppOptions{
ConfigPath: configPath, ConfigPath: configPath,
LogOptions: log.Options{ LogOptions: log.LogOptions{
Verbose: rootFlags.Verbose, Verbose: rootFlags.Verbose,
Debug: rootFlags.Debug, Debug: rootFlags.Debug,
Quiet: rootFlags.Quiet, Quiet: rootFlags.Quiet,
@@ -130,7 +117,7 @@ func buildRestoreInvokes(snapshotID string, opts *RestoreOptions) []fx.Option {
return []fx.Option{ return []fx.Option{
fx.Invoke(func(app *RestoreApp, lc fx.Lifecycle) { fx.Invoke(func(app *RestoreApp, lc fx.Lifecycle) {
lc.Append(fx.Hook{ lc.Append(fx.Hook{
OnStart: func(_ context.Context) error { OnStart: func(ctx context.Context) error {
// Start the restore operation in a goroutine // Start the restore operation in a goroutine
go func() { go func() {
// Run the restore operation // Run the restore operation
@@ -139,31 +126,23 @@ func buildRestoreInvokes(snapshotID string, opts *RestoreOptions) []fx.Option {
TargetDir: opts.TargetDir, TargetDir: opts.TargetDir,
Paths: opts.Paths, Paths: opts.Paths,
Verify: opts.Verify, Verify: opts.Verify,
SkipErrors: GetRootFlags().SkipErrors,
} }
if err := app.Vaultik.Restore(restoreOpts); err != nil {
err := app.Vaultik.Restore(restoreOpts) if err != context.Canceled {
if err != nil {
if !errors.Is(err, context.Canceled) {
log.Error("Restore operation failed", "error", err) log.Error("Restore operation failed", "error", err)
ReportErrorf("Restore failed: %v", err)
os.Exit(1)
} }
} }
// Shutdown the app when restore completes // Shutdown the app when restore completes
err = app.Shutdowner.Shutdown() if err := app.Shutdowner.Shutdown(); err != nil {
if err != nil {
log.Error("Failed to shutdown", "error", err) log.Error("Failed to shutdown", "error", err)
} }
}() }()
return nil return nil
}, },
OnStop: func(_ context.Context) error { OnStop: func(ctx context.Context) error {
log.Debug("Stopping restore operation") log.Debug("Stopping restore operation")
app.Vaultik.Cancel() app.Vaultik.Cancel()
return nil return nil
}, },
}) })

View File

@@ -1,19 +1,12 @@
package cli package cli
import ( import (
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath"
"strings"
"github.com/adrg/xdg"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
// errConfigNotFound is wrapped by all config-resolution failures.
var errConfigNotFound = errors.New("config file not found")
// RootFlags holds global flags that apply to all commands. // RootFlags holds global flags that apply to all commands.
// These flags are defined on the root command and inherited by all subcommands. // These flags are defined on the root command and inherited by all subcommands.
type RootFlags struct { type RootFlags struct {
@@ -21,10 +14,8 @@ type RootFlags struct {
Verbose bool Verbose bool
Debug bool Debug bool
Quiet bool Quiet bool
SkipErrors bool
} }
//nolint:gochecknoglobals // cobra persistent flags bind to package state
var rootFlags RootFlags var rootFlags RootFlags
// NewRootCommand creates the root cobra command for the vaultik CLI. // NewRootCommand creates the root cobra command for the vaultik CLI.
@@ -34,36 +25,24 @@ func NewRootCommand() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "vaultik", Use: "vaultik",
Short: "Secure incremental backup tool with asymmetric encryption", Short: "Secure incremental backup tool with asymmetric encryption",
Long: `vaultik is a secure incremental backup tool that encrypts data using age Long: `vaultik is a secure incremental backup daemon that encrypts data using age
public keys and uploads to S3-compatible storage. No private keys are needed public keys and uploads to S3-compatible storage. No private keys are needed
on the source system.`, on the source system.`,
SilenceUsage: true, SilenceUsage: true,
// Bare 'vaultik' (no subcommand): print help. The banner is
// printed once at process startup by Entry, before cobra
// parses arguments, so it appears even when cobra rejects
// args (e.g. "requires at least 2 arg(s)") and on --help.
Run: func(cmd *cobra.Command, _ []string) {
_ = cmd.Help()
},
} }
// Add global flags // Add global flags
cmd.PersistentFlags().StringVar(&rootFlags.ConfigPath, "config", "", cmd.PersistentFlags().StringVar(&rootFlags.ConfigPath, "config", "", "Path to config file (default: $VAULTIK_CONFIG or /etc/vaultik/config.yml)")
"Path to config file (default: $VAULTIK_CONFIG or platform config dir)") cmd.PersistentFlags().BoolVarP(&rootFlags.Verbose, "verbose", "v", false, "Enable verbose output")
cmd.PersistentFlags().BoolVarP(&rootFlags.Verbose, "verbose", "v", false, cmd.PersistentFlags().BoolVar(&rootFlags.Debug, "debug", false, "Enable debug output")
"Enable verbose output") cmd.PersistentFlags().BoolVarP(&rootFlags.Quiet, "quiet", "q", false, "Suppress non-error output")
cmd.PersistentFlags().BoolVar(&rootFlags.Debug, "debug", false,
"Enable debug output")
cmd.PersistentFlags().BoolVarP(&rootFlags.Quiet, "quiet", "q", false,
"Suppress non-error output")
cmd.PersistentFlags().BoolVar(&rootFlags.SkipErrors, "skip-errors", false,
"Continue past per-file errors instead of aborting "+
"(applies to snapshot create and restore)")
// Add subcommands // Add subcommands
cmd.AddCommand( cmd.AddCommand(
NewConfigCommand(), NewRestoreCommand(),
NewPruneCommand(), NewPruneCommand(),
NewVerifyCommand(),
NewStoreCommand(),
NewSnapshotCommand(), NewSnapshotCommand(),
NewInfoCommand(), NewInfoCommand(),
NewVersionCommand(), NewVersionCommand(),
@@ -81,64 +60,25 @@ func GetRootFlags() RootFlags {
} }
// ResolveConfigPath resolves the config file path from flags, environment, or default. // ResolveConfigPath resolves the config file path from flags, environment, or default.
// Search order: --config flag, VAULTIK_CONFIG env, XDG config dir, // It checks in order: 1) --config flag, 2) VAULTIK_CONFIG environment variable,
// /etc/vaultik/config.yml. // 3) default location /etc/vaultik/config.yml. Returns an error if no valid
// Explicit paths from --config and $VAULTIK_CONFIG are checked for existence // config file can be found through any of these methods.
// so the user gets a clear error instead of a downstream YAML parser failure.
func ResolveConfigPath() (string, error) { func ResolveConfigPath() (string, error) {
if path := rootFlags.ConfigPath; path != "" { // First check global flag
_, err := os.Stat(path) if rootFlags.ConfigPath != "" {
if err != nil { return rootFlags.ConfigPath, nil
return "", fmt.Errorf(
"%w: from --config: %s (run 'vaultik config init --config %s' to create it)",
errConfigNotFound, path, path)
} }
return path, nil // Then check environment variable
if envPath := os.Getenv("VAULTIK_CONFIG"); envPath != "" {
return envPath, nil
} }
if path := os.Getenv("VAULTIK_CONFIG"); path != "" { // Finally check default location
_, err := os.Stat(path) //nolint:gosec // G703: path is operator-supplied by design defaultPath := "/etc/vaultik/config.yml"
if err != nil { if _, err := os.Stat(defaultPath); err == nil {
return "", fmt.Errorf( return defaultPath, nil
"%w: from $VAULTIK_CONFIG: %s (unset VAULTIK_CONFIG, point it at "+
"an existing file, or run 'vaultik config init')",
errConfigNotFound, path)
} }
return path, nil return "", fmt.Errorf("no config file specified, VAULTIK_CONFIG not set, and %s not found", defaultPath)
}
for _, path := range defaultConfigPaths() {
_, err := os.Stat(path)
if err == nil {
return path, nil
}
}
return "", fmt.Errorf(
"%w: searched %s (run 'vaultik config init' to create the default "+
"config, or pass --config <path>)",
errConfigNotFound, strings.Join(defaultConfigPaths(), " or "))
}
// defaultConfigPaths returns the ordered list of config paths to search.
// On macOS: ~/Library/Application Support/vaultik/config.yml
// On Linux: ~/.config/vaultik/config.yml
// Fallback: /etc/vaultik/config.yml
func defaultConfigPaths() []string {
return []string{
filepath.Join(xdg.ConfigHome, "vaultik", "config.yml"),
"/etc/vaultik/config.yml",
}
}
// DefaultConfigPath returns the platform-appropriate default config path.
// Used by the init command and in help text.
func DefaultConfigPath() string {
if os.Getuid() == 0 {
return "/etc/vaultik/config.yml"
}
return filepath.Join(xdg.ConfigHome, "vaultik", "config.yml")
} }

View File

@@ -2,42 +2,15 @@ package cli
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"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"
) )
var (
errSnapshotIDRequired = errors.New("snapshot ID required")
errWrongArgCount = errors.New("wrong argument count")
errPurgeCriteriaNeeded = errors.New(
"must specify either --keep-latest or --older-than")
errPurgeCriteriaBoth = errors.New(
"cannot specify both --keep-latest and --older-than")
)
// requireSnapshotIDArg validates that exactly one positional argument
// (the snapshot ID) was supplied, printing help otherwise.
func requireSnapshotIDArg(cmd *cobra.Command, args []string) error {
if len(args) != 1 {
_ = cmd.Help()
if len(args) == 0 {
return errSnapshotIDRequired
}
return fmt.Errorf("%w: expected 1 argument, got %d",
errWrongArgCount, len(args))
}
return nil
}
// NewSnapshotCommand creates the snapshot command and subcommands // NewSnapshotCommand creates the snapshot command and subcommands
func NewSnapshotCommand() *cobra.Command { func NewSnapshotCommand() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
@@ -52,7 +25,7 @@ func NewSnapshotCommand() *cobra.Command {
cmd.AddCommand(newSnapshotPurgeCommand()) cmd.AddCommand(newSnapshotPurgeCommand())
cmd.AddCommand(newSnapshotVerifyCommand()) cmd.AddCommand(newSnapshotVerifyCommand())
cmd.AddCommand(newSnapshotRemoveCommand()) cmd.AddCommand(newSnapshotRemoveCommand())
cmd.AddCommand(newSnapshotRestoreCommand()) cmd.AddCommand(newSnapshotPruneCommand())
return cmd return cmd
} }
@@ -75,8 +48,6 @@ specifying a path using --config or by setting VAULTIK_CONFIG to a path.`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
// Pass snapshot names from args // Pass snapshot names from args
opts.Snapshots = args opts.Snapshots = args
// --skip-errors is a global flag on the root command.
opts.SkipErrors = rootFlags.SkipErrors
// Use unified config resolution // Use unified config resolution
configPath, err := ResolveConfigPath() configPath, err := ResolveConfigPath()
if err != nil { if err != nil {
@@ -85,10 +56,9 @@ specifying a path using --config or by setting VAULTIK_CONFIG to a path.`,
// Use the backup functionality from cli package // Use the backup functionality from cli package
rootFlags := GetRootFlags() rootFlags := GetRootFlags()
return RunWithApp(cmd.Context(), AppOptions{ return RunWithApp(cmd.Context(), AppOptions{
ConfigPath: configPath, ConfigPath: configPath,
LogOptions: log.Options{ LogOptions: log.LogOptions{
Verbose: rootFlags.Verbose, Verbose: rootFlags.Verbose,
Debug: rootFlags.Debug, Debug: rootFlags.Debug,
Cron: opts.Cron, Cron: opts.Cron,
@@ -98,33 +68,27 @@ specifying a path using --config or by setting VAULTIK_CONFIG to a path.`,
Invokes: []fx.Option{ Invokes: []fx.Option{
fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) { fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) {
lc.Append(fx.Hook{ lc.Append(fx.Hook{
OnStart: func(_ context.Context) error { OnStart: func(ctx context.Context) error {
// Start the snapshot creation in a goroutine // Start the snapshot creation in a goroutine
go func() { go func() {
// --cron suppression is wired through v.UI by setupGlobals. // Run the snapshot creation
err := v.CreateSnapshot(opts) if err := v.CreateSnapshot(opts); err != nil {
if err != nil { if err != context.Canceled {
if !errors.Is(err, context.Canceled) {
log.Error("Snapshot creation failed", "error", err) log.Error("Snapshot creation failed", "error", err)
ReportErrorf("Snapshot creation failed: %v", err)
os.Exit(1)
} }
} }
// Shutdown the app when snapshot completes // Shutdown the app when snapshot completes
err = v.Shutdowner.Shutdown() if err := v.Shutdowner.Shutdown(); err != nil {
if err != nil {
log.Error("Failed to shutdown", "error", err) log.Error("Failed to shutdown", "error", err)
} }
}() }()
return nil return nil
}, },
OnStop: func(_ context.Context) error { OnStop: func(ctx context.Context) error {
log.Debug("Stopping snapshot creation") log.Debug("Stopping snapshot creation")
// Cancel the Vaultik context // Cancel the Vaultik context
v.Cancel() v.Cancel()
return nil return nil
}, },
}) })
@@ -134,14 +98,10 @@ specifying a path using --config or by setting VAULTIK_CONFIG to a path.`,
}, },
} }
cmd.Flags().BoolVar(&opts.Cron, "cron", false, cmd.Flags().BoolVar(&opts.Daemon, "daemon", false, "Run in daemon mode with inotify monitoring")
"Run in cron mode (silent unless error)") cmd.Flags().BoolVar(&opts.Cron, "cron", false, "Run in cron mode (silent unless error)")
cmd.Flags().BoolVar(&opts.Prune, "prune", false, cmd.Flags().BoolVar(&opts.Prune, "prune", false, "Delete all previous snapshots and unreferenced blobs after backup")
"After backup, drop older snapshots of the same name and remove "+ cmd.Flags().BoolVar(&opts.SkipErrors, "skip-errors", false, "Skip file read errors (log them loudly but continue)")
"orphaned blobs")
cmd.Flags().StringVar(&opts.KeepNewerThan, "keep-newer-than", "",
"With --prune: keep snapshots newer than this duration "+
"(e.g. 4w, 30d, 6mo) instead of only the latest")
return cmd return cmd
} }
@@ -156,11 +116,46 @@ func newSnapshotListCommand() *cobra.Command {
Short: "List all snapshots", Short: "List all snapshots",
Long: "Lists all snapshots with their ID, timestamp, and compressed size", Long: "Lists all snapshots with their ID, timestamp, and compressed size",
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error { RunE: func(cmd *cobra.Command, args []string) error {
return runVaultikApp(cmd, false, false, // Use unified config resolution
"Failed to list snapshots", configPath, err := ResolveConfigPath()
func(v *vaultik.Vaultik) error { if err != nil {
return v.ListSnapshots(jsonOutput) return err
}
rootFlags := GetRootFlags()
return RunWithApp(cmd.Context(), AppOptions{
ConfigPath: configPath,
LogOptions: log.LogOptions{
Verbose: rootFlags.Verbose,
Debug: rootFlags.Debug,
Quiet: rootFlags.Quiet,
},
Modules: []fx.Option{},
Invokes: []fx.Option{
fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) {
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
go func() {
if err := v.ListSnapshots(jsonOutput); err != nil {
if err != context.Canceled {
log.Error("Failed to list snapshots", "error", err)
os.Exit(1)
}
}
if err := v.Shutdowner.Shutdown(); err != nil {
log.Error("Failed to shutdown", "error", err)
}
}()
return nil
},
OnStop: func(ctx context.Context) error {
v.Cancel()
return nil
},
})
}),
},
}) })
}, },
} }
@@ -179,35 +174,68 @@ func newSnapshotPurgeCommand() *cobra.Command {
Short: "Purge old snapshots", Short: "Purge old snapshots",
Long: `Removes snapshots based on age or count criteria. Long: `Removes snapshots based on age or count criteria.
Retention is per-snapshot-name: --keep-latest keeps the latest of each When --keep-latest is used, retention is applied per snapshot name. For example,
configured snapshot name, not the latest globally. Use --snapshot to if you have snapshots named "home" and "system", --keep-latest keeps the most
restrict the operation to specific snapshot names.`, recent of each.
Use --name to restrict the purge to a single snapshot name.`,
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error { RunE: func(cmd *cobra.Command, args []string) error {
// Validate flags // Validate flags
if !opts.KeepLatest && opts.OlderThan == "" { if !opts.KeepLatest && opts.OlderThan == "" {
return errPurgeCriteriaNeeded return fmt.Errorf("must specify either --keep-latest or --older-than")
} }
if opts.KeepLatest && opts.OlderThan != "" { if opts.KeepLatest && opts.OlderThan != "" {
return errPurgeCriteriaBoth return fmt.Errorf("cannot specify both --keep-latest and --older-than")
} }
return runVaultikApp(cmd, false, false, // Use unified config resolution
"Failed to purge snapshots", configPath, err := ResolveConfigPath()
func(v *vaultik.Vaultik) error { if err != nil {
return v.PurgeSnapshotsWithOptions(opts) return err
}
rootFlags := GetRootFlags()
return RunWithApp(cmd.Context(), AppOptions{
ConfigPath: configPath,
LogOptions: log.LogOptions{
Verbose: rootFlags.Verbose,
Debug: rootFlags.Debug,
Quiet: rootFlags.Quiet,
},
Modules: []fx.Option{},
Invokes: []fx.Option{
fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) {
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
go func() {
if err := v.PurgeSnapshotsWithOptions(opts); err != nil {
if err != context.Canceled {
log.Error("Failed to purge snapshots", "error", err)
os.Exit(1)
}
}
if err := v.Shutdowner.Shutdown(); err != nil {
log.Error("Failed to shutdown", "error", err)
}
}()
return nil
},
OnStop: func(ctx context.Context) error {
v.Cancel()
return nil
},
})
}),
},
}) })
}, },
} }
cmd.Flags().BoolVar(&opts.KeepLatest, "keep-latest", false, cmd.Flags().BoolVar(&opts.KeepLatest, "keep-latest", false, "Keep only the latest snapshot per name")
"Keep only the latest snapshot of each name") cmd.Flags().StringVar(&opts.OlderThan, "older-than", "", "Remove snapshots older than duration (e.g., 30d, 6m, 1y)")
cmd.Flags().StringVar(&opts.OlderThan, "older-than", "",
"Remove snapshots older than duration (e.g., 30d, 6m, 1y)")
cmd.Flags().BoolVar(&opts.Force, "force", false, "Skip confirmation prompt") cmd.Flags().BoolVar(&opts.Force, "force", false, "Skip confirmation prompt")
cmd.Flags().StringArrayVar(&opts.Names, "snapshot", nil, cmd.Flags().StringVar(&opts.Name, "name", "", "Filter purge to a specific snapshot name")
"Restrict to snapshots with these names (repeat for multiple)")
return cmd return cmd
} }
@@ -220,7 +248,16 @@ func newSnapshotVerifyCommand() *cobra.Command {
Use: "verify <snapshot-id>", Use: "verify <snapshot-id>",
Short: "Verify snapshot integrity", Short: "Verify snapshot integrity",
Long: "Verifies that all blobs referenced in a snapshot exist", Long: "Verifies that all blobs referenced in a snapshot exist",
Args: requireSnapshotIDArg, Args: func(cmd *cobra.Command, args []string) error {
if len(args) != 1 {
_ = cmd.Help()
if len(args) == 0 {
return fmt.Errorf("snapshot ID required")
}
return fmt.Errorf("expected 1 argument, got %d", len(args))
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
snapshotID := args[0] snapshotID := args[0]
@@ -231,10 +268,9 @@ func newSnapshotVerifyCommand() *cobra.Command {
} }
rootFlags := GetRootFlags() rootFlags := GetRootFlags()
return RunWithApp(cmd.Context(), AppOptions{ return RunWithApp(cmd.Context(), AppOptions{
ConfigPath: configPath, ConfigPath: configPath,
LogOptions: log.Options{ LogOptions: log.LogOptions{
Verbose: rootFlags.Verbose, Verbose: rootFlags.Verbose,
Debug: rootFlags.Debug, Debug: rootFlags.Debug,
Quiet: rootFlags.Quiet || opts.JSON, Quiet: rootFlags.Quiet || opts.JSON,
@@ -243,31 +279,30 @@ func newSnapshotVerifyCommand() *cobra.Command {
Invokes: []fx.Option{ Invokes: []fx.Option{
fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) { fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) {
lc.Append(fx.Hook{ lc.Append(fx.Hook{
OnStart: func(_ context.Context) error { OnStart: func(ctx context.Context) error {
go func() { go func() {
err := v.VerifySnapshotWithOptions(snapshotID, opts) var err error
if opts.Deep {
err = v.RunDeepVerify(snapshotID, opts)
} else {
err = v.VerifySnapshotWithOptions(snapshotID, opts)
}
if err != nil { if err != nil {
if !errors.Is(err, context.Canceled) { if err != context.Canceled {
if !opts.JSON { if !opts.JSON {
log.Error("Verification failed", "error", err) log.Error("Verification failed", "error", err)
ReportErrorf("Verification failed: %v", err)
} }
os.Exit(1) os.Exit(1)
} }
} }
if err := v.Shutdowner.Shutdown(); err != nil {
err = v.Shutdowner.Shutdown()
if err != nil {
log.Error("Failed to shutdown", "error", err) log.Error("Failed to shutdown", "error", err)
} }
}() }()
return nil return nil
}, },
OnStop: func(_ context.Context) error { OnStop: func(ctx context.Context) error {
v.Cancel() v.Cancel()
return nil return nil
}, },
}) })
@@ -288,45 +323,150 @@ func newSnapshotRemoveCommand() *cobra.Command {
opts := &vaultik.RemoveOptions{} opts := &vaultik.RemoveOptions{}
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "remove <snapshot-id>", Use: "remove [snapshot-id]",
Aliases: []string{"rm"}, Aliases: []string{"rm"},
Short: "Remove a snapshot from local index and remote metadata", Short: "Remove a snapshot from the local database",
Long: `Removes a snapshot. Long: `Removes a snapshot from the local database.
By default, this removes the snapshot from the local index database and By default, only removes from the local database. Use --remote to also remove
strips the snapshot's metadata from the backup destination store. Blobs the snapshot metadata from remote storage.
are NOT touched: deleting them requires reading every remaining remote
manifest (the destination store may hold snapshots this host doesn't
know about), which is what 'vaultik prune' does. On success the command
prints the exact 'vaultik prune' invocation to run as a follow-up.
Use --local-only to skip the remote half (e.g. when you want to forget a Note: This does NOT remove blobs. Use 'vaultik prune' to remove orphaned blobs
snapshot locally without touching the destination store). after removing snapshots.
If the remote is unreachable, the local-database removal still completes Use --all --force to remove all snapshots.`,
and a warning is emitted; rerun 'vaultik prune' once the destination store Args: func(cmd *cobra.Command, args []string) error {
is reachable to finish remote cleanup. all, _ := cmd.Flags().GetBool("all")
if all {
To wipe the entire destination store and start over, use 'vaultik remote if len(args) > 0 {
nuke --force' — it is the single supported entry point for that.`, _ = cmd.Help()
Args: requireSnapshotIDArg, return fmt.Errorf("--all cannot be used with a snapshot ID")
}
return nil
}
if len(args) != 1 {
_ = cmd.Help()
if len(args) == 0 {
return fmt.Errorf("snapshot ID required (or use --all --force)")
}
return fmt.Errorf("expected 1 argument, got %d", len(args))
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
return runVaultikApp(cmd, opts.JSON, opts.JSON, // Use unified config resolution
"Failed to remove snapshot", configPath, err := ResolveConfigPath()
func(v *vaultik.Vaultik) error { if err != nil {
_, err := v.RemoveSnapshot(args[0], opts)
return err return err
}
rootFlags := GetRootFlags()
return RunWithApp(cmd.Context(), AppOptions{
ConfigPath: configPath,
LogOptions: log.LogOptions{
Verbose: rootFlags.Verbose,
Debug: rootFlags.Debug,
Quiet: rootFlags.Quiet || opts.JSON,
},
Modules: []fx.Option{},
Invokes: []fx.Option{
fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) {
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
go func() {
var err error
if opts.All {
_, err = v.RemoveAllSnapshots(opts)
} else {
_, err = v.RemoveSnapshot(args[0], opts)
}
if err != nil {
if err != context.Canceled {
if !opts.JSON {
log.Error("Failed to remove snapshot", "error", err)
}
os.Exit(1)
}
}
if err := v.Shutdowner.Shutdown(); err != nil {
log.Error("Failed to shutdown", "error", err)
}
}()
return nil
},
OnStop: func(ctx context.Context) error {
v.Cancel()
return nil
},
})
}),
},
}) })
}, },
} }
cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "Skip confirmation prompt") cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "Skip confirmation prompt")
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Show what would be removed without removing")
"Show what would be removed without removing")
cmd.Flags().BoolVar(&opts.JSON, "json", false, "Output result as JSON") cmd.Flags().BoolVar(&opts.JSON, "json", false, "Output result as JSON")
cmd.Flags().BoolVar(&opts.LocalOnly, "local-only", false, cmd.Flags().BoolVar(&opts.Remote, "remote", false, "Also remove snapshot metadata from remote storage")
"Skip remote cleanup; only touch the local index") cmd.Flags().BoolVar(&opts.All, "all", false, "Remove all snapshots (requires --force)")
return cmd
}
// newSnapshotPruneCommand creates the 'snapshot prune' subcommand
func newSnapshotPruneCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "prune",
Short: "Remove orphaned data from local database",
Long: `Removes orphaned files, chunks, and blobs from the local database.
This cleans up data that is no longer referenced by any snapshot, which can
accumulate from incomplete backups or deleted snapshots.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
// Use unified config resolution
configPath, err := ResolveConfigPath()
if err != nil {
return err
}
rootFlags := GetRootFlags()
return RunWithApp(cmd.Context(), AppOptions{
ConfigPath: configPath,
LogOptions: log.LogOptions{
Verbose: rootFlags.Verbose,
Debug: rootFlags.Debug,
Quiet: rootFlags.Quiet,
},
Modules: []fx.Option{},
Invokes: []fx.Option{
fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) {
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
go func() {
if _, err := v.PruneDatabase(); err != nil {
if err != context.Canceled {
log.Error("Failed to prune database", "error", err)
os.Exit(1)
}
}
if err := v.Shutdowner.Shutdown(); err != nil {
log.Error("Failed to shutdown", "error", err)
}
}()
return nil
},
OnStop: func(ctx context.Context) error {
v.Cancel()
return nil
},
})
}),
},
})
},
}
return cmd return cmd
} }

158
internal/cli/store.go Normal file
View File

@@ -0,0 +1,158 @@
package cli
import (
"context"
"fmt"
"strings"
"time"
"git.eeqj.de/sneak/vaultik/internal/log"
"git.eeqj.de/sneak/vaultik/internal/storage"
"github.com/spf13/cobra"
"go.uber.org/fx"
)
// StoreApp contains dependencies for store commands
type StoreApp struct {
Storage storage.Storer
Shutdowner fx.Shutdowner
}
// NewStoreCommand creates the store command and subcommands
func NewStoreCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "store",
Short: "Storage information commands",
Long: "Commands for viewing information about the storage backend",
}
// Add subcommands
cmd.AddCommand(newStoreInfoCommand())
return cmd
}
// newStoreInfoCommand creates the 'store info' subcommand
func newStoreInfoCommand() *cobra.Command {
return &cobra.Command{
Use: "info",
Short: "Display storage information",
Long: "Shows storage configuration and statistics including snapshots and blobs",
RunE: func(cmd *cobra.Command, args []string) error {
return runWithApp(cmd.Context(), func(app *StoreApp) error {
return app.Info(cmd.Context())
})
},
}
}
// Info displays storage information
func (app *StoreApp) Info(ctx context.Context) error {
// Get storage info
storageInfo := app.Storage.Info()
fmt.Printf("Storage Information\n")
fmt.Printf("==================\n\n")
fmt.Printf("Storage Configuration:\n")
fmt.Printf(" Type: %s\n", storageInfo.Type)
fmt.Printf(" Location: %s\n\n", storageInfo.Location)
// Count snapshots by listing metadata/ prefix
snapshotCount := 0
snapshotCh := app.Storage.ListStream(ctx, "metadata/")
snapshotDirs := make(map[string]bool)
for object := range snapshotCh {
if object.Err != nil {
return fmt.Errorf("listing snapshots: %w", object.Err)
}
// Extract snapshot ID from path like metadata/2024-01-15-143052-hostname/
parts := strings.Split(object.Key, "/")
if len(parts) >= 2 && parts[0] == "metadata" && parts[1] != "" {
snapshotDirs[parts[1]] = true
}
}
snapshotCount = len(snapshotDirs)
// Count blobs and calculate total size by listing blobs/ prefix
blobCount := 0
var totalSize int64
blobCh := app.Storage.ListStream(ctx, "blobs/")
for object := range blobCh {
if object.Err != nil {
return fmt.Errorf("listing blobs: %w", object.Err)
}
if !strings.HasSuffix(object.Key, "/") { // Skip directories
blobCount++
totalSize += object.Size
}
}
fmt.Printf("Storage Statistics:\n")
fmt.Printf(" Snapshots: %d\n", snapshotCount)
fmt.Printf(" Blobs: %d\n", blobCount)
fmt.Printf(" Total Size: %s\n", formatBytes(totalSize))
return nil
}
// formatBytes formats bytes into human-readable format
func formatBytes(bytes int64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%d B", bytes)
}
div, exp := int64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
}
// runWithApp creates the FX app and runs the given function
func runWithApp(ctx context.Context, fn func(*StoreApp) error) error {
var result error
rootFlags := GetRootFlags()
// Use unified config resolution
configPath, err := ResolveConfigPath()
if err != nil {
return err
}
err = RunWithApp(ctx, AppOptions{
ConfigPath: configPath,
LogOptions: log.LogOptions{
Verbose: rootFlags.Verbose,
Debug: rootFlags.Debug,
Quiet: rootFlags.Quiet,
},
Modules: []fx.Option{
fx.Provide(func(storer storage.Storer, shutdowner fx.Shutdowner) *StoreApp {
return &StoreApp{
Storage: storer,
Shutdowner: shutdowner,
}
}),
},
Invokes: []fx.Option{
fx.Invoke(func(app *StoreApp, shutdowner fx.Shutdowner) {
result = fn(app)
// Shutdown after command completes
go func() {
time.Sleep(100 * time.Millisecond) // Brief delay to ensure clean shutdown
if err := shutdowner.Shutdown(); err != nil {
log.Error("Failed to shutdown", "error", err)
}
}()
}),
},
})
if err != nil {
return err
}
return result
}

View File

@@ -3,8 +3,6 @@ package cli
import "time" import "time"
// SnapshotInfo represents snapshot information for listing // SnapshotInfo represents snapshot information for listing
//
//nolint:tagliatelle // snake_case is the established output format
type SnapshotInfo struct { type SnapshotInfo struct {
ID string `json:"id"` ID string `json:"id"`
Timestamp time.Time `json:"timestamp"` Timestamp time.Time `json:"timestamp"`

98
internal/cli/verify.go Normal file
View File

@@ -0,0 +1,98 @@
package cli
import (
"context"
"os"
"git.eeqj.de/sneak/vaultik/internal/log"
"git.eeqj.de/sneak/vaultik/internal/vaultik"
"github.com/spf13/cobra"
"go.uber.org/fx"
)
// NewVerifyCommand creates the verify command
func NewVerifyCommand() *cobra.Command {
opts := &vaultik.VerifyOptions{}
cmd := &cobra.Command{
Use: "verify <snapshot-id>",
Short: "Verify snapshot integrity",
Long: `Verifies that all blobs referenced in a snapshot exist and optionally verifies their contents.
Shallow verification (default):
- Downloads and decompresses manifest
- Checks existence of all blobs in S3
- Reports missing blobs
Deep verification (--deep):
- Downloads and decrypts database
- Verifies blob lists match between manifest and database
- Downloads, decrypts, and decompresses each blob
- Verifies SHA256 hash of each chunk matches database
- Ensures chunks are ordered correctly
The command will fail immediately on any verification error and exit with non-zero status.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
snapshotID := args[0]
// Use unified config resolution
configPath, err := ResolveConfigPath()
if err != nil {
return err
}
// Use the app framework for all verification
rootFlags := GetRootFlags()
return RunWithApp(cmd.Context(), AppOptions{
ConfigPath: configPath,
LogOptions: log.LogOptions{
Verbose: rootFlags.Verbose,
Debug: rootFlags.Debug,
Quiet: rootFlags.Quiet || opts.JSON, // Suppress log output in JSON mode
},
Modules: []fx.Option{},
Invokes: []fx.Option{
fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) {
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
// Run the verify operation directly
go func() {
var err error
if opts.Deep {
err = v.RunDeepVerify(snapshotID, opts)
} else {
err = v.VerifySnapshotWithOptions(snapshotID, opts)
}
if err != nil {
if err != context.Canceled {
if !opts.JSON {
log.Error("Verification failed", "error", err)
}
os.Exit(1)
}
}
if err := v.Shutdowner.Shutdown(); err != nil {
log.Error("Failed to shutdown", "error", err)
}
}()
return nil
},
OnStop: func(ctx context.Context) error {
log.Debug("Stopping verify operation")
v.Cancel()
return nil
},
})
}),
},
})
},
}
cmd.Flags().BoolVar(&opts.Deep, "deep", false, "Perform deep verification by downloading and verifying all blob contents")
cmd.Flags().BoolVar(&opts.JSON, "json", false, "Output verification results as JSON")
return cmd
}

View File

@@ -2,11 +2,10 @@ package cli
import ( import (
"fmt" "fmt"
"os"
"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
@@ -16,26 +15,11 @@ func NewVersionCommand() *cobra.Command {
Short: "Print version information", Short: "Print version information",
Long: `Print version, git commit, and build information for vaultik.`, Long: `Print version, git commit, and build information for vaultik.`,
Args: cobra.NoArgs, Args: cobra.NoArgs,
Run: func(_ *cobra.Command, _ []string) { Run: func(cmd *cobra.Command, args []string) {
_, _ = fmt.Fprintf(os.Stdout, "vaultik %s\n", globals.Version) fmt.Printf("vaultik %s\n", globals.Version)
_, _ = fmt.Fprintf(os.Stdout, " commit: %s\n", globals.Commit) fmt.Printf(" commit: %s\n", globals.Commit)
_, _ = fmt.Fprintf(os.Stdout, " build date: %s\n", globals.CommitDate) fmt.Printf(" go: %s\n", runtime.Version())
_, _ = fmt.Fprintf(os.Stdout, " go: %s\n", runtime.Version()) fmt.Printf(" os/arch: %s/%s\n", runtime.GOOS, runtime.GOARCH)
_, _ = fmt.Fprintf(os.Stdout, " os/arch: %s/%s\n",
runtime.GOOS, runtime.GOARCH)
_, _ = fmt.Fprintf(os.Stdout, " author: %s\n", globals.Author)
_, _ = fmt.Fprintf(os.Stdout, " homepage: %s\n", globals.Homepage)
_, _ = fmt.Fprintf(os.Stdout, " license: %s\n", globals.License)
if globals.Version == "dev" {
_, _ = fmt.Fprintln(os.Stdout)
_, _ = fmt.Fprintln(os.Stdout,
"This is a development build (no version information embedded).")
_, _ = fmt.Fprintln(os.Stdout,
"Build a release binary with 'make vaultik' or download from")
_, _ = fmt.Fprintln(os.Stdout,
"https://sneak.berlin/go/vaultik for embedded version metadata.")
}
}, },
} }

View File

@@ -1,72 +1,33 @@
// Package config loads, validates, and provides the vaultik YAML
// configuration, including snapshot definitions, encryption recipients,
// and storage settings.
package config package config
import ( import (
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
"time"
"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 = "vaultik" const appName = "berlin.sneak.app.vaultik"
// Defaults and validation bounds for tunable settings.
const (
defaultBlobSizeLimit = Size(10 * 1024 * 1024 * 1024) // 10GB
defaultChunkSize = Size(10 * 1024 * 1024) // 10MB
defaultS3PartSize = Size(5 * 1024 * 1024) // 5MB
defaultCompressionLevel = 3
minChunkSize = 1024 * 1024 // 1MB
minCompressionLevel = 1
maxCompressionLevel = 19
)
// Sentinel validation errors.
var (
errNoConfigPath = errors.New("config path not provided")
errNoAgeRecipients = errors.New(
"at least one age_recipient is required (generate with: age-keygen)")
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 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 + " +
"s3.bucket + credentials")
errS3BucketRequired = errors.New("s3.bucket is required (or set storage_url)")
errS3KeyIDRequired = errors.New("s3.access_key_id is required")
errS3SecretRequired = errors.New("s3.secret_access_key is required")
)
// 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 {
if path == "~" { if path == "~" {
home, _ := os.UserHomeDir() home, _ := os.UserHomeDir()
return home return home
} }
if strings.HasPrefix(path, "~/") { if strings.HasPrefix(path, "~/") {
home, _ := os.UserHomeDir() home, _ := os.UserHomeDir()
return filepath.Join(home, path[2:]) return filepath.Join(home, path[2:])
} }
return path return path
} }
@@ -74,10 +35,8 @@ func expandTilde(path string) string {
func expandTildeInURL(url string) string { func expandTildeInURL(url string) string {
if strings.HasPrefix(url, "file://~/") { if strings.HasPrefix(url, "file://~/") {
home, _ := os.UserHomeDir() home, _ := os.UserHomeDir()
return "file://" + filepath.Join(home, url[9:]) return "file://" + filepath.Join(home, url[9:])
} }
return url return url
} }
@@ -105,7 +64,6 @@ func (c *Config) GetExcludes(snapshotName string) []string {
combined := make([]string, 0, len(c.Exclude)+len(snap.Exclude)) combined := make([]string, 0, len(c.Exclude)+len(snap.Exclude))
combined = append(combined, c.Exclude...) combined = append(combined, c.Exclude...)
combined = append(combined, snap.Exclude...) combined = append(combined, snap.Exclude...)
return combined return combined
} }
@@ -117,7 +75,6 @@ func (c *Config) SnapshotNames() []string {
} }
// Sort for deterministic order // Sort for deterministic order
sort.Strings(names) sort.Strings(names)
return names return names
} }
@@ -125,17 +82,17 @@ func (c *Config) SnapshotNames() []string {
// It defines all settings for backup operations, including source directories, // It defines all settings for backup operations, including source directories,
// encryption recipients, storage configuration, and performance tuning parameters. // encryption recipients, storage configuration, and performance tuning parameters.
// Configuration is typically loaded from a YAML file. // Configuration is typically loaded from a YAML file.
//
//nolint:tagliatelle // snake_case is the established config-file format
type Config struct { type Config struct {
AgeRecipients []string `yaml:"age_recipients"` AgeRecipients []string `yaml:"age_recipients"`
AgeSecretKey string `yaml:"age_secret_key"` AgeSecretKey string `yaml:"age_secret_key"`
BackupInterval time.Duration `yaml:"backup_interval"`
BlobSizeLimit Size `yaml:"blob_size_limit"` BlobSizeLimit Size `yaml:"blob_size_limit"`
ChunkSize Size `yaml:"chunk_size"` ChunkSize Size `yaml:"chunk_size"`
// Exclude holds global excludes applied to all snapshots. Exclude []string `yaml:"exclude"` // Global excludes applied to all snapshots
Exclude []string `yaml:"exclude"` FullScanInterval time.Duration `yaml:"full_scan_interval"`
Hostname string `yaml:"hostname"` Hostname string `yaml:"hostname"`
IndexPath string `yaml:"index_path"` IndexPath string `yaml:"index_path"`
MinTimeBetweenRun time.Duration `yaml:"min_time_between_run"`
S3 S3Config `yaml:"s3"` S3 S3Config `yaml:"s3"`
Snapshots map[string]SnapshotConfig `yaml:"snapshots"` Snapshots map[string]SnapshotConfig `yaml:"snapshots"`
CompressionLevel int `yaml:"compression_level"` CompressionLevel int `yaml:"compression_level"`
@@ -145,16 +102,13 @@ type Config struct {
// Supported formats: // Supported formats:
// - s3://bucket/prefix?endpoint=host&region=us-east-1 // - s3://bucket/prefix?endpoint=host&region=us-east-1
// - file:///path/to/backup // - file:///path/to/backup
// For S3 URLs, credentials are still read from s3.access_key_id // For S3 URLs, credentials are still read from s3.access_key_id and s3.secret_access_key.
// and s3.secret_access_key.
StorageURL string `yaml:"storage_url"` StorageURL string `yaml:"storage_url"`
} }
// S3Config represents S3 storage configuration for backup storage. // S3Config represents S3 storage configuration for backup storage.
// It supports both AWS S3 and S3-compatible storage services. // It supports both AWS S3 and S3-compatible storage services.
// All fields except UseSSL and PartSize are required. // All fields except UseSSL and PartSize are required.
//
//nolint:tagliatelle // snake_case is the established config-file format
type S3Config struct { type S3Config struct {
Endpoint string `yaml:"endpoint"` Endpoint string `yaml:"endpoint"`
Bucket string `yaml:"bucket"` Bucket string `yaml:"bucket"`
@@ -166,17 +120,17 @@ type S3Config struct {
PartSize Size `yaml:"part_size"` PartSize Size `yaml:"part_size"`
} }
// Path wraps the config file path for fx dependency injection. // ConfigPath wraps the config file path for fx dependency injection.
// This type allows the config file path to be injected as a distinct type // This type allows the config file path to be injected as a distinct type
// rather than a plain string, avoiding conflicts with other string dependencies. // rather than a plain string, avoiding conflicts with other string dependencies.
type Path string type ConfigPath string
// New creates a new Config instance by loading from the specified path. // New creates a new Config instance by loading from the specified path.
// This function is used by the fx dependency injection framework. // This function is used by the fx dependency injection framework.
// Returns an error if the path is empty or if loading fails. // Returns an error if the path is empty or if loading fails.
func New(path Path) (*Config, error) { func New(path ConfigPath) (*Config, error) {
if path == "" { if path == "" {
return nil, errNoConfigPath return nil, fmt.Errorf("config path not provided")
} }
cfg, err := Load(string(path)) cfg, err := Load(string(path))
@@ -201,22 +155,23 @@ func Load(path string) (*Config, error) {
cfg := &Config{ cfg := &Config{
// Set defaults // Set defaults
BlobSizeLimit: defaultBlobSizeLimit, BlobSizeLimit: Size(10 * 1024 * 1024 * 1024), // 10GB
ChunkSize: defaultChunkSize, ChunkSize: Size(10 * 1024 * 1024), // 10MB
BackupInterval: 1 * time.Hour,
FullScanInterval: 24 * time.Hour,
MinTimeBetweenRun: 15 * time.Minute,
IndexPath: filepath.Join(xdg.DataHome, appName, "index.sqlite"), IndexPath: filepath.Join(xdg.DataHome, appName, "index.sqlite"),
CompressionLevel: defaultCompressionLevel, CompressionLevel: 3,
} }
// Convert smartconfig data to YAML then unmarshal // Convert smartconfig data to YAML then unmarshal
configData := sc.Data() configData := sc.Data()
yamlBytes, err := yaml.Marshal(configData) yamlBytes, err := yaml.Marshal(configData)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to marshal config data: %w", err) return nil, fmt.Errorf("failed to marshal config data: %w", err)
} }
err = yaml.Unmarshal(yamlBytes, cfg) if err := yaml.Unmarshal(yamlBytes, cfg); err != nil {
if err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err) return nil, fmt.Errorf("failed to parse config: %w", err)
} }
@@ -229,7 +184,6 @@ func Load(path string) (*Config, error) {
for i, path := range snap.Paths { for i, path := range snap.Paths {
snap.Paths[i] = expandTilde(path) snap.Paths[i] = expandTilde(path)
} }
cfg.Snapshots[name] = snap cfg.Snapshots[name] = snap
} }
@@ -249,7 +203,6 @@ func Load(path string) (*Config, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get hostname: %w", err) return nil, fmt.Errorf("failed to get hostname: %w", err)
} }
cfg.Hostname = hostname cfg.Hostname = hostname
} }
@@ -257,15 +210,12 @@ func Load(path string) (*Config, error) {
if cfg.S3.Region == "" { if cfg.S3.Region == "" {
cfg.S3.Region = "us-east-1" cfg.S3.Region = "us-east-1"
} }
if cfg.S3.PartSize == 0 { if cfg.S3.PartSize == 0 {
cfg.S3.PartSize = defaultS3PartSize cfg.S3.PartSize = Size(5 * 1024 * 1024) // 5MB
} }
// Check config file permissions (warn if world or group readable) // Check config file permissions (warn if world or group readable)
//nolint:gosec // G703: config path is operator-supplied by design if info, err := os.Stat(path); err == nil {
info, statErr := os.Stat(path)
if statErr == nil {
mode := info.Mode().Perm() mode := info.Mode().Perm()
if mode&0044 != 0 { // group or world readable if mode&0044 != 0 { // group or world readable
log.Warn("Config file has insecure permissions (contains S3 credentials)", log.Warn("Config file has insecure permissions (contains S3 credentials)",
@@ -275,8 +225,7 @@ func Load(path string) (*Config, error) {
} }
} }
err = cfg.Validate() if err := cfg.Validate(); err != nil {
if err != nil {
return nil, fmt.Errorf("invalid config: %w", err) return nil, fmt.Errorf("invalid config: %w", err)
} }
@@ -294,36 +243,34 @@ func Load(path string) (*Config, error) {
// Returns an error describing the first validation failure encountered. // Returns an error describing the first validation failure encountered.
func (c *Config) Validate() error { func (c *Config) Validate() error {
if len(c.AgeRecipients) == 0 { if len(c.AgeRecipients) == 0 {
return errNoAgeRecipients return fmt.Errorf("at least one age_recipient is required")
} }
if len(c.Snapshots) == 0 { if len(c.Snapshots) == 0 {
return errNoSnapshots return fmt.Errorf("at least one snapshot must be configured")
} }
for name, snap := range c.Snapshots { for name, snap := range c.Snapshots {
if len(snap.Paths) == 0 { if len(snap.Paths) == 0 {
return fmt.Errorf("%w: %q", errSnapshotNoPaths, name) return fmt.Errorf("snapshot %q must have at least one path", name)
} }
} }
// Validate storage configuration // Validate storage configuration
err := c.validateStorage() if err := c.validateStorage(); err != nil {
if err != nil {
return err return err
} }
if c.ChunkSize.Int64() < minChunkSize { if c.ChunkSize.Int64() < 1024*1024 { // 1MB minimum
return errChunkSizeTooSmall return fmt.Errorf("chunk_size must be at least 1MB")
} }
if c.BlobSizeLimit.Int64() < c.ChunkSize.Int64() { if c.BlobSizeLimit.Int64() < c.ChunkSize.Int64() {
return errBlobSizeTooSmall return fmt.Errorf("blob_size_limit must be at least chunk_size")
} }
if c.CompressionLevel < minCompressionLevel || if c.CompressionLevel < 1 || c.CompressionLevel > 19 {
c.CompressionLevel > maxCompressionLevel { return fmt.Errorf("compression_level must be between 1 and 19")
return errBadCompression
} }
return nil return nil
@@ -335,56 +282,48 @@ func (c *Config) Validate() error {
// If StorageURL is not set, legacy S3 configuration is required. // If StorageURL is not set, legacy S3 configuration is required.
func (c *Config) validateStorage() error { func (c *Config) validateStorage() error {
if c.StorageURL != "" { if c.StorageURL != "" {
return c.validateStorageURL() // URL-based configuration
if strings.HasPrefix(c.StorageURL, "file://") {
// File storage doesn't need S3 credentials
return nil
}
if strings.HasPrefix(c.StorageURL, "s3://") {
// S3 storage needs credentials
if c.S3.AccessKeyID == "" {
return fmt.Errorf("s3.access_key_id is required for s3:// URLs")
}
if c.S3.SecretAccessKey == "" {
return fmt.Errorf("s3.secret_access_key is required for s3:// URLs")
}
return nil
}
if strings.HasPrefix(c.StorageURL, "rclone://") {
// Rclone storage uses rclone's own config
return nil
}
return fmt.Errorf("storage_url must start with s3://, file://, or rclone://")
} }
// Legacy S3 configuration // Legacy S3 configuration
if c.S3.Endpoint == "" { if c.S3.Endpoint == "" {
return errStorageNotConfigured return fmt.Errorf("s3.endpoint is required (or set storage_url)")
} }
if c.S3.Bucket == "" { if c.S3.Bucket == "" {
return errS3BucketRequired return fmt.Errorf("s3.bucket is required (or set storage_url)")
} }
if c.S3.AccessKeyID == "" { if c.S3.AccessKeyID == "" {
return errS3KeyIDRequired return fmt.Errorf("s3.access_key_id is required")
} }
if c.S3.SecretAccessKey == "" { if c.S3.SecretAccessKey == "" {
return errS3SecretRequired return fmt.Errorf("s3.secret_access_key is required")
} }
return nil return nil
} }
// validateStorageURL validates URL-based storage configuration. File and
// rclone URLs need no credentials; S3 URLs require the legacy s3.*
// credential fields.
func (c *Config) validateStorageURL() error {
switch {
case strings.HasPrefix(c.StorageURL, "file://"):
// File storage doesn't need S3 credentials
return nil
case strings.HasPrefix(c.StorageURL, "rclone://"):
// Rclone storage uses rclone's own config
return nil
case strings.HasPrefix(c.StorageURL, "s3://"):
// S3 storage needs credentials
if c.S3.AccessKeyID == "" {
return fmt.Errorf("%w for s3:// URLs", errS3KeyIDRequired)
}
if c.S3.SecretAccessKey == "" {
return fmt.Errorf("%w for s3:// URLs", errS3SecretRequired)
}
return nil
default:
return errBadStorageScheme
}
}
// extractAgeSecretKey extracts the AGE-SECRET-KEY from the input using // extractAgeSecretKey extracts the AGE-SECRET-KEY from the input using
// the age library's parser, which handles comments and whitespace. // the age library's parser, which handles comments and whitespace.
func extractAgeSecretKey(input string) string { func extractAgeSecretKey(input string) string {
@@ -397,14 +336,11 @@ func extractAgeSecretKey(input string) string {
if id, ok := identities[0].(*age.X25519Identity); ok { if id, ok := identities[0].(*age.X25519Identity); ok {
return id.String() return id.String()
} }
return strings.TrimSpace(input) return strings.TrimSpace(input)
} }
// Module exports the config module for fx dependency injection. // Module exports the config module for fx dependency injection.
// It provides the Config type to other modules in the application. // It provides the Config type to other modules in the application.
//
//nolint:gochecknoglobals // fx module definitions are package globals
var Module = fx.Module("config", var Module = fx.Module("config",
fx.Provide(New), fx.Provide(New),
) )

View File

@@ -1,4 +1,4 @@
package config //nolint:testpackage // exercises unexported extractAgeSecretKey package config
import ( import (
"os" "os"
@@ -7,20 +7,15 @@ import (
) )
const ( const (
testSneakAgePublicKey = "age1278m9q7dp3chsh2dcy82qk27v047zywyvt" + TEST_SNEAK_AGE_PUBLIC_KEY = "age1278m9q7dp3chsh2dcy82qk27v047zywyvtxwnj4cvt0z65jw6a7q5dqhfj"
"xwnj4cvt0z65jw6a7q5dqhfj" TEST_INTEGRATION_AGE_PUBLIC_KEY = "age1ezrjmfpwsc95svdg0y54mums3zevgzu0x0ecq2f7tp8a05gl0sjq9q9wjg"
testIntegrationAgePublicKey = "age1ezrjmfpwsc95svdg0y54mums3zevgzu" + TEST_INTEGRATION_AGE_PRIVATE_KEY = "AGE-SECRET-KEY-19CR5YSFW59HM4TLD6GXVEDMZFTVVF7PPHKUT68TXSFPK7APHXA2QS2NJA5"
"0x0ecq2f7tp8a05gl0sjq9q9wjg"
testIntegrationAgePrivateKey = "AGE-SECRET-KEY-19CR5YSFW59HM4TLD6GX" +
"VEDMZFTVVF7PPHKUT68TXSFPK7APHXA2QS2NJA5"
) )
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
// Set up test environment // Set up test environment
testConfigPath := filepath.Join("..", "..", "test", "config.yaml") testConfigPath := filepath.Join("..", "..", "test", "config.yaml")
if absPath, err := filepath.Abs(testConfigPath); err == nil {
absPath, err := filepath.Abs(testConfigPath)
if err == nil {
_ = os.Setenv("VAULTIK_CONFIG", absPath) _ = os.Setenv("VAULTIK_CONFIG", absPath)
} }
@@ -28,11 +23,8 @@ func TestMain(m *testing.M) {
os.Exit(code) os.Exit(code)
} }
// TestConfigLoad ensures the config package can be imported and basic // TestConfigLoad ensures the config package can be imported and basic functionality works
// functionality works.
func TestConfigLoad(t *testing.T) { func TestConfigLoad(t *testing.T) {
t.Parallel()
// Use the test config file // Use the test config file
configPath := os.Getenv("VAULTIK_CONFIG") configPath := os.Getenv("VAULTIK_CONFIG")
if configPath == "" { if configPath == "" {
@@ -49,10 +41,8 @@ func TestConfigLoad(t *testing.T) {
if len(cfg.AgeRecipients) != 2 { if len(cfg.AgeRecipients) != 2 {
t.Errorf("Expected 2 age recipients, got %d", len(cfg.AgeRecipients)) t.Errorf("Expected 2 age recipients, got %d", len(cfg.AgeRecipients))
} }
if cfg.AgeRecipients[0] != TEST_SNEAK_AGE_PUBLIC_KEY {
if cfg.AgeRecipients[0] != testSneakAgePublicKey { t.Errorf("Expected first age recipient to be %s, got '%s'", TEST_SNEAK_AGE_PUBLIC_KEY, cfg.AgeRecipients[0])
t.Errorf("Expected first age recipient to be %s, got '%s'",
testSneakAgePublicKey, cfg.AgeRecipients[0])
} }
if len(cfg.Snapshots) != 1 { if len(cfg.Snapshots) != 1 {
@@ -69,13 +59,11 @@ func TestConfigLoad(t *testing.T) {
} }
if testSnap.Paths[0] != "/tmp/vaultik-test-source" { if testSnap.Paths[0] != "/tmp/vaultik-test-source" {
t.Errorf("Expected first path to be '/tmp/vaultik-test-source', got '%s'", t.Errorf("Expected first path to be '/tmp/vaultik-test-source', got '%s'", testSnap.Paths[0])
testSnap.Paths[0])
} }
if cfg.S3.Bucket != "vaultik-test-bucket" { if cfg.S3.Bucket != "vaultik-test-bucket" {
t.Errorf("Expected S3 bucket to be 'vaultik-test-bucket', got '%s'", t.Errorf("Expected S3 bucket to be 'vaultik-test-bucket', got '%s'", cfg.S3.Bucket)
cfg.S3.Bucket)
} }
if cfg.Hostname != "test-host" { if cfg.Hostname != "test-host" {
@@ -85,26 +73,19 @@ func TestConfigLoad(t *testing.T) {
// TestConfigFromEnv tests loading config path from environment variable // TestConfigFromEnv tests loading config path from environment variable
func TestConfigFromEnv(t *testing.T) { func TestConfigFromEnv(t *testing.T) {
t.Parallel()
configPath := os.Getenv("VAULTIK_CONFIG") configPath := os.Getenv("VAULTIK_CONFIG")
if configPath == "" { if configPath == "" {
t.Skip("VAULTIK_CONFIG not set") t.Skip("VAULTIK_CONFIG not set")
} }
// Verify the file exists // Verify the file exists
//nolint:gosec // G703: test config path comes from the test environment if _, err := os.Stat(configPath); os.IsNotExist(err) {
_, err := os.Stat(configPath) t.Errorf("Config file does not exist at path from VAULTIK_CONFIG: %s", configPath)
if os.IsNotExist(err) {
t.Errorf("Config file does not exist at path from VAULTIK_CONFIG: %s",
configPath)
} }
} }
// TestExtractAgeSecretKey tests extraction of AGE-SECRET-KEY from various inputs // TestExtractAgeSecretKey tests extraction of AGE-SECRET-KEY from various inputs
func TestExtractAgeSecretKey(t *testing.T) { func TestExtractAgeSecretKey(t *testing.T) {
t.Parallel()
tests := []struct { tests := []struct {
name string name string
input string input string
@@ -112,32 +93,36 @@ func TestExtractAgeSecretKey(t *testing.T) {
}{ }{
{ {
name: "plain key", name: "plain key",
input: testIntegrationAgePrivateKey, input: "AGE-SECRET-KEY-19CR5YSFW59HM4TLD6GXVEDMZFTVVF7PPHKUT68TXSFPK7APHXA2QS2NJA5",
expected: testIntegrationAgePrivateKey, expected: "AGE-SECRET-KEY-19CR5YSFW59HM4TLD6GXVEDMZFTVVF7PPHKUT68TXSFPK7APHXA2QS2NJA5",
}, },
{ {
name: "key with trailing newline", name: "key with trailing newline",
input: testIntegrationAgePrivateKey + "\n", input: "AGE-SECRET-KEY-19CR5YSFW59HM4TLD6GXVEDMZFTVVF7PPHKUT68TXSFPK7APHXA2QS2NJA5\n",
expected: testIntegrationAgePrivateKey, expected: "AGE-SECRET-KEY-19CR5YSFW59HM4TLD6GXVEDMZFTVVF7PPHKUT68TXSFPK7APHXA2QS2NJA5",
}, },
{ {
name: "full age-keygen output", name: "full age-keygen output",
input: "# created: 2025-01-14T12:00:00Z\n" + input: `# created: 2025-01-14T12:00:00Z
"# public key: " + testIntegrationAgePublicKey + "\n" + # public key: age1ezrjmfpwsc95svdg0y54mums3zevgzu0x0ecq2f7tp8a05gl0sjq9q9wjg
testIntegrationAgePrivateKey + "\n", AGE-SECRET-KEY-19CR5YSFW59HM4TLD6GXVEDMZFTVVF7PPHKUT68TXSFPK7APHXA2QS2NJA5
expected: testIntegrationAgePrivateKey, `,
expected: "AGE-SECRET-KEY-19CR5YSFW59HM4TLD6GXVEDMZFTVVF7PPHKUT68TXSFPK7APHXA2QS2NJA5",
}, },
{ {
name: "age-keygen output with extra blank lines", name: "age-keygen output with extra blank lines",
input: "# created: 2025-01-14T12:00:00Z\n" + input: `# created: 2025-01-14T12:00:00Z
"# public key: " + testIntegrationAgePublicKey + "\n\n" + # public key: age1ezrjmfpwsc95svdg0y54mums3zevgzu0x0ecq2f7tp8a05gl0sjq9q9wjg
testIntegrationAgePrivateKey + "\n\n",
expected: testIntegrationAgePrivateKey, AGE-SECRET-KEY-19CR5YSFW59HM4TLD6GXVEDMZFTVVF7PPHKUT68TXSFPK7APHXA2QS2NJA5
`,
expected: "AGE-SECRET-KEY-19CR5YSFW59HM4TLD6GXVEDMZFTVVF7PPHKUT68TXSFPK7APHXA2QS2NJA5",
}, },
{ {
name: "key with leading whitespace", name: "key with leading whitespace",
input: " " + testIntegrationAgePrivateKey + " ", input: " AGE-SECRET-KEY-19CR5YSFW59HM4TLD6GXVEDMZFTVVF7PPHKUT68TXSFPK7APHXA2QS2NJA5 ",
expected: testIntegrationAgePrivateKey, expected: "AGE-SECRET-KEY-19CR5YSFW59HM4TLD6GXVEDMZFTVVF7PPHKUT68TXSFPK7APHXA2QS2NJA5",
}, },
{ {
name: "empty input", name: "empty input",
@@ -153,12 +138,9 @@ func TestExtractAgeSecretKey(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel()
result := extractAgeSecretKey(tt.input) result := extractAgeSecretKey(tt.input)
if result != tt.expected { if result != tt.expected {
t.Errorf("extractAgeSecretKey(%q) = %q, want %q", t.Errorf("extractAgeSecretKey(%q) = %q, want %q", tt.input, result, tt.expected)
tt.input, result, tt.expected)
} }
}) })
} }

View File

@@ -1,45 +1,31 @@
package config package config
import ( import (
"errors"
"fmt" "fmt"
"math"
"github.com/dustin/go-humanize" "github.com/dustin/go-humanize"
) )
var (
errSizeType = errors.New("size must be a number or string")
errSizeTooLarge = errors.New("size exceeds maximum supported value")
)
// Size represents a byte size that can be specified in configuration files. // Size represents a byte size that can be specified in configuration files.
// It can unmarshal from both numeric values (interpreted as bytes) and // It can unmarshal from both numeric values (interpreted as bytes) and
// human-readable strings like "10MB", "2.5GB", or "1TB". // human-readable strings like "10MB", "2.5GB", or "1TB".
//
//nolint:recvcheck // UnmarshalYAML requires a pointer; String/Int64 are value reads
type Size int64 type Size int64
// UnmarshalYAML implements yaml.Unmarshaler for Size, allowing it to be // UnmarshalYAML implements yaml.Unmarshaler for Size, allowing it to be
// parsed from YAML configuration files. It accepts both numeric values // parsed from YAML configuration files. It accepts both numeric values
// (interpreted as bytes) and string values with units (e.g., "10MB"). // (interpreted as bytes) and string values with units (e.g., "10MB").
func (s *Size) UnmarshalYAML(unmarshal func(any) error) error { func (s *Size) UnmarshalYAML(unmarshal func(interface{}) error) error {
// Try to unmarshal as int64 first // Try to unmarshal as int64 first
var intVal int64 var intVal int64
if err := unmarshal(&intVal); err == nil {
err := unmarshal(&intVal)
if err == nil {
*s = Size(intVal) *s = Size(intVal)
return nil return nil
} }
// Try to unmarshal as string // Try to unmarshal as string
var strVal string var strVal string
if err := unmarshal(&strVal); err != nil {
err = unmarshal(&strVal) return fmt.Errorf("size must be a number or string")
if err != nil {
return errSizeType
} }
// Parse the string using go-humanize // Parse the string using go-humanize
@@ -48,12 +34,7 @@ func (s *Size) UnmarshalYAML(unmarshal func(any) error) error {
return fmt.Errorf("invalid size format: %w", err) return fmt.Errorf("invalid size format: %w", err)
} }
if bytes > math.MaxInt64 {
return fmt.Errorf("%w: %s", errSizeTooLarge, strVal)
}
*s = Size(bytes) *s = Size(bytes)
return nil return nil
} }
@@ -68,7 +49,6 @@ func (s Size) Int64() int64 {
// For example, 1048576 bytes would be formatted as "1.0 MB". // For example, 1048576 bytes would be formatted as "1.0 MB".
// This implements the fmt.Stringer interface. // This implements the fmt.Stringer interface.
func (s Size) String() string { func (s Size) String() string {
//nolint:gosec // G115: sizes are non-negative by construction
return humanize.Bytes(uint64(s)) return humanize.Bytes(uint64(s))
} }
@@ -78,10 +58,5 @@ func ParseSize(s string) (Size, error) {
if err != nil { if err != nil {
return 0, fmt.Errorf("invalid size format: %w", err) return 0, fmt.Errorf("invalid size format: %w", err)
} }
if bytes > math.MaxInt64 {
return 0, fmt.Errorf("%w: %s", errSizeTooLarge, s)
}
return Size(bytes), nil return Size(bytes), nil
} }

View File

@@ -1,10 +1,7 @@
// Package crypto provides thread-safe age encryption and decryption package crypto
// helpers used to protect blob and metadata content.
package crypto //nolint:revive,nolintlint // stdlib crypto unused; see #76
import ( import (
"bytes" "bytes"
"errors"
"fmt" "fmt"
"io" "io"
"sync" "sync"
@@ -13,10 +10,6 @@ import (
"go.uber.org/fx" "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. // Encryptor provides thread-safe encryption using the age encryption library.
// It supports encrypting data for multiple recipients simultaneously, allowing // It supports encrypting data for multiple recipients simultaneously, allowing
// any of the corresponding private keys to decrypt the data. This is useful // any of the corresponding private keys to decrypt the data. This is useful
@@ -32,7 +25,7 @@ type Encryptor struct {
// public keys are invalid or if no recipients are specified. // public keys are invalid or if no recipients are specified.
func NewEncryptor(publicKeys []string) (*Encryptor, error) { func NewEncryptor(publicKeys []string) (*Encryptor, error) {
if len(publicKeys) == 0 { if len(publicKeys) == 0 {
return nil, ErrNoRecipients return nil, fmt.Errorf("at least one recipient is required")
} }
recipients := make([]age.Recipient, 0, len(publicKeys)) recipients := make([]age.Recipient, 0, len(publicKeys))
@@ -41,7 +34,6 @@ func NewEncryptor(publicKeys []string) (*Encryptor, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("parsing age recipient %s: %w", key, err) return nil, fmt.Errorf("parsing age recipient %s: %w", key, err)
} }
recipients = append(recipients, recipient) recipients = append(recipients, recipient)
} }
@@ -68,14 +60,12 @@ func (e *Encryptor) Encrypt(data []byte) ([]byte, error) {
} }
// Write data // Write data
_, err = w.Write(data) if _, err := w.Write(data); err != nil {
if err != nil {
return nil, fmt.Errorf("writing encrypted data: %w", err) return nil, fmt.Errorf("writing encrypted data: %w", err)
} }
// Close to flush // Close to flush
err = w.Close() if err := w.Close(); err != nil {
if err != nil {
return nil, fmt.Errorf("closing encrypted writer: %w", err) return nil, fmt.Errorf("closing encrypted writer: %w", err)
} }
@@ -98,14 +88,12 @@ func (e *Encryptor) EncryptStream(dst io.Writer, src io.Reader) error {
} }
// Copy data // Copy data
_, err = io.Copy(w, src) if _, err := io.Copy(w, src); err != nil {
if err != nil {
return fmt.Errorf("copying encrypted data: %w", err) return fmt.Errorf("copying encrypted data: %w", err)
} }
// Close to flush // Close to flush
err = w.Close() if err := w.Close(); err != nil {
if err != nil {
return fmt.Errorf("closing encrypted writer: %w", err) return fmt.Errorf("closing encrypted writer: %w", err)
} }
@@ -138,7 +126,7 @@ func (e *Encryptor) EncryptWriter(dst io.Writer) (io.WriteCloser, error) {
// of the public keys are invalid or if no recipients are specified. // of the public keys are invalid or if no recipients are specified.
func (e *Encryptor) UpdateRecipients(publicKeys []string) error { func (e *Encryptor) UpdateRecipients(publicKeys []string) error {
if len(publicKeys) == 0 { if len(publicKeys) == 0 {
return ErrNoRecipients return fmt.Errorf("at least one recipient is required")
} }
recipients := make([]age.Recipient, 0, len(publicKeys)) recipients := make([]age.Recipient, 0, len(publicKeys))
@@ -147,7 +135,6 @@ func (e *Encryptor) UpdateRecipients(publicKeys []string) error {
if err != nil { if err != nil {
return fmt.Errorf("parsing age recipient %s: %w", key, err) return fmt.Errorf("parsing age recipient %s: %w", key, err)
} }
recipients = append(recipients, recipient) recipients = append(recipients, recipient)
} }
@@ -219,6 +206,4 @@ func (d *Decryptor) DecryptStream(src io.Reader) (io.Reader, error) {
} }
// Module exports the crypto module for fx dependency injection. // Module exports the crypto module for fx dependency injection.
//
//nolint:gochecknoglobals // fx module definitions are package globals
var Module = fx.Module("crypto") var Module = fx.Module("crypto")

View File

@@ -1,16 +1,13 @@
package crypto_test package crypto
import ( import (
"bytes" "bytes"
"testing" "testing"
"filippo.io/age" "filippo.io/age"
"sneak.berlin/go/vaultik/internal/crypto"
) )
func TestEncryptor(t *testing.T) { func TestEncryptor(t *testing.T) {
t.Parallel()
// Generate a test key pair // Generate a test key pair
identity, err := age.GenerateX25519Identity() identity, err := age.GenerateX25519Identity()
if err != nil { if err != nil {
@@ -20,7 +17,7 @@ func TestEncryptor(t *testing.T) {
publicKey := identity.Recipient().String() publicKey := identity.Recipient().String()
// Create encryptor // Create encryptor
enc, err := crypto.NewEncryptor([]string{publicKey}) enc, err := NewEncryptor([]string{publicKey})
if err != nil { if err != nil {
t.Fatalf("failed to create encryptor: %v", err) t.Fatalf("failed to create encryptor: %v", err)
} }
@@ -46,9 +43,7 @@ func TestEncryptor(t *testing.T) {
} }
var decrypted bytes.Buffer var decrypted bytes.Buffer
if _, err := decrypted.ReadFrom(r); err != nil {
_, err = decrypted.ReadFrom(r)
if err != nil {
t.Fatalf("failed to read decrypted data: %v", err) t.Fatalf("failed to read decrypted data: %v", err)
} }
@@ -58,19 +53,15 @@ func TestEncryptor(t *testing.T) {
} }
func TestEncryptorMultipleRecipients(t *testing.T) { func TestEncryptorMultipleRecipients(t *testing.T) {
t.Parallel()
// Generate three test key pairs // Generate three test key pairs
identity1, err := age.GenerateX25519Identity() identity1, err := age.GenerateX25519Identity()
if err != nil { if err != nil {
t.Fatalf("failed to generate identity1: %v", err) t.Fatalf("failed to generate identity1: %v", err)
} }
identity2, err := age.GenerateX25519Identity() identity2, err := age.GenerateX25519Identity()
if err != nil { if err != nil {
t.Fatalf("failed to generate identity2: %v", err) t.Fatalf("failed to generate identity2: %v", err)
} }
identity3, err := age.GenerateX25519Identity() identity3, err := age.GenerateX25519Identity()
if err != nil { if err != nil {
t.Fatalf("failed to generate identity3: %v", err) t.Fatalf("failed to generate identity3: %v", err)
@@ -83,7 +74,7 @@ func TestEncryptorMultipleRecipients(t *testing.T) {
} }
// Create encryptor with multiple recipients // Create encryptor with multiple recipients
enc, err := crypto.NewEncryptor(publicKeys) enc, err := NewEncryptor(publicKeys)
if err != nil { if err != nil {
t.Fatalf("failed to create encryptor: %v", err) t.Fatalf("failed to create encryptor: %v", err)
} }
@@ -106,9 +97,7 @@ func TestEncryptorMultipleRecipients(t *testing.T) {
} }
var decrypted bytes.Buffer var decrypted bytes.Buffer
if _, err := decrypted.ReadFrom(r); err != nil {
_, err = decrypted.ReadFrom(r)
if err != nil {
t.Fatalf("recipient %d failed to read decrypted data: %v", i+1, err) t.Fatalf("recipient %d failed to read decrypted data: %v", i+1, err)
} }
@@ -119,8 +108,6 @@ func TestEncryptorMultipleRecipients(t *testing.T) {
} }
func TestEncryptorUpdateRecipients(t *testing.T) { func TestEncryptorUpdateRecipients(t *testing.T) {
t.Parallel()
// Generate two identities // Generate two identities
identity1, _ := age.GenerateX25519Identity() identity1, _ := age.GenerateX25519Identity()
identity2, _ := age.GenerateX25519Identity() identity2, _ := age.GenerateX25519Identity()
@@ -129,22 +116,20 @@ func TestEncryptorUpdateRecipients(t *testing.T) {
publicKey2 := identity2.Recipient().String() publicKey2 := identity2.Recipient().String()
// Create encryptor with first key // Create encryptor with first key
enc, err := crypto.NewEncryptor([]string{publicKey1}) enc, err := NewEncryptor([]string{publicKey1})
if err != nil { if err != nil {
t.Fatalf("failed to create encryptor: %v", err) t.Fatalf("failed to create encryptor: %v", err)
} }
// Encrypt with first key // Encrypt with first key
plaintext := []byte("test data") plaintext := []byte("test data")
ciphertext1, err := enc.Encrypt(plaintext) ciphertext1, err := enc.Encrypt(plaintext)
if err != nil { if err != nil {
t.Fatalf("failed to encrypt: %v", err) t.Fatalf("failed to encrypt: %v", err)
} }
// Update to second key // Update to second key
err = enc.UpdateRecipients([]string{publicKey2}) if err := enc.UpdateRecipients([]string{publicKey2}); err != nil {
if err != nil {
t.Fatalf("failed to update recipients: %v", err) t.Fatalf("failed to update recipients: %v", err)
} }
@@ -155,24 +140,18 @@ func TestEncryptorUpdateRecipients(t *testing.T) {
} }
// First ciphertext should only decrypt with first identity // First ciphertext should only decrypt with first identity
_, err = age.Decrypt(bytes.NewReader(ciphertext1), identity1) if _, err := age.Decrypt(bytes.NewReader(ciphertext1), identity1); err != nil {
if err != nil {
t.Error("failed to decrypt with identity1") t.Error("failed to decrypt with identity1")
} }
if _, err := age.Decrypt(bytes.NewReader(ciphertext1), identity2); err == nil {
_, err = age.Decrypt(bytes.NewReader(ciphertext1), identity2)
if err == nil {
t.Error("should not decrypt with identity2") t.Error("should not decrypt with identity2")
} }
// Second ciphertext should only decrypt with second identity // Second ciphertext should only decrypt with second identity
_, err = age.Decrypt(bytes.NewReader(ciphertext2), identity2) if _, err := age.Decrypt(bytes.NewReader(ciphertext2), identity2); err != nil {
if err != nil {
t.Error("failed to decrypt with identity2") t.Error("failed to decrypt with identity2")
} }
if _, err := age.Decrypt(bytes.NewReader(ciphertext2), identity1); err == nil {
_, err = age.Decrypt(bytes.NewReader(ciphertext2), identity1)
if err == nil {
t.Error("should not decrypt with identity1") t.Error("should not decrypt with identity1")
} }
} }

View File

@@ -3,25 +3,18 @@ package database
import ( import (
"context" "context"
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
) )
// BlobChunkRepository provides access to the blob_chunks table, which maps
// blobs to the chunks they contain (with offset and length).
type BlobChunkRepository struct { type BlobChunkRepository struct {
db *DB db *DB
} }
// NewBlobChunkRepository creates a BlobChunkRepository backed by db.
func NewBlobChunkRepository(db *DB) *BlobChunkRepository { func NewBlobChunkRepository(db *DB) *BlobChunkRepository {
return &BlobChunkRepository{db: db} return &BlobChunkRepository{db: db}
} }
// Create inserts a blob_chunks row, using tx when non-nil. func (r *BlobChunkRepository) Create(ctx context.Context, tx *sql.Tx, bc *BlobChunk) error {
func (r *BlobChunkRepository) Create(
ctx context.Context, tx *sql.Tx, bc *BlobChunk,
) error {
query := ` query := `
INSERT INTO blob_chunks (blob_id, chunk_hash, offset, length) INSERT INTO blob_chunks (blob_id, chunk_hash, offset, length)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
@@ -41,11 +34,7 @@ func (r *BlobChunkRepository) Create(
return nil return nil
} }
// GetByBlobID returns all chunks contained in the given blob, ordered by func (r *BlobChunkRepository) GetByBlobID(ctx context.Context, blobID string) ([]*BlobChunk, error) {
// their offset within the blob.
func (r *BlobChunkRepository) GetByBlobID(
ctx context.Context, blobID string,
) ([]*BlobChunk, error) {
query := ` query := `
SELECT blob_id, chunk_hash, offset, length SELECT blob_id, chunk_hash, offset, length
FROM blob_chunks FROM blob_chunks
@@ -57,35 +46,22 @@ func (r *BlobChunkRepository) GetByBlobID(
if err != nil { if err != nil {
return nil, fmt.Errorf("querying blob chunks: %w", err) return nil, fmt.Errorf("querying blob chunks: %w", err)
} }
defer CloseRows(rows)
defer func() {
err := rows.Close()
if err != nil {
Fatalf("failed to close rows: %v", err)
}
}()
var blobChunks []*BlobChunk var blobChunks []*BlobChunk
for rows.Next() { for rows.Next() {
var bc BlobChunk var bc BlobChunk
err := rows.Scan(&bc.BlobID, &bc.ChunkHash, &bc.Offset, &bc.Length) err := rows.Scan(&bc.BlobID, &bc.ChunkHash, &bc.Offset, &bc.Length)
if err != nil { if err != nil {
return nil, fmt.Errorf("scanning blob chunk: %w", err) return nil, fmt.Errorf("scanning blob chunk: %w", err)
} }
blobChunks = append(blobChunks, &bc) blobChunks = append(blobChunks, &bc)
} }
return blobChunks, rows.Err() return blobChunks, rows.Err()
} }
// GetByChunkHash returns one blob_chunks row containing the given chunk, func (r *BlobChunkRepository) GetByChunkHash(ctx context.Context, chunkHash string) (*BlobChunk, error) {
// or nil if the chunk is not packed in any blob.
func (r *BlobChunkRepository) GetByChunkHash(
ctx context.Context, chunkHash string,
) (*BlobChunk, error) {
query := ` query := `
SELECT blob_id, chunk_hash, offset, length SELECT blob_id, chunk_hash, offset, length
FROM blob_chunks FROM blob_chunks
@@ -94,9 +70,7 @@ func (r *BlobChunkRepository) GetByChunkHash(
` `
LogSQL("GetByChunkHash", query, chunkHash) LogSQL("GetByChunkHash", query, chunkHash)
var bc BlobChunk var bc BlobChunk
err := r.db.conn.QueryRowContext(ctx, query, chunkHash).Scan( err := r.db.conn.QueryRowContext(ctx, query, chunkHash).Scan(
&bc.BlobID, &bc.BlobID,
&bc.ChunkHash, &bc.ChunkHash,
@@ -104,27 +78,21 @@ func (r *BlobChunkRepository) GetByChunkHash(
&bc.Length, &bc.Length,
) )
if errors.Is(err, sql.ErrNoRows) { if err == sql.ErrNoRows {
LogSQL("GetByChunkHash", "No rows found", chunkHash) LogSQL("GetByChunkHash", "No rows found", chunkHash)
return nil, nil
return nil, nil //nolint:nilnil // nil,nil signals not-found; callers check nil
} }
if err != nil { if err != nil {
LogSQL("GetByChunkHash", "Error", chunkHash, err) LogSQL("GetByChunkHash", "Error", chunkHash, err)
return nil, fmt.Errorf("querying blob chunk: %w", err) return nil, fmt.Errorf("querying blob chunk: %w", err)
} }
LogSQL("GetByChunkHash", "Found blob", chunkHash, "blob", bc.BlobID) LogSQL("GetByChunkHash", "Found blob", chunkHash, "blob", bc.BlobID)
return &bc, nil return &bc, nil
} }
// GetByChunkHashTx retrieves a blob chunk within a transaction // GetByChunkHashTx retrieves a blob chunk within a transaction
func (r *BlobChunkRepository) GetByChunkHashTx( func (r *BlobChunkRepository) GetByChunkHashTx(ctx context.Context, tx *sql.Tx, chunkHash string) (*BlobChunk, error) {
ctx context.Context, tx *sql.Tx, chunkHash string,
) (*BlobChunk, error) {
query := ` query := `
SELECT blob_id, chunk_hash, offset, length SELECT blob_id, chunk_hash, offset, length
FROM blob_chunks FROM blob_chunks
@@ -133,9 +101,7 @@ func (r *BlobChunkRepository) GetByChunkHashTx(
` `
LogSQL("GetByChunkHashTx", query, chunkHash) LogSQL("GetByChunkHashTx", query, chunkHash)
var bc BlobChunk var bc BlobChunk
err := tx.QueryRowContext(ctx, query, chunkHash).Scan( err := tx.QueryRowContext(ctx, query, chunkHash).Scan(
&bc.BlobID, &bc.BlobID,
&bc.ChunkHash, &bc.ChunkHash,
@@ -143,25 +109,20 @@ func (r *BlobChunkRepository) GetByChunkHashTx(
&bc.Length, &bc.Length,
) )
if errors.Is(err, sql.ErrNoRows) { if err == sql.ErrNoRows {
LogSQL("GetByChunkHashTx", "No rows found", chunkHash) LogSQL("GetByChunkHashTx", "No rows found", chunkHash)
return nil, nil
return nil, nil //nolint:nilnil // nil,nil signals not-found; callers check nil
} }
if err != nil { if err != nil {
LogSQL("GetByChunkHashTx", "Error", chunkHash, err) LogSQL("GetByChunkHashTx", "Error", chunkHash, err)
return nil, fmt.Errorf("querying blob chunk: %w", err) return nil, fmt.Errorf("querying blob chunk: %w", err)
} }
LogSQL("GetByChunkHashTx", "Found blob", chunkHash, "blob", bc.BlobID) LogSQL("GetByChunkHashTx", "Found blob", chunkHash, "blob", bc.BlobID)
return &bc, nil return &bc, nil
} }
// DeleteOrphaned deletes blob_chunks entries where either the blob or the // DeleteOrphaned deletes blob_chunks entries where either the blob or chunk no longer exists
// chunk no longer exists.
func (r *BlobChunkRepository) DeleteOrphaned(ctx context.Context) error { func (r *BlobChunkRepository) DeleteOrphaned(ctx context.Context) error {
// Delete blob_chunks where the blob doesn't exist // Delete blob_chunks where the blob doesn't exist
query1 := ` query1 := `
@@ -171,9 +132,7 @@ func (r *BlobChunkRepository) DeleteOrphaned(ctx context.Context) error {
WHERE blobs.id = blob_chunks.blob_id WHERE blobs.id = blob_chunks.blob_id
) )
` `
if _, err := r.db.ExecWithLog(ctx, query1); err != nil {
_, err := r.db.ExecWithLog(ctx, query1)
if err != nil {
return fmt.Errorf("deleting blob_chunks with missing blobs: %w", err) return fmt.Errorf("deleting blob_chunks with missing blobs: %w", err)
} }
@@ -185,9 +144,7 @@ func (r *BlobChunkRepository) DeleteOrphaned(ctx context.Context) error {
WHERE chunks.chunk_hash = blob_chunks.chunk_hash WHERE chunks.chunk_hash = blob_chunks.chunk_hash
) )
` `
if _, err := r.db.ExecWithLog(ctx, query2); err != nil {
_, err = r.db.ExecWithLog(ctx, query2)
if err != nil {
return fmt.Errorf("deleting blob_chunks with missing chunks: %w", err) return fmt.Errorf("deleting blob_chunks with missing chunks: %w", err)
} }

View File

@@ -1,4 +1,4 @@
package database_test package database
import ( import (
"context" "context"
@@ -6,107 +6,71 @@ import (
"testing" "testing"
"time" "time"
"sneak.berlin/go/vaultik/internal/database" "git.eeqj.de/sneak/vaultik/internal/types"
"sneak.berlin/go/vaultik/internal/types"
) )
// Chunk hashes used across the blob_chunks tests.
const (
chunk1Hash = "chunk1"
chunk2Hash = "chunk2"
chunk3Hash = "chunk3"
)
// mustCreateChunks registers the given chunk hashes (1024 bytes each).
func mustCreateChunks(
t *testing.T,
repos *database.Repositories,
hashes ...types.ChunkHash,
) {
t.Helper()
ctx := context.Background()
for _, chunkHash := range hashes {
chunk := &database.Chunk{
ChunkHash: chunkHash,
Size: 1024,
}
err := repos.Chunks.Create(ctx, nil, chunk)
if err != nil {
t.Fatalf("failed to create chunk %s: %v", chunkHash, err)
}
}
}
// mustCreateBlob creates a blob row with the given hash.
func mustCreateBlob(
t *testing.T,
repos *database.Repositories,
hash types.BlobHash,
) *database.Blob {
t.Helper()
blob := &database.Blob{
ID: types.NewBlobID(),
Hash: hash,
CreatedTS: time.Now(),
}
err := repos.Blobs.Create(context.Background(), nil, blob)
if err != nil {
t.Fatalf("failed to create blob %s: %v", hash, err)
}
return blob
}
func TestBlobChunkRepository(t *testing.T) { func TestBlobChunkRepository(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t) db, cleanup := setupTestDB(t)
defer cleanup() defer cleanup()
ctx := context.Background() ctx := context.Background()
repos := database.NewRepositories(db) repos := NewRepositories(db)
blob := mustCreateBlob(t, repos, "blob1-hash") // Create blob first
mustCreateChunks(t, repos, chunk1Hash, chunk2Hash, chunk3Hash) blob := &Blob{
ID: types.NewBlobID(),
Hash: types.BlobHash("blob1-hash"),
CreatedTS: time.Now(),
}
err := repos.Blobs.Create(ctx, nil, blob)
if err != nil {
t.Fatalf("failed to create blob: %v", err)
}
// Create chunks
chunks := []types.ChunkHash{"chunk1", "chunk2", "chunk3"}
for _, chunkHash := range chunks {
chunk := &Chunk{
ChunkHash: chunkHash,
Size: 1024,
}
err = repos.Chunks.Create(ctx, nil, chunk)
if err != nil {
t.Fatalf("failed to create chunk %s: %v", chunkHash, err)
}
}
// Test Create // Test Create
bc1 := &database.BlobChunk{ bc1 := &BlobChunk{
BlobID: blob.ID, BlobID: blob.ID,
ChunkHash: types.ChunkHash(chunk1Hash), ChunkHash: types.ChunkHash("chunk1"),
Offset: 0, Offset: 0,
Length: 1024, Length: 1024,
} }
err := repos.BlobChunks.Create(ctx, nil, bc1) err = repos.BlobChunks.Create(ctx, nil, bc1)
if err != nil { if err != nil {
t.Fatalf("failed to create blob chunk: %v", err) t.Fatalf("failed to create blob chunk: %v", err)
} }
// Add more chunks to the same blob // Add more chunks to the same blob
bc2 := &database.BlobChunk{ bc2 := &BlobChunk{
BlobID: blob.ID, BlobID: blob.ID,
ChunkHash: types.ChunkHash(chunk2Hash), ChunkHash: types.ChunkHash("chunk2"),
Offset: 1024, Offset: 1024,
Length: 2048, Length: 2048,
} }
err = repos.BlobChunks.Create(ctx, nil, bc2) err = repos.BlobChunks.Create(ctx, nil, bc2)
if err != nil { if err != nil {
t.Fatalf("failed to create second blob chunk: %v", err) t.Fatalf("failed to create second blob chunk: %v", err)
} }
bc3 := &database.BlobChunk{ bc3 := &BlobChunk{
BlobID: blob.ID, BlobID: blob.ID,
ChunkHash: types.ChunkHash(chunk3Hash), ChunkHash: types.ChunkHash("chunk3"),
Offset: 3072, Offset: 3072,
Length: 512, Length: 512,
} }
err = repos.BlobChunks.Create(ctx, nil, bc3) err = repos.BlobChunks.Create(ctx, nil, bc3)
if err != nil { if err != nil {
t.Fatalf("failed to create third blob chunk: %v", err) t.Fatalf("failed to create third blob chunk: %v", err)
@@ -117,7 +81,6 @@ func TestBlobChunkRepository(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("failed to get blob chunks: %v", err) t.Fatalf("failed to get blob chunks: %v", err)
} }
if len(blobChunks) != 3 { if len(blobChunks) != 3 {
t.Errorf("expected 3 chunks, got %d", len(blobChunks)) t.Errorf("expected 3 chunks, got %d", len(blobChunks))
} }
@@ -126,97 +89,92 @@ func TestBlobChunkRepository(t *testing.T) {
expectedOffsets := []int64{0, 1024, 3072} expectedOffsets := []int64{0, 1024, 3072}
for i, bc := range blobChunks { for i, bc := range blobChunks {
if bc.Offset != expectedOffsets[i] { if bc.Offset != expectedOffsets[i] {
t.Errorf("wrong chunk order: expected offset %d, got %d", t.Errorf("wrong chunk order: expected offset %d, got %d", expectedOffsets[i], bc.Offset)
expectedOffsets[i], bc.Offset)
} }
} }
// Test GetByChunkHash
bc, err := repos.BlobChunks.GetByChunkHash(ctx, "chunk2")
if err != nil {
t.Fatalf("failed to get blob chunk by chunk hash: %v", err)
}
if bc == nil {
t.Fatal("expected blob chunk, got nil")
}
if bc.BlobID != blob.ID {
t.Errorf("wrong blob ID: expected %s, got %s", blob.ID, bc.BlobID)
}
if bc.Offset != 1024 {
t.Errorf("wrong offset: expected 1024, got %d", bc.Offset)
}
// Test duplicate insert (should fail due to primary key constraint) // Test duplicate insert (should fail due to primary key constraint)
err = repos.BlobChunks.Create(ctx, nil, bc1) err = repos.BlobChunks.Create(ctx, nil, bc1)
if err == nil { if err == nil {
t.Fatal("duplicate blob_chunk insert should fail due to primary key constraint") t.Fatal("duplicate blob_chunk insert should fail due to primary key constraint")
} }
if !strings.Contains(err.Error(), "UNIQUE") && !strings.Contains(err.Error(), "constraint") {
if !strings.Contains(err.Error(), "UNIQUE") &&
!strings.Contains(err.Error(), "constraint") {
t.Fatalf("expected constraint error, got: %v", err) t.Fatalf("expected constraint error, got: %v", err)
} }
}
func TestBlobChunkRepositoryGetByChunkHash(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
repos := database.NewRepositories(db)
blob := mustCreateBlob(t, repos, "blob-gbch-hash")
mustCreateChunks(t, repos, chunk2Hash)
bc2 := &database.BlobChunk{
BlobID: blob.ID,
ChunkHash: types.ChunkHash(chunk2Hash),
Offset: 1024,
Length: 2048,
}
err := repos.BlobChunks.Create(ctx, nil, bc2)
if err != nil {
t.Fatalf("failed to create blob chunk: %v", err)
}
// Test GetByChunkHash
bc, err := repos.BlobChunks.GetByChunkHash(ctx, chunk2Hash)
if err != nil {
t.Fatalf("failed to get blob chunk by chunk hash: %v", err)
}
if bc == nil {
t.Fatal("expected blob chunk, got nil")
}
if bc.BlobID != blob.ID {
t.Errorf("wrong blob ID: expected %s, got %s", blob.ID, bc.BlobID)
}
if bc.Offset != 1024 {
t.Errorf("wrong offset: expected 1024, got %d", bc.Offset)
}
// Test non-existent chunk // Test non-existent chunk
bc, err = repos.BlobChunks.GetByChunkHash(ctx, "nonexistent") bc, err = repos.BlobChunks.GetByChunkHash(ctx, "nonexistent")
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
if bc != nil { if bc != nil {
t.Error("expected nil for non-existent chunk") t.Error("expected nil for non-existent chunk")
} }
} }
func TestBlobChunkRepositoryMultipleBlobs(t *testing.T) { func TestBlobChunkRepositoryMultipleBlobs(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t) db, cleanup := setupTestDB(t)
defer cleanup() defer cleanup()
ctx := context.Background() ctx := context.Background()
repos := database.NewRepositories(db) repos := NewRepositories(db)
blob1 := mustCreateBlob(t, repos, "blob1-hash") // Create blobs
blob2 := mustCreateBlob(t, repos, "blob2-hash") blob1 := &Blob{
mustCreateChunks(t, repos, chunk1Hash, chunk2Hash, chunk3Hash) ID: types.NewBlobID(),
Hash: types.BlobHash("blob1-hash"),
CreatedTS: time.Now(),
}
blob2 := &Blob{
ID: types.NewBlobID(),
Hash: types.BlobHash("blob2-hash"),
CreatedTS: time.Now(),
}
err := repos.Blobs.Create(ctx, nil, blob1)
if err != nil {
t.Fatalf("failed to create blob1: %v", err)
}
err = repos.Blobs.Create(ctx, nil, blob2)
if err != nil {
t.Fatalf("failed to create blob2: %v", err)
}
// Create chunks
chunkHashes := []types.ChunkHash{"chunk1", "chunk2", "chunk3"}
for _, chunkHash := range chunkHashes {
chunk := &Chunk{
ChunkHash: chunkHash,
Size: 1024,
}
err = repos.Chunks.Create(ctx, nil, chunk)
if err != nil {
t.Fatalf("failed to create chunk %s: %v", chunkHash, err)
}
}
// Create chunks across multiple blobs // Create chunks across multiple blobs
// Some chunks are shared between blobs (deduplication scenario) // Some chunks are shared between blobs (deduplication scenario)
blobChunks := []database.BlobChunk{ blobChunks := []BlobChunk{
{BlobID: blob1.ID, ChunkHash: chunk1Hash, Offset: 0, Length: 1024}, {BlobID: blob1.ID, ChunkHash: types.ChunkHash("chunk1"), Offset: 0, Length: 1024},
{BlobID: blob1.ID, ChunkHash: chunk2Hash, Offset: 1024, Length: 1024}, {BlobID: blob1.ID, ChunkHash: types.ChunkHash("chunk2"), Offset: 1024, Length: 1024},
// chunk2 is shared between the blobs {BlobID: blob2.ID, ChunkHash: types.ChunkHash("chunk2"), Offset: 0, Length: 1024}, // chunk2 is shared
{BlobID: blob2.ID, ChunkHash: chunk2Hash, Offset: 0, Length: 1024}, {BlobID: blob2.ID, ChunkHash: types.ChunkHash("chunk3"), Offset: 1024, Length: 1024},
{BlobID: blob2.ID, ChunkHash: chunk3Hash, Offset: 1024, Length: 1024},
} }
for _, bc := range blobChunks { for _, bc := range blobChunks {
@@ -231,7 +189,6 @@ func TestBlobChunkRepositoryMultipleBlobs(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("failed to get blob1 chunks: %v", err) t.Fatalf("failed to get blob1 chunks: %v", err)
} }
if len(chunks) != 2 { if len(chunks) != 2 {
t.Errorf("expected 2 chunks for blob1, got %d", len(chunks)) t.Errorf("expected 2 chunks for blob1, got %d", len(chunks))
} }
@@ -241,17 +198,15 @@ func TestBlobChunkRepositoryMultipleBlobs(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("failed to get blob2 chunks: %v", err) t.Fatalf("failed to get blob2 chunks: %v", err)
} }
if len(chunks) != 2 { if len(chunks) != 2 {
t.Errorf("expected 2 chunks for blob2, got %d", len(chunks)) t.Errorf("expected 2 chunks for blob2, got %d", len(chunks))
} }
// Verify shared chunk // Verify shared chunk
bc, err := repos.BlobChunks.GetByChunkHash(ctx, chunk2Hash) bc, err := repos.BlobChunks.GetByChunkHash(ctx, "chunk2")
if err != nil { if err != nil {
t.Fatalf("failed to get shared chunk: %v", err) t.Fatalf("failed to get shared chunk: %v", err)
} }
if bc == nil { if bc == nil {
t.Fatal("expected shared chunk, got nil") t.Fatal("expected shared chunk, got nil")
} }

View File

@@ -3,39 +3,31 @@ package database
import ( import (
"context" "context"
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
"time" "time"
"sneak.berlin/go/vaultik/internal/log" "git.eeqj.de/sneak/vaultik/internal/log"
) )
// BlobRepository provides access to the blobs table, which tracks the
// packed, encrypted storage units uploaded to the destination.
type BlobRepository struct { type BlobRepository struct {
db *DB db *DB
} }
// NewBlobRepository creates a BlobRepository backed by db.
func NewBlobRepository(db *DB) *BlobRepository { func NewBlobRepository(db *DB) *BlobRepository {
return &BlobRepository{db: db} return &BlobRepository{db: db}
} }
// Create inserts a blob row, using tx when non-nil.
func (r *BlobRepository) Create(ctx context.Context, tx *sql.Tx, blob *Blob) error { func (r *BlobRepository) Create(ctx context.Context, tx *sql.Tx, blob *Blob) error {
query := ` query := `
INSERT INTO blobs (id, blob_hash, created_ts, finished_ts, INSERT INTO blobs (id, blob_hash, created_ts, finished_ts, uncompressed_size, compressed_size, uploaded_ts)
uncompressed_size, compressed_size, uploaded_ts)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
` `
var finishedTS, uploadedTS *int64 var finishedTS, uploadedTS *int64
if blob.FinishedTS != nil { if blob.FinishedTS != nil {
ts := blob.FinishedTS.Unix() ts := blob.FinishedTS.Unix()
finishedTS = &ts finishedTS = &ts
} }
if blob.UploadedTS != nil { if blob.UploadedTS != nil {
ts := blob.UploadedTS.Unix() ts := blob.UploadedTS.Unix()
uploadedTS = &ts uploadedTS = &ts
@@ -57,49 +49,18 @@ func (r *BlobRepository) Create(ctx context.Context, tx *sql.Tx, blob *Blob) err
return nil return nil
} }
// GetByHash returns the blob with the given content hash, or nil if no
// such blob exists.
func (r *BlobRepository) GetByHash(ctx context.Context, hash string) (*Blob, error) { func (r *BlobRepository) GetByHash(ctx context.Context, hash string) (*Blob, error) {
return r.getOne(ctx, "blob_hash", hash)
}
// GetByID retrieves a blob by its ID
func (r *BlobRepository) GetByID(ctx context.Context, id string) (*Blob, error) {
return r.getOne(ctx, "id", id)
}
// GetAll returns every blob row keyed by blob ID. Useful at restore
// start to translate the per-chunk blob_id references in chunkToBlobMap
// into blob hashes without doing one GetByID query per chunk.
func (r *BlobRepository) GetAll(ctx context.Context) (map[string]*Blob, error) {
query := ` query := `
SELECT id, blob_hash, created_ts, finished_ts, SELECT id, blob_hash, created_ts, finished_ts, uncompressed_size, compressed_size, uploaded_ts
uncompressed_size, compressed_size, uploaded_ts
FROM blobs FROM blobs
WHERE blob_hash = ?
` `
rows, err := r.db.conn.QueryContext(ctx, query) var blob Blob
if err != nil { var createdTSUnix int64
return nil, fmt.Errorf("querying blobs: %w", err) var finishedTSUnix, uploadedTSUnix sql.NullInt64
}
defer func() { err := r.db.conn.QueryRowContext(ctx, query, hash).Scan(
err := rows.Close()
if err != nil {
Fatalf("failed to close rows: %v", err)
}
}()
out := make(map[string]*Blob)
for rows.Next() {
var (
blob Blob
createdTSUnix int64
finishedTSUnix, uploadedTSUnix sql.NullInt64
)
err := rows.Scan(
&blob.ID, &blob.ID,
&blob.Hash, &blob.Hash,
&createdTSUnix, &createdTSUnix,
@@ -108,8 +69,12 @@ func (r *BlobRepository) GetAll(ctx context.Context) (map[string]*Blob, error) {
&blob.CompressedSize, &blob.CompressedSize,
&uploadedTSUnix, &uploadedTSUnix,
) )
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil { if err != nil {
return nil, fmt.Errorf("scanning blob: %w", err) return nil, fmt.Errorf("querying blob: %w", err)
} }
blob.CreatedTS = time.Unix(createdTSUnix, 0).UTC() blob.CreatedTS = time.Unix(createdTSUnix, 0).UTC()
@@ -117,26 +82,56 @@ func (r *BlobRepository) GetAll(ctx context.Context) (map[string]*Blob, error) {
ts := time.Unix(finishedTSUnix.Int64, 0).UTC() ts := time.Unix(finishedTSUnix.Int64, 0).UTC()
blob.FinishedTS = &ts blob.FinishedTS = &ts
} }
if uploadedTSUnix.Valid { if uploadedTSUnix.Valid {
ts := time.Unix(uploadedTSUnix.Int64, 0).UTC() ts := time.Unix(uploadedTSUnix.Int64, 0).UTC()
blob.UploadedTS = &ts blob.UploadedTS = &ts
} }
return &blob, nil
}
out[blob.ID.String()] = &blob // GetByID retrieves a blob by its ID
func (r *BlobRepository) GetByID(ctx context.Context, id string) (*Blob, error) {
query := `
SELECT id, blob_hash, created_ts, finished_ts, uncompressed_size, compressed_size, uploaded_ts
FROM blobs
WHERE id = ?
`
var blob Blob
var createdTSUnix int64
var finishedTSUnix, uploadedTSUnix sql.NullInt64
err := r.db.conn.QueryRowContext(ctx, query, id).Scan(
&blob.ID,
&blob.Hash,
&createdTSUnix,
&finishedTSUnix,
&blob.UncompressedSize,
&blob.CompressedSize,
&uploadedTSUnix,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("querying blob: %w", err)
} }
return out, rows.Err() blob.CreatedTS = time.Unix(createdTSUnix, 0).UTC()
if finishedTSUnix.Valid {
ts := time.Unix(finishedTSUnix.Int64, 0).UTC()
blob.FinishedTS = &ts
}
if uploadedTSUnix.Valid {
ts := time.Unix(uploadedTSUnix.Int64, 0).UTC()
blob.UploadedTS = &ts
}
return &blob, nil
} }
// UpdateFinished updates a blob when it's finalized // UpdateFinished updates a blob when it's finalized
func (r *BlobRepository) UpdateFinished( func (r *BlobRepository) UpdateFinished(ctx context.Context, tx *sql.Tx, id string, hash string, uncompressedSize, compressedSize int64) error {
ctx context.Context,
tx *sql.Tx,
id string,
hash string,
uncompressedSize, compressedSize int64,
) error {
query := ` query := `
UPDATE blobs UPDATE blobs
SET blob_hash = ?, finished_ts = ?, uncompressed_size = ?, compressed_size = ? SET blob_hash = ?, finished_ts = ?, uncompressed_size = ?, compressed_size = ?
@@ -144,7 +139,6 @@ func (r *BlobRepository) UpdateFinished(
` `
now := time.Now().UTC().Unix() now := time.Now().UTC().Unix()
var err error var err error
if tx != nil { if tx != nil {
_, err = tx.ExecContext(ctx, query, hash, now, uncompressedSize, compressedSize, id) _, err = tx.ExecContext(ctx, query, hash, now, uncompressedSize, compressedSize, id)
@@ -160,9 +154,7 @@ func (r *BlobRepository) UpdateFinished(
} }
// UpdateUploaded marks a blob as uploaded // UpdateUploaded marks a blob as uploaded
func (r *BlobRepository) UpdateUploaded( func (r *BlobRepository) UpdateUploaded(ctx context.Context, tx *sql.Tx, id string) error {
ctx context.Context, tx *sql.Tx, id string,
) error {
query := ` query := `
UPDATE blobs UPDATE blobs
SET uploaded_ts = ? SET uploaded_ts = ?
@@ -170,7 +162,6 @@ func (r *BlobRepository) UpdateUploaded(
` `
now := time.Now().UTC().Unix() now := time.Now().UTC().Unix()
var err error var err error
if tx != nil { if tx != nil {
_, err = tx.ExecContext(ctx, query, now, id) _, err = tx.ExecContext(ctx, query, now, id)
@@ -207,52 +198,3 @@ func (r *BlobRepository) DeleteOrphaned(ctx context.Context) error {
return nil return nil
} }
// getOne fetches a single blob row matched on the given column, or
// (nil, nil) when no row matches.
func (r *BlobRepository) getOne(
ctx context.Context, column, value string,
) (*Blob, error) {
query := `
SELECT id, blob_hash, created_ts, finished_ts,
uncompressed_size, compressed_size, uploaded_ts
FROM blobs
WHERE ` + column + ` = ?`
var (
blob Blob
createdTSUnix int64
finishedTSUnix, uploadedTSUnix sql.NullInt64
)
err := r.db.conn.QueryRowContext(ctx, query, value).Scan(
&blob.ID,
&blob.Hash,
&createdTSUnix,
&finishedTSUnix,
&blob.UncompressedSize,
&blob.CompressedSize,
&uploadedTSUnix,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil //nolint:nilnil // nil,nil signals not-found; callers check nil
}
if err != nil {
return nil, fmt.Errorf("querying blob: %w", err)
}
blob.CreatedTS = time.Unix(createdTSUnix, 0).UTC()
if finishedTSUnix.Valid {
ts := time.Unix(finishedTSUnix.Int64, 0).UTC()
blob.FinishedTS = &ts
}
if uploadedTSUnix.Valid {
ts := time.Unix(uploadedTSUnix.Int64, 0).UTC()
blob.UploadedTS = &ts
}
return &blob, nil
}

View File

@@ -1,25 +1,22 @@
package database_test package database
import ( import (
"context" "context"
"testing" "testing"
"time" "time"
"sneak.berlin/go/vaultik/internal/database" "git.eeqj.de/sneak/vaultik/internal/types"
"sneak.berlin/go/vaultik/internal/types"
) )
func TestBlobRepository(t *testing.T) { func TestBlobRepository(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t) db, cleanup := setupTestDB(t)
defer cleanup() defer cleanup()
ctx := context.Background() ctx := context.Background()
repo := database.NewBlobRepository(db) repo := NewBlobRepository(db)
// Test Create // Test Create
blob := &database.Blob{ blob := &Blob{
ID: types.NewBlobID(), ID: types.NewBlobID(),
Hash: types.BlobHash("blobhash123"), Hash: types.BlobHash("blobhash123"),
CreatedTS: time.Now().Truncate(time.Second), CreatedTS: time.Now().Truncate(time.Second),
@@ -35,18 +32,14 @@ func TestBlobRepository(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("failed to get blob: %v", err) t.Fatalf("failed to get blob: %v", err)
} }
if retrieved == nil { if retrieved == nil {
t.Fatal("expected blob, got nil") t.Fatal("expected blob, got nil")
} }
if retrieved.Hash != blob.Hash { if retrieved.Hash != blob.Hash {
t.Errorf("blob hash mismatch: got %s, want %s", retrieved.Hash, blob.Hash) t.Errorf("blob hash mismatch: got %s, want %s", retrieved.Hash, blob.Hash)
} }
if !retrieved.CreatedTS.Equal(blob.CreatedTS) { if !retrieved.CreatedTS.Equal(blob.CreatedTS) {
t.Errorf("created timestamp mismatch: got %v, want %v", t.Errorf("created timestamp mismatch: got %v, want %v", retrieved.CreatedTS, blob.CreatedTS)
retrieved.CreatedTS, blob.CreatedTS)
} }
// Test GetByID // Test GetByID
@@ -54,51 +47,26 @@ func TestBlobRepository(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("failed to get blob by ID: %v", err) t.Fatalf("failed to get blob by ID: %v", err)
} }
if retrievedByID == nil { if retrievedByID == nil {
t.Fatal("expected blob, got nil") t.Fatal("expected blob, got nil")
} }
if retrievedByID.ID != blob.ID { if retrievedByID.ID != blob.ID {
t.Errorf("blob ID mismatch: got %s, want %s", retrievedByID.ID, blob.ID) t.Errorf("blob ID mismatch: got %s, want %s", retrievedByID.ID, blob.ID)
} }
// Test with second blob // Test with second blob
blob2 := &database.Blob{ blob2 := &Blob{
ID: types.NewBlobID(), ID: types.NewBlobID(),
Hash: types.BlobHash("blobhash456"), Hash: types.BlobHash("blobhash456"),
CreatedTS: time.Now().Truncate(time.Second), CreatedTS: time.Now().Truncate(time.Second),
} }
err = repo.Create(ctx, nil, blob2) err = repo.Create(ctx, nil, blob2)
if err != nil { if err != nil {
t.Fatalf("failed to create second blob: %v", err) t.Fatalf("failed to create second blob: %v", err)
} }
}
func TestBlobRepositoryUpdates(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
repo := database.NewBlobRepository(db)
blob := &database.Blob{
ID: types.NewBlobID(),
Hash: types.BlobHash("blobhash123"),
CreatedTS: time.Now().Truncate(time.Second),
}
err := repo.Create(ctx, nil, blob)
if err != nil {
t.Fatalf("failed to create blob: %v", err)
}
// Test UpdateFinished // Test UpdateFinished
now := time.Now() now := time.Now()
err = repo.UpdateFinished(ctx, nil, blob.ID.String(), blob.Hash.String(), 1000, 500) err = repo.UpdateFinished(ctx, nil, blob.ID.String(), blob.Hash.String(), 1000, 500)
if err != nil { if err != nil {
t.Fatalf("failed to update blob as finished: %v", err) t.Fatalf("failed to update blob as finished: %v", err)
@@ -109,15 +77,12 @@ func TestBlobRepositoryUpdates(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("failed to get updated blob: %v", err) t.Fatalf("failed to get updated blob: %v", err)
} }
if updated.FinishedTS == nil { if updated.FinishedTS == nil {
t.Fatal("expected finished timestamp to be set") t.Fatal("expected finished timestamp to be set")
} }
if updated.UncompressedSize != 1000 { if updated.UncompressedSize != 1000 {
t.Errorf("expected uncompressed size 1000, got %d", updated.UncompressedSize) t.Errorf("expected uncompressed size 1000, got %d", updated.UncompressedSize)
} }
if updated.CompressedSize != 500 { if updated.CompressedSize != 500 {
t.Errorf("expected compressed size 500, got %d", updated.CompressedSize) t.Errorf("expected compressed size 500, got %d", updated.CompressedSize)
} }
@@ -133,7 +98,6 @@ func TestBlobRepositoryUpdates(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("failed to get uploaded blob: %v", err) t.Fatalf("failed to get uploaded blob: %v", err)
} }
if uploaded.UploadedTS == nil { if uploaded.UploadedTS == nil {
t.Fatal("expected uploaded timestamp to be set") t.Fatal("expected uploaded timestamp to be set")
} }
@@ -144,15 +108,13 @@ func TestBlobRepositoryUpdates(t *testing.T) {
} }
func TestBlobRepositoryDuplicate(t *testing.T) { func TestBlobRepositoryDuplicate(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t) db, cleanup := setupTestDB(t)
defer cleanup() defer cleanup()
ctx := context.Background() ctx := context.Background()
repo := database.NewBlobRepository(db) repo := NewBlobRepository(db)
blob := &database.Blob{ blob := &Blob{
ID: types.NewBlobID(), ID: types.NewBlobID(),
Hash: types.BlobHash("duplicate_blob"), Hash: types.BlobHash("duplicate_blob"),
CreatedTS: time.Now().Truncate(time.Second), CreatedTS: time.Now().Truncate(time.Second),

View File

@@ -1,4 +1,3 @@
//nolint:testpackage // inspects the unexported database connection
package database package database
import ( import (
@@ -7,16 +6,26 @@ import (
"testing" "testing"
"time" "time"
"sneak.berlin/go/vaultik/internal/types" "git.eeqj.de/sneak/vaultik/internal/types"
) )
// createCascadeFixtures creates a file with three chunk mappings for the // TestCascadeDeleteDebug tests cascade delete with debug output
// cascade-delete test. func TestCascadeDeleteDebug(t *testing.T) {
func createCascadeFixtures(t *testing.T, repos *Repositories) *File { db, cleanup := setupTestDB(t)
t.Helper() defer cleanup()
ctx := context.Background() ctx := context.Background()
repos := NewRepositories(db)
// Check if foreign keys are enabled
var fkEnabled int
err := db.conn.QueryRow("PRAGMA foreign_keys").Scan(&fkEnabled)
if err != nil {
t.Fatal(err)
}
t.Logf("Foreign keys enabled: %d", fkEnabled)
// Create a file
file := &File{ file := &File{
Path: "/cascade-test.txt", Path: "/cascade-test.txt",
MTime: time.Now().Truncate(time.Second), MTime: time.Now().Truncate(time.Second),
@@ -25,21 +34,18 @@ func createCascadeFixtures(t *testing.T, repos *Repositories) *File {
UID: 1000, UID: 1000,
GID: 1000, GID: 1000,
} }
err = repos.Files.Create(ctx, nil, file)
err := repos.Files.Create(ctx, nil, file)
if err != nil { if err != nil {
t.Fatalf("failed to create file: %v", err) t.Fatalf("failed to create file: %v", err)
} }
t.Logf("Created file with ID: %s", file.ID) t.Logf("Created file with ID: %s", file.ID)
// Create chunks and file-chunk mappings // Create chunks and file-chunk mappings
for i := range 3 { for i := 0; i < 3; i++ {
chunk := &Chunk{ chunk := &Chunk{
ChunkHash: types.ChunkHash(fmt.Sprintf("cascade-chunk-%d", i)), ChunkHash: types.ChunkHash(fmt.Sprintf("cascade-chunk-%d", i)),
Size: 1024, Size: 1024,
} }
err = repos.Chunks.Create(ctx, nil, chunk) err = repos.Chunks.Create(ctx, nil, chunk)
if err != nil { if err != nil {
t.Fatalf("failed to create chunk: %v", err) t.Fatalf("failed to create chunk: %v", err)
@@ -50,73 +56,33 @@ func createCascadeFixtures(t *testing.T, repos *Repositories) *File {
Idx: i, Idx: i,
ChunkHash: chunk.ChunkHash, ChunkHash: chunk.ChunkHash,
} }
err = repos.FileChunks.Create(ctx, nil, fc) err = repos.FileChunks.Create(ctx, nil, fc)
if err != nil { if err != nil {
t.Fatalf("failed to create file chunk: %v", err) t.Fatalf("failed to create file chunk: %v", err)
} }
t.Logf("Created file chunk mapping: file_id=%s, idx=%d, chunk=%s", fc.FileID, fc.Idx, fc.ChunkHash)
t.Logf("Created file chunk mapping: file_id=%s, idx=%d, chunk=%s",
fc.FileID, fc.Idx, fc.ChunkHash)
} }
return file
}
// logCascadeDebugInfo logs foreign-key state and the file_chunks table
// definition for cascade-delete debugging.
func logCascadeDebugInfo(ctx context.Context, t *testing.T, db *DB) {
t.Helper()
// Check if foreign keys are enabled
var fkEnabled int
err := db.conn.QueryRowContext(ctx, "PRAGMA foreign_keys").Scan(&fkEnabled)
if err != nil {
t.Fatal(err)
}
t.Logf("Foreign keys enabled: %d", fkEnabled)
// Check the foreign key constraint
var fkInfo string
err = db.conn.QueryRowContext(ctx, `
SELECT sql FROM sqlite_master
WHERE type='table' AND name='file_chunks'
`).Scan(&fkInfo)
if err != nil {
t.Fatal(err)
}
t.Logf("file_chunks table definition:\n%s", fkInfo)
}
// TestCascadeDeleteDebug tests cascade delete with debug output
func TestCascadeDeleteDebug(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
repos := NewRepositories(db)
logCascadeDebugInfo(ctx, t, db)
file := createCascadeFixtures(t, repos)
// Verify file chunks exist // Verify file chunks exist
fileChunks, err := repos.FileChunks.GetByFileID(ctx, file.ID) fileChunks, err := repos.FileChunks.GetByFileID(ctx, file.ID)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
t.Logf("File chunks before delete: %d", len(fileChunks)) t.Logf("File chunks before delete: %d", len(fileChunks))
// Check the foreign key constraint
var fkInfo string
err = db.conn.QueryRow(`
SELECT sql FROM sqlite_master
WHERE type='table' AND name='file_chunks'
`).Scan(&fkInfo)
if err != nil {
t.Fatal(err)
}
t.Logf("file_chunks table definition:\n%s", fkInfo)
// Delete the file // Delete the file
t.Log("Deleting file...") t.Log("Deleting file...")
err = repos.Files.DeleteByID(ctx, nil, file.ID) err = repos.Files.DeleteByID(ctx, nil, file.ID)
if err != nil { if err != nil {
t.Fatalf("failed to delete file: %v", err) t.Fatalf("failed to delete file: %v", err)
@@ -127,7 +93,6 @@ func TestCascadeDeleteDebug(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if deletedFile != nil { if deletedFile != nil {
t.Error("file should have been deleted") t.Error("file should have been deleted")
} else { } else {
@@ -139,27 +104,21 @@ func TestCascadeDeleteDebug(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
t.Logf("File chunks after delete: %d", len(fileChunks)) t.Logf("File chunks after delete: %d", len(fileChunks))
// Manually check the database // Manually check the database
var count int var count int
err = db.conn.QueryRow("SELECT COUNT(*) FROM file_chunks WHERE file_id = ?", file.ID).Scan(&count)
err = db.conn.QueryRowContext(ctx,
"SELECT COUNT(*) FROM file_chunks WHERE file_id = ?", file.ID,
).Scan(&count)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
t.Logf("Manual count of file_chunks for deleted file: %d", count) t.Logf("Manual count of file_chunks for deleted file: %d", count)
if len(fileChunks) != 0 { if len(fileChunks) != 0 {
t.Errorf("expected 0 file chunks after cascade delete, got %d", len(fileChunks)) t.Errorf("expected 0 file chunks after cascade delete, got %d", len(fileChunks))
// List the remaining chunks // List the remaining chunks
for _, fc := range fileChunks { for _, fc := range fileChunks {
t.Logf("Remaining chunk: file_id=%s, idx=%d, chunk=%s", t.Logf("Remaining chunk: file_id=%s, idx=%d, chunk=%s", fc.FileID, fc.Idx, fc.ChunkHash)
fc.FileID, fc.Idx, fc.ChunkHash)
} }
} }
} }

View File

@@ -4,26 +4,19 @@ import (
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
"strings"
"sneak.berlin/go/vaultik/internal/types" "git.eeqj.de/sneak/vaultik/internal/types"
) )
// ChunkFileRepository provides access to the chunk_files table, the
// reverse mapping from chunks to the files that contain them.
type ChunkFileRepository struct { type ChunkFileRepository struct {
db *DB db *DB
} }
// NewChunkFileRepository creates a ChunkFileRepository backed by db.
func NewChunkFileRepository(db *DB) *ChunkFileRepository { func NewChunkFileRepository(db *DB) *ChunkFileRepository {
return &ChunkFileRepository{db: db} return &ChunkFileRepository{db: db}
} }
// Create inserts a chunk_files row (idempotently), using tx when non-nil. func (r *ChunkFileRepository) Create(ctx context.Context, tx *sql.Tx, cf *ChunkFile) error {
func (r *ChunkFileRepository) Create(
ctx context.Context, tx *sql.Tx, cf *ChunkFile,
) error {
query := ` query := `
INSERT INTO chunk_files (chunk_hash, file_id, file_offset, length) INSERT INTO chunk_files (chunk_hash, file_id, file_offset, length)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
@@ -32,11 +25,9 @@ func (r *ChunkFileRepository) Create(
var err error var err error
if tx != nil { if tx != nil {
_, err = tx.ExecContext(ctx, query, _, err = tx.ExecContext(ctx, query, cf.ChunkHash.String(), cf.FileID.String(), cf.FileOffset, cf.Length)
cf.ChunkHash.String(), cf.FileID.String(), cf.FileOffset, cf.Length)
} else { } else {
_, err = r.db.ExecWithLog(ctx, query, _, err = r.db.ExecWithLog(ctx, query, cf.ChunkHash.String(), cf.FileID.String(), cf.FileOffset, cf.Length)
cf.ChunkHash.String(), cf.FileID.String(), cf.FileOffset, cf.Length)
} }
if err != nil { if err != nil {
@@ -46,10 +37,7 @@ func (r *ChunkFileRepository) Create(
return nil return nil
} }
// GetByChunkHash returns all chunk_files rows for the given chunk hash. func (r *ChunkFileRepository) GetByChunkHash(ctx context.Context, chunkHash types.ChunkHash) ([]*ChunkFile, error) {
func (r *ChunkFileRepository) GetByChunkHash(
ctx context.Context, chunkHash types.ChunkHash,
) ([]*ChunkFile, error) {
query := ` query := `
SELECT chunk_hash, file_id, file_offset, length SELECT chunk_hash, file_id, file_offset, length
FROM chunk_files FROM chunk_files
@@ -60,21 +48,12 @@ func (r *ChunkFileRepository) GetByChunkHash(
if err != nil { if err != nil {
return nil, fmt.Errorf("querying chunk files: %w", err) return nil, fmt.Errorf("querying chunk files: %w", err)
} }
defer CloseRows(rows)
defer func() {
err := rows.Close()
if err != nil {
Fatalf("failed to close rows: %v", err)
}
}()
return r.scanChunkFiles(rows) return r.scanChunkFiles(rows)
} }
// GetByFilePath returns all chunk_files rows for the file at the given path. func (r *ChunkFileRepository) GetByFilePath(ctx context.Context, filePath string) ([]*ChunkFile, error) {
func (r *ChunkFileRepository) GetByFilePath(
ctx context.Context, filePath string,
) ([]*ChunkFile, error) {
query := ` query := `
SELECT cf.chunk_hash, cf.file_id, cf.file_offset, cf.length SELECT cf.chunk_hash, cf.file_id, cf.file_offset, cf.length
FROM chunk_files cf FROM chunk_files cf
@@ -86,21 +65,13 @@ func (r *ChunkFileRepository) GetByFilePath(
if err != nil { if err != nil {
return nil, fmt.Errorf("querying chunk files: %w", err) return nil, fmt.Errorf("querying chunk files: %w", err)
} }
defer CloseRows(rows)
defer func() {
err := rows.Close()
if err != nil {
Fatalf("failed to close rows: %v", err)
}
}()
return r.scanChunkFiles(rows) return r.scanChunkFiles(rows)
} }
// GetByFileID retrieves chunk files by file ID // GetByFileID retrieves chunk files by file ID
func (r *ChunkFileRepository) GetByFileID( func (r *ChunkFileRepository) GetByFileID(ctx context.Context, fileID types.FileID) ([]*ChunkFile, error) {
ctx context.Context, fileID types.FileID,
) ([]*ChunkFile, error) {
query := ` query := `
SELECT chunk_hash, file_id, file_offset, length SELECT chunk_hash, file_id, file_offset, length
FROM chunk_files FROM chunk_files
@@ -111,21 +82,34 @@ func (r *ChunkFileRepository) GetByFileID(
if err != nil { if err != nil {
return nil, fmt.Errorf("querying chunk files: %w", err) return nil, fmt.Errorf("querying chunk files: %w", err)
} }
defer CloseRows(rows)
defer func() {
err := rows.Close()
if err != nil {
Fatalf("failed to close rows: %v", err)
}
}()
return r.scanChunkFiles(rows) return r.scanChunkFiles(rows)
} }
// scanChunkFiles is a helper that scans chunk file rows
func (r *ChunkFileRepository) scanChunkFiles(rows *sql.Rows) ([]*ChunkFile, error) {
var chunkFiles []*ChunkFile
for rows.Next() {
var cf ChunkFile
var chunkHashStr, fileIDStr string
err := rows.Scan(&chunkHashStr, &fileIDStr, &cf.FileOffset, &cf.Length)
if err != nil {
return nil, fmt.Errorf("scanning chunk file: %w", err)
}
cf.ChunkHash = types.ChunkHash(chunkHashStr)
cf.FileID, err = types.ParseFileID(fileIDStr)
if err != nil {
return nil, fmt.Errorf("parsing file ID: %w", err)
}
chunkFiles = append(chunkFiles, &cf)
}
return chunkFiles, rows.Err()
}
// DeleteByFileID deletes all chunk_files entries for a given file ID // DeleteByFileID deletes all chunk_files entries for a given file ID
func (r *ChunkFileRepository) DeleteByFileID( func (r *ChunkFileRepository) DeleteByFileID(ctx context.Context, tx *sql.Tx, fileID types.FileID) error {
ctx context.Context, tx *sql.Tx, fileID types.FileID,
) error {
query := `DELETE FROM chunk_files WHERE file_id = ?` query := `DELETE FROM chunk_files WHERE file_id = ?`
var err error var err error
@@ -143,11 +127,7 @@ func (r *ChunkFileRepository) DeleteByFileID(
} }
// DeleteByFileIDs deletes all chunk_files for multiple files in a single statement. // DeleteByFileIDs deletes all chunk_files for multiple files in a single statement.
// func (r *ChunkFileRepository) DeleteByFileIDs(ctx context.Context, tx *sql.Tx, fileIDs []types.FileID) error {
//nolint:dupl // symmetric implementation for a parallel association table
func (r *ChunkFileRepository) DeleteByFileIDs(
ctx context.Context, tx *sql.Tx, fileIDs []types.FileID,
) error {
if len(fileIDs) == 0 { if len(fileIDs) == 0 {
return nil return nil
} }
@@ -156,15 +136,14 @@ func (r *ChunkFileRepository) DeleteByFileIDs(
const batchSize = 500 const batchSize = 500
for i := 0; i < len(fileIDs); i += batchSize { for i := 0; i < len(fileIDs); i += batchSize {
end := min(i+batchSize, len(fileIDs)) end := i + batchSize
if end > len(fileIDs) {
end = len(fileIDs)
}
batch := fileIDs[i:end] batch := fileIDs[i:end]
//nolint:gosec // G202: concatenates constant SQL and "?" placeholders only query := "DELETE FROM chunk_files WHERE file_id IN (?" + repeatPlaceholder(len(batch)-1) + ")"
query := "DELETE FROM chunk_files WHERE file_id IN (?" + args := make([]interface{}, len(batch))
repeatPlaceholder(len(batch)-1) + ")"
args := make([]any, len(batch))
for j, id := range batch { for j, id := range batch {
args[j] = id.String() args[j] = id.String()
} }
@@ -175,7 +154,6 @@ func (r *ChunkFileRepository) DeleteByFileIDs(
} else { } else {
_, err = r.db.ExecWithLog(ctx, query, args...) _, err = r.db.ExecWithLog(ctx, query, args...)
} }
if err != nil { if err != nil {
return fmt.Errorf("batch deleting chunk_files: %w", err) return fmt.Errorf("batch deleting chunk_files: %w", err)
} }
@@ -185,43 +163,30 @@ func (r *ChunkFileRepository) DeleteByFileIDs(
} }
// CreateBatch inserts multiple chunk_files in a single statement for efficiency. // CreateBatch inserts multiple chunk_files in a single statement for efficiency.
func (r *ChunkFileRepository) CreateBatch( func (r *ChunkFileRepository) CreateBatch(ctx context.Context, tx *sql.Tx, cfs []ChunkFile) error {
ctx context.Context, tx *sql.Tx, cfs []ChunkFile,
) error {
if len(cfs) == 0 { if len(cfs) == 0 {
return nil return nil
} }
// Each chunk_files row binds this many SQL variables. // Each ChunkFile has 4 values, so batch at 200 to be safe with SQLite's variable limit
const chunkFileCols = 4
// Batch at 200 rows to be safe with SQLite's variable limit.
const batchSize = 200 const batchSize = 200
for i := 0; i < len(cfs); i += batchSize { for i := 0; i < len(cfs); i += batchSize {
end := min(i+batchSize, len(cfs)) end := i + batchSize
if end > len(cfs) {
end = len(cfs)
}
batch := cfs[i:end] batch := cfs[i:end]
query := "INSERT INTO chunk_files (chunk_hash, file_id, file_offset, length) VALUES " query := "INSERT INTO chunk_files (chunk_hash, file_id, file_offset, length) VALUES "
args := make([]interface{}, 0, len(batch)*4)
args := make([]any, 0, len(batch)*chunkFileCols)
var querySb183 strings.Builder
for j, cf := range batch { for j, cf := range batch {
if j > 0 { if j > 0 {
querySb183.WriteString(", ") query += ", "
} }
query += "(?, ?, ?, ?)"
querySb183.WriteString("(?, ?, ?, ?)") args = append(args, cf.ChunkHash.String(), cf.FileID.String(), cf.FileOffset, cf.Length)
args = append(args,
cf.ChunkHash.String(), cf.FileID.String(), cf.FileOffset, cf.Length)
} }
query += querySb183.String() //nolint:gosec // G202: appends "?" placeholders only
query += " ON CONFLICT(chunk_hash, file_id) DO NOTHING" query += " ON CONFLICT(chunk_hash, file_id) DO NOTHING"
var err error var err error
@@ -230,7 +195,6 @@ func (r *ChunkFileRepository) CreateBatch(
} else { } else {
_, err = r.db.ExecWithLog(ctx, query, args...) _, err = r.db.ExecWithLog(ctx, query, args...)
} }
if err != nil { if err != nil {
return fmt.Errorf("batch inserting chunk_files: %w", err) return fmt.Errorf("batch inserting chunk_files: %w", err)
} }
@@ -238,31 +202,3 @@ func (r *ChunkFileRepository) CreateBatch(
return nil return nil
} }
// scanChunkFiles is a helper that scans chunk file rows.
func (r *ChunkFileRepository) scanChunkFiles(rows *sql.Rows) ([]*ChunkFile, error) {
var chunkFiles []*ChunkFile
for rows.Next() {
var (
cf ChunkFile
chunkHashStr, fileIDStr string
)
err := rows.Scan(&chunkHashStr, &fileIDStr, &cf.FileOffset, &cf.Length)
if err != nil {
return nil, fmt.Errorf("scanning chunk file: %w", err)
}
cf.ChunkHash = types.ChunkHash(chunkHashStr)
cf.FileID, err = types.ParseFileID(fileIDStr)
if err != nil {
return nil, fmt.Errorf("parsing file ID: %w", err)
}
chunkFiles = append(chunkFiles, &cf)
}
return chunkFiles, rows.Err()
}

View File

@@ -1,139 +1,120 @@
package database_test package database
import ( import (
"context" "context"
"testing" "testing"
"time" "time"
"sneak.berlin/go/vaultik/internal/database" "git.eeqj.de/sneak/vaultik/internal/types"
"sneak.berlin/go/vaultik/internal/types"
) )
const chunk4Hash = "chunk4"
// verifyChunkFilePair asserts that the chunk-file rows cover both test
// files at their expected offsets.
func verifyChunkFilePair(
t *testing.T, chunkFiles []*database.ChunkFile,
file1ID, file2ID types.FileID,
) {
t.Helper()
foundFile1 := false
foundFile2 := false
for _, cf := range chunkFiles {
if cf.FileID == file1ID && cf.FileOffset == 0 {
foundFile1 = true
}
if cf.FileID == file2ID && cf.FileOffset == 2048 {
foundFile2 = true
}
}
if !foundFile1 || !foundFile2 {
t.Error("not all expected files found")
}
}
// createChunkFileTestFiles creates the two files used by the chunk-file
// repository tests.
func createChunkFileTestFiles(
t *testing.T, fileRepo *database.FileRepository,
) (*database.File, *database.File) {
t.Helper()
testTime := time.Now().Truncate(time.Second)
file1 := &database.File{
Path: testFilePath1,
MTime: testTime,
Size: 1024,
Mode: 0644,
UID: 1000,
GID: 1000,
LinkTarget: "",
}
file2 := &database.File{
Path: testFilePath2,
MTime: testTime,
Size: 1024,
Mode: 0644,
UID: 1000,
GID: 1000,
LinkTarget: "",
}
mustCreateFile(t, fileRepo, file1)
mustCreateFile(t, fileRepo, file2)
return file1, file2
}
func TestChunkFileRepository(t *testing.T) { func TestChunkFileRepository(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t) db, cleanup := setupTestDB(t)
defer cleanup() defer cleanup()
ctx := context.Background() ctx := context.Background()
repo := database.NewChunkFileRepository(db) repo := NewChunkFileRepository(db)
fileRepo := database.NewFileRepository(db) fileRepo := NewFileRepository(db)
repos := database.NewRepositories(db) chunksRepo := NewChunkRepository(db)
file1, file2 := createChunkFileTestFiles(t, fileRepo) // Create test files first
mustCreateChunks(t, repos, chunk1Hash) testTime := time.Now().Truncate(time.Second)
file1 := &File{
Path: "/file1.txt",
MTime: testTime,
Size: 1024,
Mode: 0644,
UID: 1000,
GID: 1000,
LinkTarget: "",
}
err := fileRepo.Create(ctx, nil, file1)
if err != nil {
t.Fatalf("failed to create file1: %v", err)
}
file2 := &File{
Path: "/file2.txt",
MTime: testTime,
Size: 1024,
Mode: 0644,
UID: 1000,
GID: 1000,
LinkTarget: "",
}
err = fileRepo.Create(ctx, nil, file2)
if err != nil {
t.Fatalf("failed to create file2: %v", err)
}
// Create chunk first
chunk := &Chunk{
ChunkHash: types.ChunkHash("chunk1"),
Size: 1024,
}
err = chunksRepo.Create(ctx, nil, chunk)
if err != nil {
t.Fatalf("failed to create chunk: %v", err)
}
// Test Create // Test Create
cf1 := &database.ChunkFile{ cf1 := &ChunkFile{
ChunkHash: types.ChunkHash(chunk1Hash), ChunkHash: types.ChunkHash("chunk1"),
FileID: file1.ID, FileID: file1.ID,
FileOffset: 0, FileOffset: 0,
Length: 1024, Length: 1024,
} }
err := repo.Create(ctx, nil, cf1) err = repo.Create(ctx, nil, cf1)
if err != nil { if err != nil {
t.Fatalf("failed to create chunk file: %v", err) t.Fatalf("failed to create chunk file: %v", err)
} }
// Add same chunk in different file (deduplication scenario) // Add same chunk in different file (deduplication scenario)
cf2 := &database.ChunkFile{ cf2 := &ChunkFile{
ChunkHash: types.ChunkHash(chunk1Hash), ChunkHash: types.ChunkHash("chunk1"),
FileID: file2.ID, FileID: file2.ID,
FileOffset: 2048, FileOffset: 2048,
Length: 1024, Length: 1024,
} }
err = repo.Create(ctx, nil, cf2) err = repo.Create(ctx, nil, cf2)
if err != nil { if err != nil {
t.Fatalf("failed to create second chunk file: %v", err) t.Fatalf("failed to create second chunk file: %v", err)
} }
// Test GetByChunkHash // Test GetByChunkHash
chunkFiles, err := repo.GetByChunkHash(ctx, chunk1Hash) chunkFiles, err := repo.GetByChunkHash(ctx, "chunk1")
if err != nil { if err != nil {
t.Fatalf("failed to get chunk files: %v", err) t.Fatalf("failed to get chunk files: %v", err)
} }
if len(chunkFiles) != 2 { if len(chunkFiles) != 2 {
t.Errorf("expected 2 files for chunk, got %d", len(chunkFiles)) t.Errorf("expected 2 files for chunk, got %d", len(chunkFiles))
} }
// Verify both files are returned // Verify both files are returned
verifyChunkFilePair(t, chunkFiles, file1.ID, file2.ID) foundFile1 := false
foundFile2 := false
for _, cf := range chunkFiles {
if cf.FileID == file1.ID && cf.FileOffset == 0 {
foundFile1 = true
}
if cf.FileID == file2.ID && cf.FileOffset == 2048 {
foundFile2 = true
}
}
if !foundFile1 || !foundFile2 {
t.Error("not all expected files found")
}
// Test GetByFileID // Test GetByFileID
chunkFiles, err = repo.GetByFileID(ctx, file1.ID) chunkFiles, err = repo.GetByFileID(ctx, file1.ID)
if err != nil { if err != nil {
t.Fatalf("failed to get chunks by file ID: %v", err) t.Fatalf("failed to get chunks by file ID: %v", err)
} }
if len(chunkFiles) != 1 { if len(chunkFiles) != 1 {
t.Errorf("expected 1 chunk for file, got %d", len(chunkFiles)) t.Errorf("expected 1 chunk for file, got %d", len(chunkFiles))
} }
if chunkFiles[0].ChunkHash != types.ChunkHash("chunk1") {
if chunkFiles[0].ChunkHash != types.ChunkHash(chunk1Hash) {
t.Errorf("wrong chunk hash: expected chunk1, got %s", chunkFiles[0].ChunkHash) t.Errorf("wrong chunk hash: expected chunk1, got %s", chunkFiles[0].ChunkHash)
} }
@@ -145,53 +126,60 @@ func TestChunkFileRepository(t *testing.T) {
} }
func TestChunkFileRepositoryComplexDeduplication(t *testing.T) { func TestChunkFileRepositoryComplexDeduplication(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t) db, cleanup := setupTestDB(t)
defer cleanup() defer cleanup()
ctx := context.Background() ctx := context.Background()
repo := database.NewChunkFileRepository(db) repo := NewChunkFileRepository(db)
fileRepo := database.NewFileRepository(db) fileRepo := NewFileRepository(db)
repos := database.NewRepositories(db) chunksRepo := NewChunkRepository(db)
// Create test files // Create test files
testTime := time.Now().Truncate(time.Second) testTime := time.Now().Truncate(time.Second)
file1 := &database.File{ file1 := &File{Path: "/file1.txt", MTime: testTime, Size: 3072, Mode: 0644, UID: 1000, GID: 1000}
Path: testFilePath1, MTime: testTime, Size: 3072, file2 := &File{Path: "/file2.txt", MTime: testTime, Size: 3072, Mode: 0644, UID: 1000, GID: 1000}
Mode: 0644, UID: 1000, GID: 1000, file3 := &File{Path: "/file3.txt", MTime: testTime, Size: 2048, Mode: 0644, UID: 1000, GID: 1000}
if err := fileRepo.Create(ctx, nil, file1); err != nil {
t.Fatalf("failed to create file1: %v", err)
} }
file2 := &database.File{ if err := fileRepo.Create(ctx, nil, file2); err != nil {
Path: testFilePath2, MTime: testTime, Size: 3072, t.Fatalf("failed to create file2: %v", err)
Mode: 0644, UID: 1000, GID: 1000,
} }
file3 := &database.File{ if err := fileRepo.Create(ctx, nil, file3); err != nil {
Path: "/file3.txt", MTime: testTime, Size: 2048, t.Fatalf("failed to create file3: %v", err)
Mode: 0644, UID: 1000, GID: 1000,
} }
mustCreateFile(t, fileRepo, file1) // Create chunks first
mustCreateFile(t, fileRepo, file2) chunks := []types.ChunkHash{"chunk1", "chunk2", "chunk3", "chunk4"}
mustCreateFile(t, fileRepo, file3) for _, chunkHash := range chunks {
mustCreateChunks(t, repos, chunk1Hash, chunk2Hash, chunk3Hash, chunk4Hash) chunk := &Chunk{
ChunkHash: chunkHash,
Size: 1024,
}
err := chunksRepo.Create(ctx, nil, chunk)
if err != nil {
t.Fatalf("failed to create chunk %s: %v", chunkHash, err)
}
}
// Simulate a scenario where multiple files share chunks // Simulate a scenario where multiple files share chunks
// File1: chunk1, chunk2, chunk3 // File1: chunk1, chunk2, chunk3
// File2: chunk2, chunk3, chunk4 // File2: chunk2, chunk3, chunk4
// File3: chunk1, chunk4 // File3: chunk1, chunk4
chunkFiles := []database.ChunkFile{ chunkFiles := []ChunkFile{
// File1 // File1
{ChunkHash: chunk1Hash, FileID: file1.ID, FileOffset: 0, Length: 1024}, {ChunkHash: types.ChunkHash("chunk1"), FileID: file1.ID, FileOffset: 0, Length: 1024},
{ChunkHash: chunk2Hash, FileID: file1.ID, FileOffset: 1024, Length: 1024}, {ChunkHash: types.ChunkHash("chunk2"), FileID: file1.ID, FileOffset: 1024, Length: 1024},
{ChunkHash: chunk3Hash, FileID: file1.ID, FileOffset: 2048, Length: 1024}, {ChunkHash: types.ChunkHash("chunk3"), FileID: file1.ID, FileOffset: 2048, Length: 1024},
// File2 // File2
{ChunkHash: chunk2Hash, FileID: file2.ID, FileOffset: 0, Length: 1024}, {ChunkHash: types.ChunkHash("chunk2"), FileID: file2.ID, FileOffset: 0, Length: 1024},
{ChunkHash: chunk3Hash, FileID: file2.ID, FileOffset: 1024, Length: 1024}, {ChunkHash: types.ChunkHash("chunk3"), FileID: file2.ID, FileOffset: 1024, Length: 1024},
{ChunkHash: chunk4Hash, FileID: file2.ID, FileOffset: 2048, Length: 1024}, {ChunkHash: types.ChunkHash("chunk4"), FileID: file2.ID, FileOffset: 2048, Length: 1024},
// File3 // File3
{ChunkHash: chunk1Hash, FileID: file3.ID, FileOffset: 0, Length: 1024}, {ChunkHash: types.ChunkHash("chunk1"), FileID: file3.ID, FileOffset: 0, Length: 1024},
{ChunkHash: chunk4Hash, FileID: file3.ID, FileOffset: 1024, Length: 1024}, {ChunkHash: types.ChunkHash("chunk4"), FileID: file3.ID, FileOffset: 1024, Length: 1024},
} }
for _, cf := range chunkFiles { for _, cf := range chunkFiles {
@@ -202,21 +190,19 @@ func TestChunkFileRepositoryComplexDeduplication(t *testing.T) {
} }
// Test chunk1 (used by file1 and file3) // Test chunk1 (used by file1 and file3)
files, err := repo.GetByChunkHash(ctx, chunk1Hash) files, err := repo.GetByChunkHash(ctx, "chunk1")
if err != nil { if err != nil {
t.Fatalf("failed to get files for chunk1: %v", err) t.Fatalf("failed to get files for chunk1: %v", err)
} }
if len(files) != 2 { if len(files) != 2 {
t.Errorf("expected 2 files for chunk1, got %d", len(files)) t.Errorf("expected 2 files for chunk1, got %d", len(files))
} }
// Test chunk2 (used by file1 and file2) // Test chunk2 (used by file1 and file2)
files, err = repo.GetByChunkHash(ctx, chunk2Hash) files, err = repo.GetByChunkHash(ctx, "chunk2")
if err != nil { if err != nil {
t.Fatalf("failed to get files for chunk2: %v", err) t.Fatalf("failed to get files for chunk2: %v", err)
} }
if len(files) != 2 { if len(files) != 2 {
t.Errorf("expected 2 files for chunk2, got %d", len(files)) t.Errorf("expected 2 files for chunk2, got %d", len(files))
} }
@@ -226,7 +212,6 @@ func TestChunkFileRepositoryComplexDeduplication(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("failed to get chunks for file2: %v", err) t.Fatalf("failed to get chunks for file2: %v", err)
} }
if len(file2Chunks) != 3 { if len(file2Chunks) != 3 {
t.Errorf("expected 3 chunks for file2, got %d", len(file2Chunks)) t.Errorf("expected 3 chunks for file2, got %d", len(file2Chunks))
} }

View File

@@ -3,25 +3,19 @@ package database
import ( import (
"context" "context"
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
"strings"
"sneak.berlin/go/vaultik/internal/log" "git.eeqj.de/sneak/vaultik/internal/log"
) )
// ChunkRepository provides access to the chunks table, which tracks
// content-defined chunks by hash and size.
type ChunkRepository struct { type ChunkRepository struct {
db *DB db *DB
} }
// NewChunkRepository creates a ChunkRepository backed by db.
func NewChunkRepository(db *DB) *ChunkRepository { func NewChunkRepository(db *DB) *ChunkRepository {
return &ChunkRepository{db: db} return &ChunkRepository{db: db}
} }
// Create inserts a chunk row (idempotently), using tx when non-nil.
func (r *ChunkRepository) Create(ctx context.Context, tx *sql.Tx, chunk *Chunk) error { func (r *ChunkRepository) Create(ctx context.Context, tx *sql.Tx, chunk *Chunk) error {
query := ` query := `
INSERT INTO chunks (chunk_hash, size) INSERT INTO chunks (chunk_hash, size)
@@ -43,8 +37,6 @@ func (r *ChunkRepository) Create(ctx context.Context, tx *sql.Tx, chunk *Chunk)
return nil return nil
} }
// GetByHash returns the chunk with the given hash, or nil if it is not
// known to the index.
func (r *ChunkRepository) GetByHash(ctx context.Context, hash string) (*Chunk, error) { func (r *ChunkRepository) GetByHash(ctx context.Context, hash string) (*Chunk, error) {
query := ` query := `
SELECT chunk_hash, size SELECT chunk_hash, size
@@ -59,10 +51,9 @@ func (r *ChunkRepository) GetByHash(ctx context.Context, hash string) (*Chunk, e
&chunk.Size, &chunk.Size,
) )
if errors.Is(err, sql.ErrNoRows) { if err == sql.ErrNoRows {
return nil, nil //nolint:nilnil // nil,nil signals not-found; callers check nil return nil, nil
} }
if err != nil { if err != nil {
return nil, fmt.Errorf("querying chunk: %w", err) return nil, fmt.Errorf("querying chunk: %w", err)
} }
@@ -70,11 +61,7 @@ func (r *ChunkRepository) GetByHash(ctx context.Context, hash string) (*Chunk, e
return &chunk, nil return &chunk, nil
} }
// GetByHashes returns the chunks whose hashes appear in hashes, ordered by func (r *ChunkRepository) GetByHashes(ctx context.Context, hashes []string) ([]*Chunk, error) {
// chunk hash. Unknown hashes are silently omitted from the result.
func (r *ChunkRepository) GetByHashes(
ctx context.Context, hashes []string,
) ([]*Chunk, error) {
if len(hashes) == 0 { if len(hashes) == 0 {
return nil, nil return nil, nil
} }
@@ -84,38 +71,23 @@ func (r *ChunkRepository) GetByHashes(
FROM chunks FROM chunks
WHERE chunk_hash IN (` WHERE chunk_hash IN (`
args := make([]any, len(hashes)) args := make([]interface{}, len(hashes))
var querySb75 strings.Builder
for i, hash := range hashes { for i, hash := range hashes {
if i > 0 { if i > 0 {
querySb75.WriteString(", ") query += ", "
} }
query += "?"
querySb75.WriteString("?")
args[i] = hash args[i] = hash
} }
query += querySb75.String() //nolint:gosec // G202: appends "?" placeholders only
query += ") ORDER BY chunk_hash" query += ") ORDER BY chunk_hash"
rows, err := r.db.conn.QueryContext(ctx, query, args...) rows, err := r.db.conn.QueryContext(ctx, query, args...)
if err != nil { if err != nil {
return nil, fmt.Errorf("querying chunks: %w", err) return nil, fmt.Errorf("querying chunks: %w", err)
} }
defer CloseRows(rows)
defer func() {
err := rows.Close()
if err != nil {
Fatalf("failed to close rows: %v", err)
}
}()
var chunks []*Chunk var chunks []*Chunk
for rows.Next() { for rows.Next() {
var chunk Chunk var chunk Chunk
@@ -133,11 +105,7 @@ func (r *ChunkRepository) GetByHashes(
return chunks, rows.Err() return chunks, rows.Err()
} }
// ListUnpacked returns up to limit chunks that are not yet stored in any func (r *ChunkRepository) ListUnpacked(ctx context.Context, limit int) ([]*Chunk, error) {
// blob, ordered by chunk hash.
func (r *ChunkRepository) ListUnpacked(
ctx context.Context, limit int,
) ([]*Chunk, error) {
query := ` query := `
SELECT c.chunk_hash, c.size SELECT c.chunk_hash, c.size
FROM chunks c FROM chunks c
@@ -151,16 +119,9 @@ func (r *ChunkRepository) ListUnpacked(
if err != nil { if err != nil {
return nil, fmt.Errorf("querying unpacked chunks: %w", err) return nil, fmt.Errorf("querying unpacked chunks: %w", err)
} }
defer CloseRows(rows)
defer func() {
err := rows.Close()
if err != nil {
Fatalf("failed to close rows: %v", err)
}
}()
var chunks []*Chunk var chunks []*Chunk
for rows.Next() { for rows.Next() {
var chunk Chunk var chunk Chunk

View File

@@ -5,7 +5,6 @@ import (
"fmt" "fmt"
) )
// List returns every chunk in the index, ordered by chunk hash.
func (r *ChunkRepository) List(ctx context.Context) ([]*Chunk, error) { func (r *ChunkRepository) List(ctx context.Context) ([]*Chunk, error) {
query := ` query := `
SELECT chunk_hash, size SELECT chunk_hash, size
@@ -17,16 +16,9 @@ func (r *ChunkRepository) List(ctx context.Context) ([]*Chunk, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("querying chunks: %w", err) return nil, fmt.Errorf("querying chunks: %w", err)
} }
defer CloseRows(rows)
defer func() {
err := rows.Close()
if err != nil {
Fatalf("failed to close rows: %v", err)
}
}()
var chunks []*Chunk var chunks []*Chunk
for rows.Next() { for rows.Next() {
var chunk Chunk var chunk Chunk

View File

@@ -1,24 +1,21 @@
package database_test package database
import ( import (
"context" "context"
"testing" "testing"
"sneak.berlin/go/vaultik/internal/database" "git.eeqj.de/sneak/vaultik/internal/types"
"sneak.berlin/go/vaultik/internal/types"
) )
func TestChunkRepository(t *testing.T) { func TestChunkRepository(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t) db, cleanup := setupTestDB(t)
defer cleanup() defer cleanup()
ctx := context.Background() ctx := context.Background()
repo := database.NewChunkRepository(db) repo := NewChunkRepository(db)
// Test Create // Test Create
chunk := &database.Chunk{ chunk := &Chunk{
ChunkHash: types.ChunkHash("chunkhash123"), ChunkHash: types.ChunkHash("chunkhash123"),
Size: 4096, Size: 4096,
} }
@@ -33,15 +30,12 @@ func TestChunkRepository(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("failed to get chunk: %v", err) t.Fatalf("failed to get chunk: %v", err)
} }
if retrieved == nil { if retrieved == nil {
t.Fatal("expected chunk, got nil") t.Fatal("expected chunk, got nil")
} }
if retrieved.ChunkHash != chunk.ChunkHash { if retrieved.ChunkHash != chunk.ChunkHash {
t.Errorf("chunk hash mismatch: got %s, want %s", retrieved.ChunkHash, chunk.ChunkHash) t.Errorf("chunk hash mismatch: got %s, want %s", retrieved.ChunkHash, chunk.ChunkHash)
} }
if retrieved.Size != chunk.Size { if retrieved.Size != chunk.Size {
t.Errorf("size mismatch: got %d, want %d", retrieved.Size, chunk.Size) t.Errorf("size mismatch: got %d, want %d", retrieved.Size, chunk.Size)
} }
@@ -53,23 +47,19 @@ func TestChunkRepository(t *testing.T) {
} }
// Test GetByHashes // Test GetByHashes
chunk2 := &database.Chunk{ chunk2 := &Chunk{
ChunkHash: types.ChunkHash("chunkhash456"), ChunkHash: types.ChunkHash("chunkhash456"),
Size: 8192, Size: 8192,
} }
err = repo.Create(ctx, nil, chunk2) err = repo.Create(ctx, nil, chunk2)
if err != nil { if err != nil {
t.Fatalf("failed to create second chunk: %v", err) t.Fatalf("failed to create second chunk: %v", err)
} }
chunks, err := repo.GetByHashes(ctx, []string{ chunks, err := repo.GetByHashes(ctx, []string{chunk.ChunkHash.String(), chunk2.ChunkHash.String()})
chunk.ChunkHash.String(), chunk2.ChunkHash.String(),
})
if err != nil { if err != nil {
t.Fatalf("failed to get chunks by hashes: %v", err) t.Fatalf("failed to get chunks by hashes: %v", err)
} }
if len(chunks) != 2 { if len(chunks) != 2 {
t.Errorf("expected 2 chunks, got %d", len(chunks)) t.Errorf("expected 2 chunks, got %d", len(chunks))
} }
@@ -79,27 +69,23 @@ func TestChunkRepository(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("failed to list unpacked chunks: %v", err) t.Fatalf("failed to list unpacked chunks: %v", err)
} }
if len(unpacked) != 2 { if len(unpacked) != 2 {
t.Errorf("expected 2 unpacked chunks, got %d", len(unpacked)) t.Errorf("expected 2 unpacked chunks, got %d", len(unpacked))
} }
} }
func TestChunkRepositoryNotFound(t *testing.T) { func TestChunkRepositoryNotFound(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t) db, cleanup := setupTestDB(t)
defer cleanup() defer cleanup()
ctx := context.Background() ctx := context.Background()
repo := database.NewChunkRepository(db) repo := NewChunkRepository(db)
// Test GetByHash with non-existent hash // Test GetByHash with non-existent hash
chunk, err := repo.GetByHash(ctx, "nonexistent") chunk, err := repo.GetByHash(ctx, "nonexistent")
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
if chunk != nil { if chunk != nil {
t.Error("expected nil for non-existent chunk") t.Error("expected nil for non-existent chunk")
} }
@@ -109,7 +95,6 @@ func TestChunkRepositoryNotFound(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
if chunks != nil { if chunks != nil {
t.Error("expected nil for empty hash list") t.Error("expected nil for empty hash list")
} }

View File

@@ -6,38 +6,24 @@
// multiple source files. Blobs are content-addressed, meaning their filename // multiple source files. Blobs are content-addressed, meaning their filename
// is derived from their SHA256 hash after compression and encryption. // is derived from their SHA256 hash after compression and encryption.
// //
// Schema is managed via numbered SQL migrations embedded in the schema/ // The database does not support migrations. If the schema changes, delete
// directory. Migration 000.sql bootstraps the schema_migrations tracking // the local database and perform a full backup to recreate it.
// table; subsequent migrations (001, 002, …) are applied in order.
package database package database
import ( import (
"context" "context"
"database/sql" "database/sql"
"embed" _ "embed"
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath"
"sort"
"strconv"
"strings" "strings"
// Register the pure-Go sqlite driver. "git.eeqj.de/sneak/vaultik/internal/log"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
"sneak.berlin/go/vaultik/internal/log"
) )
// errInvalidMigrationFilename is returned when an embedded migration file //go:embed schema.sql
// does not follow the "<version>[_<description>].sql" naming pattern. var schemaSQL string
var errInvalidMigrationFilename = errors.New("invalid migration filename")
//go:embed schema/*.sql
var schemaFS embed.FS
// bootstrapVersion is the migration that creates the schema_migrations
// table itself. It is applied before the normal migration loop.
const bootstrapVersion = 0
// DB represents the Vaultik local index database connection. // DB represents the Vaultik local index database connection.
// It uses SQLite to track file metadata, content-defined chunks, and blob associations. // It uses SQLite to track file metadata, content-defined chunks, and blob associations.
@@ -49,48 +35,6 @@ type DB struct {
path string path string
} }
// ParseMigrationVersion extracts the numeric version prefix from a migration
// filename. Filenames must follow the pattern "<version>.sql" or
// "<version>_<description>.sql", where version is a zero-padded numeric
// string (e.g. "001", "002"). Returns the version as an integer and an
// error if the filename does not match the expected pattern.
func ParseMigrationVersion(filename string) (int, error) {
name := strings.TrimSuffix(filename, filepath.Ext(filename))
if name == "" {
return 0, fmt.Errorf("%w %q: empty name", errInvalidMigrationFilename, filename)
}
// Split on underscore to separate version from description.
// If there's no underscore, the entire stem is the version.
versionStr := name
if before, _, ok := strings.Cut(name, "_"); ok {
versionStr = before
}
if versionStr == "" {
return 0, fmt.Errorf(
"%w %q: empty version prefix", errInvalidMigrationFilename, filename,
)
}
// Validate the version is purely numeric.
for _, ch := range versionStr {
if ch < '0' || ch > '9' {
return 0, fmt.Errorf(
"%w %q: version %q contains non-numeric character %q",
errInvalidMigrationFilename, filename, versionStr, string(ch),
)
}
}
version, err := strconv.Atoi(versionStr)
if err != nil {
return 0, fmt.Errorf("invalid migration filename %q: %w", filename, err)
}
return version, nil
}
// New creates a new database connection at the specified path. // New creates a new database connection at the specified path.
// It creates the schema if needed and configures SQLite with WAL mode for // It creates the schema if needed and configures SQLite with WAL mode for
// better concurrency. SQLite handles crash recovery automatically when // better concurrency. SQLite handles crash recovery automatically when
@@ -106,93 +50,61 @@ func New(ctx context.Context, path string) (*DB, error) {
// First attempt with standard WAL mode // First attempt with standard WAL mode
log.Debug("Attempting to open database with WAL mode", "path", path) log.Debug("Attempting to open database with WAL mode", "path", path)
conn, err := sql.Open( conn, err := sql.Open(
"sqlite", "sqlite",
path+"?_journal_mode=WAL&_synchronous=NORMAL&_busy_timeout=10000"+ path+"?_journal_mode=WAL&_synchronous=NORMAL&_busy_timeout=10000&_locking_mode=NORMAL&_foreign_keys=ON",
"&_locking_mode=NORMAL&_foreign_keys=ON",
) )
if err == nil { if err == nil {
configureConnPool(conn) // Set connection pool settings
// SQLite can handle multiple readers but only one writer at a time.
// Setting MaxOpenConns to 1 ensures all writes are serialized through
// a single connection, preventing SQLITE_BUSY errors.
conn.SetMaxOpenConns(1)
conn.SetMaxIdleConns(1)
err = conn.PingContext(ctx) if err := conn.PingContext(ctx); err == nil {
if err == nil {
// Success on first try // Success on first try
log.Debug("Database opened successfully with WAL mode", "path", path) log.Debug("Database opened successfully with WAL mode", "path", path)
return finishOpen(ctx, conn, path) // Enable foreign keys explicitly
if _, err := conn.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil {
log.Warn("Failed to enable foreign keys", "error", err)
} }
log.Debug( db := &DB{conn: conn, path: path}
"Failed to ping database, closing connection", if err := db.createSchema(ctx); err != nil {
"path", path, "error", err, _ = conn.Close()
) return nil, fmt.Errorf("creating schema: %w", err)
}
return db, nil
}
log.Debug("Failed to ping database, closing connection", "path", path, "error", err)
_ = conn.Close() _ = conn.Close()
} }
// If first attempt failed, try with TRUNCATE mode to clear any locks // If first attempt failed, try with TRUNCATE mode to clear any locks
return openWithRecovery(ctx, path)
}
// configureConnPool serializes all database access through one connection.
// SQLite can handle multiple readers but only one writer at a time; setting
// MaxOpenConns to 1 ensures all writes go through a single connection,
// preventing SQLITE_BUSY errors.
func configureConnPool(conn *sql.DB) {
conn.SetMaxOpenConns(1)
conn.SetMaxIdleConns(1)
}
// finishOpen enables foreign keys, wraps the connection, and applies any
// pending migrations. On migration failure the connection is closed.
func finishOpen(ctx context.Context, conn *sql.DB, path string) (*DB, error) {
// Enable foreign keys explicitly
_, err := conn.ExecContext(ctx, "PRAGMA foreign_keys = ON")
if err != nil {
log.Warn("Failed to enable foreign keys", "path", path, "error", err)
}
db := &DB{conn: conn, path: path}
err = applyMigrations(ctx, conn)
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("applying migrations: %w", err)
}
return db, nil
}
// openWithRecovery retries opening the database in TRUNCATE journal mode to
// clear stale locks, then switches back to WAL mode.
func openWithRecovery(ctx context.Context, path string) (*DB, error) {
log.Info( log.Info(
"Database appears locked, attempting recovery with TRUNCATE mode", "Database appears locked, attempting recovery with TRUNCATE mode",
"path", path, "path", path,
) )
conn, err = sql.Open(
conn, err := sql.Open(
"sqlite", "sqlite",
path+"?_journal_mode=TRUNCATE&_synchronous=NORMAL&_busy_timeout=10000"+ path+"?_journal_mode=TRUNCATE&_synchronous=NORMAL&_busy_timeout=10000&_foreign_keys=ON",
"&_foreign_keys=ON",
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("opening database in recovery mode: %w", err) return nil, fmt.Errorf("opening database in recovery mode: %w", err)
} }
configureConnPool(conn) // Set connection pool settings
// SQLite can handle multiple readers but only one writer at a time.
err = conn.PingContext(ctx) // Setting MaxOpenConns to 1 ensures all writes are serialized through
if err != nil { // a single connection, preventing SQLITE_BUSY errors.
log.Debug( conn.SetMaxOpenConns(1)
"Failed to ping database in recovery mode, closing", conn.SetMaxIdleConns(1)
"path", path, "error", err,
)
if err := conn.PingContext(ctx); err != nil {
log.Debug("Failed to ping database in recovery mode, closing", "path", path, "error", err)
_ = conn.Close() _ = conn.Close()
return nil, fmt.Errorf( return nil, fmt.Errorf(
"database still locked after recovery attempt: %w", "database still locked after recovery attempt: %w",
err, err,
@@ -203,44 +115,35 @@ func openWithRecovery(ctx context.Context, path string) (*DB, error) {
// Switch back to WAL mode // Switch back to WAL mode
log.Debug("Switching database back to WAL mode", "path", path) log.Debug("Switching database back to WAL mode", "path", path)
if _, err := conn.ExecContext(ctx, "PRAGMA journal_mode=WAL"); err != nil {
_, err = conn.ExecContext(ctx, "PRAGMA journal_mode=WAL")
if err != nil {
log.Warn("Failed to switch back to WAL mode", "path", path, "error", err) log.Warn("Failed to switch back to WAL mode", "path", path, "error", err)
} }
db, err := finishOpen(ctx, conn, path) // Ensure foreign keys are enabled
if err != nil { if _, err := conn.ExecContext(ctx, "PRAGMA foreign_keys=ON"); err != nil {
return nil, err log.Warn("Failed to enable foreign keys", "path", path, "error", err)
}
db := &DB{conn: conn, path: path}
if err := db.createSchema(ctx); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("creating schema: %w", err)
} }
log.Debug("Database connection established successfully", "path", path) log.Debug("Database connection established successfully", "path", path)
return db, nil return db, nil
} }
// 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.
func NewTestDB() (*DB, error) {
return New(context.Background(), ":memory:")
}
// Close closes the database connection. // Close closes the database connection.
// It ensures all pending operations are completed before closing. // It ensures all pending operations are completed before closing.
// Returns an error if the database connection cannot be closed properly. // Returns an error if the database connection cannot be closed properly.
func (db *DB) Close() error { func (db *DB) Close() error {
log.Debug("Closing database connection", "path", db.path) log.Debug("Closing database connection", "path", db.path)
if err := db.conn.Close(); err != nil {
err := db.conn.Close()
if err != nil {
log.Error("Failed to close database", "path", db.path, "error", err) log.Error("Failed to close database", "path", db.path, "error", err)
return fmt.Errorf("failed to close database: %w", err) return fmt.Errorf("failed to close database: %w", err)
} }
log.Debug("Database connection closed successfully", "path", db.path) log.Debug("Database connection closed successfully", "path", db.path)
return nil return nil
} }
@@ -276,165 +179,54 @@ func (db *DB) BeginTx(
func (db *DB) ExecWithLog( func (db *DB) ExecWithLog(
ctx context.Context, ctx context.Context,
query string, query string,
args ...any, args ...interface{},
) (sql.Result, error) { ) (sql.Result, error) {
LogSQL("Execute", query, args...) LogSQL("Execute", query, args...)
return db.conn.ExecContext(ctx, query, args...) return db.conn.ExecContext(ctx, query, args...)
} }
// QueryRowWithLog executes a query that returns at most one row with SQL // QueryRowWithLog executes a query that returns at most one row with SQL logging.
// logging. This is useful for queries that modify data and return values // This is useful for queries that modify data and return values (e.g., INSERT ... RETURNING).
// (e.g., INSERT ... RETURNING). SQLite handles its own locking internally. // SQLite handles its own locking internally.
// The query and args parameters follow the same format as // The query and args parameters follow the same format as sql.DB.QueryRowContext.
// sql.DB.QueryRowContext.
func (db *DB) QueryRowWithLog( func (db *DB) QueryRowWithLog(
ctx context.Context, ctx context.Context,
query string, query string,
args ...any, args ...interface{},
) *sql.Row { ) *sql.Row {
LogSQL("QueryRow", query, args...) LogSQL("QueryRow", query, args...)
return db.conn.QueryRowContext(ctx, query, args...) return db.conn.QueryRowContext(ctx, query, args...)
} }
// collectMigrations reads the embedded schema directory and returns func (db *DB) createSchema(ctx context.Context) error {
// migration filenames sorted lexicographically. _, err := db.conn.ExecContext(ctx, schemaSQL)
func collectMigrations() ([]string, error) {
entries, err := schemaFS.ReadDir("schema")
if err != nil {
return nil, fmt.Errorf("failed to read schema directory: %w", err)
}
var migrations []string
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
migrations = append(migrations, entry.Name())
}
}
sort.Strings(migrations)
return migrations, nil
}
// bootstrapMigrationsTable ensures the schema_migrations table exists
// by applying 000.sql if the table is missing.
func bootstrapMigrationsTable(ctx context.Context, db *sql.DB) error {
var tableExists int
err := db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'",
).Scan(&tableExists)
if err != nil {
return fmt.Errorf("failed to check for migrations table: %w", err)
}
if tableExists > 0 {
return nil
}
content, err := schemaFS.ReadFile("schema/000.sql")
if err != nil {
return fmt.Errorf("failed to read bootstrap migration 000.sql: %w", err)
}
log.Info("applying bootstrap migration", "version", bootstrapVersion)
_, err = db.ExecContext(ctx, string(content))
if err != nil {
return fmt.Errorf("failed to apply bootstrap migration: %w", err)
}
return nil
}
// applyMigrations applies all pending migrations to db. It first bootstraps
// the schema_migrations table via 000.sql, then iterates through remaining
// migration files in order.
func applyMigrations(ctx context.Context, db *sql.DB) error {
err := bootstrapMigrationsTable(ctx, db)
if err != nil {
return err return err
}
migrations, err := collectMigrations()
if err != nil {
return err
}
for _, migration := range migrations {
version, parseErr := ParseMigrationVersion(migration)
if parseErr != nil {
return parseErr
}
// Check if already applied.
var count int
err := db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM schema_migrations WHERE version = ?",
version,
).Scan(&count)
if err != nil {
return fmt.Errorf("failed to check migration status: %w", err)
}
if count > 0 {
log.Debug("migration already applied", "version", version)
continue
}
// Read and apply migration.
content, readErr := schemaFS.ReadFile(filepath.Join("schema", migration))
if readErr != nil {
return fmt.Errorf("failed to read migration %s: %w", migration, readErr)
}
log.Info("applying migration", "version", version)
_, execErr := db.ExecContext(ctx, string(content))
if execErr != nil {
return fmt.Errorf("failed to apply migration %s: %w", migration, execErr)
}
// Record migration as applied.
_, recErr := db.ExecContext(ctx,
"INSERT INTO schema_migrations (version) VALUES (?)",
version,
)
if recErr != nil {
return fmt.Errorf("failed to record migration %s: %w", migration, recErr)
}
log.Info("migration applied successfully", "version", version)
}
return nil
} }
// repeatPlaceholder generates a string of ", ?" repeated n times for IN // NewTestDB creates an in-memory SQLite database for testing purposes.
// clause construction. For example, repeatPlaceholder(2) returns ", ?, ?". // The database is automatically initialized with the schema and is ready for use.
// Each call creates a new independent database instance.
func NewTestDB() (*DB, error) {
return New(context.Background(), ":memory:")
}
// repeatPlaceholder generates a string of ", ?" repeated n times for IN clause construction.
// For example, repeatPlaceholder(2) returns ", ?, ?".
func repeatPlaceholder(n int) string { func repeatPlaceholder(n int) string {
if n <= 0 { if n <= 0 {
return "" return ""
} }
return strings.Repeat(", ?", n) return strings.Repeat(", ?", n)
} }
// LogSQL logs SQL queries and their arguments when debug mode is enabled. // LogSQL logs SQL queries and their arguments when debug mode is enabled.
// Debug mode is activated by setting the GODEBUG environment variable to // Debug mode is activated by setting the GODEBUG environment variable to include "vaultik".
// include "vaultik". This is useful for troubleshooting database operations // This is useful for troubleshooting database operations and understanding query patterns.
// and understanding query patterns.
// //
// The operation parameter describes the type of SQL operation (e.g., // The operation parameter describes the type of SQL operation (e.g., "Execute", "Query").
// "Execute", "Query"). The query parameter is the SQL statement being // The query parameter is the SQL statement being executed.
// executed. The args parameter contains the query arguments that will be // The args parameter contains the query arguments that will be interpolated.
// interpolated. func LogSQL(operation, query string, args ...interface{}) {
func LogSQL(operation, query string, args ...any) {
if strings.Contains(os.Getenv("GODEBUG"), "vaultik") { if strings.Contains(os.Getenv("GODEBUG"), "vaultik") {
log.Debug( log.Debug(
"SQL "+operation, "SQL "+operation,

View File

@@ -1,17 +1,13 @@
//nolint:testpackage // exercises unexported migration internals
package database package database
import ( import (
"context" "context"
"database/sql"
"fmt" "fmt"
"path/filepath" "path/filepath"
"testing" "testing"
) )
func TestDatabase(t *testing.T) { func TestDatabase(t *testing.T) {
t.Parallel()
ctx := context.Background() ctx := context.Background()
dbPath := filepath.Join(t.TempDir(), "test.db") dbPath := filepath.Join(t.TempDir(), "test.db")
@@ -19,10 +15,8 @@ func TestDatabase(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("failed to create database: %v", err) t.Fatalf("failed to create database: %v", err)
} }
defer func() { defer func() {
err := db.Close() if err := db.Close(); err != nil {
if err != nil {
t.Errorf("failed to close database: %v", err) t.Errorf("failed to close database: %v", err)
} }
}() }()
@@ -32,20 +26,16 @@ func TestDatabase(t *testing.T) {
t.Fatal("database connection is nil") t.Fatal("database connection is nil")
} }
// Test schema creation (already done in New via migrations) // Test schema creation (already done in New)
// Verify tables exist // Verify tables exist
tables := []string{ tables := []string{
"schema_migrations",
"files", "file_chunks", "chunks", "blobs", "files", "file_chunks", "chunks", "blobs",
"blob_chunks", "chunk_files", "snapshots", "blob_chunks", "chunk_files", "snapshots",
} }
for _, table := range tables { for _, table := range tables {
var name string var name string
err := db.conn.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name=?", table).Scan(&name)
err := db.conn.QueryRowContext(ctx,
"SELECT name FROM sqlite_master WHERE type='table' AND name=?", table,
).Scan(&name)
if err != nil { if err != nil {
t.Errorf("table %s does not exist: %v", table, err) t.Errorf("table %s does not exist: %v", table, err)
} }
@@ -53,8 +43,6 @@ func TestDatabase(t *testing.T) {
} }
func TestDatabaseInvalidPath(t *testing.T) { func TestDatabaseInvalidPath(t *testing.T) {
t.Parallel()
ctx := context.Background() ctx := context.Background()
// Test with invalid path // Test with invalid path
@@ -65,8 +53,6 @@ func TestDatabaseInvalidPath(t *testing.T) {
} }
func TestDatabaseConcurrentAccess(t *testing.T) { func TestDatabaseConcurrentAccess(t *testing.T) {
t.Parallel()
ctx := context.Background() ctx := context.Background()
dbPath := filepath.Join(t.TempDir(), "test.db") dbPath := filepath.Join(t.TempDir(), "test.db")
@@ -74,10 +60,8 @@ func TestDatabaseConcurrentAccess(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("failed to create database: %v", err) t.Fatalf("failed to create database: %v", err)
} }
defer func() { defer func() {
err := db.Close() if err := db.Close(); err != nil {
if err != nil {
t.Errorf("failed to close database: %v", err) t.Errorf("failed to close database: %v", err)
} }
}() }()
@@ -87,20 +71,18 @@ func TestDatabaseConcurrentAccess(t *testing.T) {
index int index int
err error err error
} }
results := make(chan result, 10) results := make(chan result, 10)
for i := range 10 { for i := 0; i < 10; i++ {
go func(i int) { go func(i int) {
_, err := db.ExecWithLog(ctx, _, err := db.ExecWithLog(ctx, "INSERT INTO chunks (chunk_hash, size) VALUES (?, ?)",
"INSERT INTO chunks (chunk_hash, size) VALUES (?, ?)",
fmt.Sprintf("hash%d", i), i*1024) fmt.Sprintf("hash%d", i), i*1024)
results <- result{index: i, err: err} results <- result{index: i, err: err}
}(i) }(i)
} }
// Wait for all goroutines and check results // Wait for all goroutines and check results
for range 10 { for i := 0; i < 10; i++ {
r := <-results r := <-results
if r.err != nil { if r.err != nil {
t.Fatalf("concurrent insert %d failed: %v", r.index, r.err) t.Fatalf("concurrent insert %d failed: %v", r.index, r.err)
@@ -109,196 +91,11 @@ func TestDatabaseConcurrentAccess(t *testing.T) {
// Verify all inserts succeeded // Verify all inserts succeeded
var count int var count int
err = db.conn.QueryRowContext(ctx, "SELECT COUNT(*) FROM chunks").Scan(&count) err = db.conn.QueryRowContext(ctx, "SELECT COUNT(*) FROM chunks").Scan(&count)
if err != nil { if err != nil {
t.Fatalf("failed to count chunks: %v", err) t.Fatalf("failed to count chunks: %v", err)
} }
if count != 10 { if count != 10 {
t.Errorf("expected 10 chunks, got %d", count) t.Errorf("expected 10 chunks, got %d", count)
} }
} }
func TestParseMigrationVersion(t *testing.T) {
t.Parallel()
tests := []struct {
name string
filename string
wantVer int
wantError bool
}{
{name: "valid 000.sql", filename: "000.sql", wantVer: 0, wantError: false},
{name: "valid 001.sql", filename: "001.sql", wantVer: 1, wantError: false},
{name: "valid 099.sql", filename: "099.sql", wantVer: 99, wantError: false},
{
name: "valid with description", filename: "001_initial_schema.sql",
wantVer: 1, wantError: false,
},
{
name: "valid large version", filename: "123_big_migration.sql",
wantVer: 123, wantError: false,
},
{name: "invalid alpha version", filename: "abc.sql", wantVer: 0, wantError: true},
{name: "invalid mixed chars", filename: "12a.sql", wantVer: 0, wantError: true},
{name: "invalid no extension", filename: "schema.sql", wantVer: 0, wantError: true},
{name: "empty string", filename: "", wantVer: 0, wantError: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := ParseMigrationVersion(tc.filename)
if tc.wantError {
if err == nil {
t.Errorf("ParseMigrationVersion(%q) = %d, nil; want error",
tc.filename, got)
}
return
}
if err != nil {
t.Errorf("ParseMigrationVersion(%q) unexpected error: %v",
tc.filename, err)
return
}
if got != tc.wantVer {
t.Errorf("ParseMigrationVersion(%q) = %d; want %d",
tc.filename, got, tc.wantVer)
}
})
}
}
func TestApplyMigrations_Idempotent(t *testing.T) {
t.Parallel()
ctx := context.Background()
conn, err := sql.Open("sqlite", ":memory:?_foreign_keys=ON")
if err != nil {
t.Fatalf("failed to open database: %v", err)
}
defer func() {
err := conn.Close()
if err != nil {
t.Errorf("failed to close database: %v", err)
}
}()
conn.SetMaxOpenConns(1)
conn.SetMaxIdleConns(1)
// First run: apply all migrations.
err = applyMigrations(ctx, conn)
if err != nil {
t.Fatalf("first applyMigrations failed: %v", err)
}
// Count rows in schema_migrations after first run.
var countBefore int
err = conn.QueryRowContext(ctx,
"SELECT COUNT(*) FROM schema_migrations",
).Scan(&countBefore)
if err != nil {
t.Fatalf("failed to count schema_migrations after first run: %v", err)
}
// Second run: must be a no-op.
err = applyMigrations(ctx, conn)
if err != nil {
t.Fatalf("second applyMigrations failed: %v", err)
}
// Count rows in schema_migrations after second run — must be unchanged.
var countAfter int
err = conn.QueryRowContext(ctx,
"SELECT COUNT(*) FROM schema_migrations",
).Scan(&countAfter)
if err != nil {
t.Fatalf("failed to count schema_migrations after second run: %v", err)
}
if countBefore != countAfter {
t.Errorf("schema_migrations row count changed: before=%d, after=%d",
countBefore, countAfter)
}
}
func TestBootstrapMigrationsTable_FreshDatabase(t *testing.T) {
t.Parallel()
ctx := context.Background()
conn, err := sql.Open("sqlite", ":memory:?_foreign_keys=ON")
if err != nil {
t.Fatalf("failed to open database: %v", err)
}
defer func() {
err := conn.Close()
if err != nil {
t.Errorf("failed to close database: %v", err)
}
}()
conn.SetMaxOpenConns(1)
conn.SetMaxIdleConns(1)
// Verify schema_migrations does NOT exist yet.
var tableBefore int
err = conn.QueryRowContext(ctx,
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'",
).Scan(&tableBefore)
if err != nil {
t.Fatalf("failed to check for table before bootstrap: %v", err)
}
if tableBefore != 0 {
t.Fatal("schema_migrations table should not exist before bootstrap")
}
// Run bootstrap.
err = bootstrapMigrationsTable(ctx, conn)
if err != nil {
t.Fatalf("bootstrapMigrationsTable failed: %v", err)
}
// Verify schema_migrations now exists.
var tableAfter int
err = conn.QueryRowContext(ctx,
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'",
).Scan(&tableAfter)
if err != nil {
t.Fatalf("failed to check for table after bootstrap: %v", err)
}
if tableAfter != 1 {
t.Fatalf("schema_migrations table should exist after bootstrap, got count=%d",
tableAfter)
}
// Verify version 0 row exists.
var version int
err = conn.QueryRowContext(ctx,
"SELECT version FROM schema_migrations WHERE version = 0",
).Scan(&version)
if err != nil {
t.Fatalf("version 0 row not found in schema_migrations: %v", err)
}
if version != 0 {
t.Errorf("expected version 0, got %d", version)
}
}

View File

@@ -1,12 +1,20 @@
package database package database
import ( import (
"database/sql"
"fmt" "fmt"
"os" "os"
) )
// Fatalf prints an error message to stderr and exits with status 1 // Fatal prints an error message to stderr and exits with status 1
func Fatalf(format string, args ...any) { func Fatal(format string, args ...interface{}) {
fmt.Fprintf(os.Stderr, "FATAL: "+format+"\n", args...) fmt.Fprintf(os.Stderr, "FATAL: "+format+"\n", args...)
os.Exit(1) os.Exit(1)
} }
// CloseRows closes rows and exits on error
func CloseRows(rows *sql.Rows) {
if err := rows.Close(); err != nil {
Fatal("failed to close rows: %v", err)
}
}

View File

@@ -4,26 +4,19 @@ import (
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
"strings"
"sneak.berlin/go/vaultik/internal/types" "git.eeqj.de/sneak/vaultik/internal/types"
) )
// FileChunkRepository provides access to the file_chunks table, which maps
// files to their ordered constituent chunks.
type FileChunkRepository struct { type FileChunkRepository struct {
db *DB db *DB
} }
// NewFileChunkRepository creates a FileChunkRepository backed by db.
func NewFileChunkRepository(db *DB) *FileChunkRepository { func NewFileChunkRepository(db *DB) *FileChunkRepository {
return &FileChunkRepository{db: db} return &FileChunkRepository{db: db}
} }
// Create inserts a file_chunks row (idempotently), using tx when non-nil. func (r *FileChunkRepository) Create(ctx context.Context, tx *sql.Tx, fc *FileChunk) error {
func (r *FileChunkRepository) Create(
ctx context.Context, tx *sql.Tx, fc *FileChunk,
) error {
query := ` query := `
INSERT INTO file_chunks (file_id, idx, chunk_hash) INSERT INTO file_chunks (file_id, idx, chunk_hash)
VALUES (?, ?, ?) VALUES (?, ?, ?)
@@ -34,8 +27,7 @@ func (r *FileChunkRepository) Create(
if tx != nil { if tx != nil {
_, err = tx.ExecContext(ctx, query, fc.FileID.String(), fc.Idx, fc.ChunkHash.String()) _, err = tx.ExecContext(ctx, query, fc.FileID.String(), fc.Idx, fc.ChunkHash.String())
} else { } else {
_, err = r.db.ExecWithLog(ctx, query, _, err = r.db.ExecWithLog(ctx, query, fc.FileID.String(), fc.Idx, fc.ChunkHash.String())
fc.FileID.String(), fc.Idx, fc.ChunkHash.String())
} }
if err != nil { if err != nil {
@@ -45,10 +37,7 @@ func (r *FileChunkRepository) Create(
return nil return nil
} }
// GetByPath returns the ordered chunks of the file at the given path. func (r *FileChunkRepository) GetByPath(ctx context.Context, path string) ([]*FileChunk, error) {
func (r *FileChunkRepository) GetByPath(
ctx context.Context, path string,
) ([]*FileChunk, error) {
query := ` query := `
SELECT fc.file_id, fc.idx, fc.chunk_hash SELECT fc.file_id, fc.idx, fc.chunk_hash
FROM file_chunks fc FROM file_chunks fc
@@ -61,21 +50,13 @@ func (r *FileChunkRepository) GetByPath(
if err != nil { if err != nil {
return nil, fmt.Errorf("querying file chunks: %w", err) return nil, fmt.Errorf("querying file chunks: %w", err)
} }
defer CloseRows(rows)
defer func() {
err := rows.Close()
if err != nil {
Fatalf("failed to close rows: %v", err)
}
}()
return r.scanFileChunks(rows) return r.scanFileChunks(rows)
} }
// GetByFileID retrieves file chunks by file ID // GetByFileID retrieves file chunks by file ID
func (r *FileChunkRepository) GetByFileID( func (r *FileChunkRepository) GetByFileID(ctx context.Context, fileID types.FileID) ([]*FileChunk, error) {
ctx context.Context, fileID types.FileID,
) ([]*FileChunk, error) {
query := ` query := `
SELECT file_id, idx, chunk_hash SELECT file_id, idx, chunk_hash
FROM file_chunks FROM file_chunks
@@ -87,21 +68,13 @@ func (r *FileChunkRepository) GetByFileID(
if err != nil { if err != nil {
return nil, fmt.Errorf("querying file chunks: %w", err) return nil, fmt.Errorf("querying file chunks: %w", err)
} }
defer CloseRows(rows)
defer func() {
err := rows.Close()
if err != nil {
Fatalf("failed to close rows: %v", err)
}
}()
return r.scanFileChunks(rows) return r.scanFileChunks(rows)
} }
// GetByPathTx retrieves file chunks within a transaction // GetByPathTx retrieves file chunks within a transaction
func (r *FileChunkRepository) GetByPathTx( func (r *FileChunkRepository) GetByPathTx(ctx context.Context, tx *sql.Tx, path string) ([]*FileChunk, error) {
ctx context.Context, tx *sql.Tx, path string,
) ([]*FileChunk, error) {
query := ` query := `
SELECT fc.file_id, fc.idx, fc.chunk_hash SELECT fc.file_id, fc.idx, fc.chunk_hash
FROM file_chunks fc FROM file_chunks fc
@@ -111,33 +84,40 @@ func (r *FileChunkRepository) GetByPathTx(
` `
LogSQL("GetByPathTx", query, path) LogSQL("GetByPathTx", query, path)
rows, err := tx.QueryContext(ctx, query, path) rows, err := tx.QueryContext(ctx, query, path)
if err != nil { if err != nil {
return nil, fmt.Errorf("querying file chunks: %w", err) return nil, fmt.Errorf("querying file chunks: %w", err)
} }
defer CloseRows(rows)
defer func() {
err := rows.Close()
if err != nil {
Fatalf("failed to close rows: %v", err)
}
}()
fileChunks, err := r.scanFileChunks(rows) fileChunks, err := r.scanFileChunks(rows)
LogSQL("GetByPathTx", "Complete", path, "count", len(fileChunks)) LogSQL("GetByPathTx", "Complete", path, "count", len(fileChunks))
return fileChunks, err return fileChunks, err
} }
// DeleteByPath deletes all file_chunks rows for the file at the given path. // scanFileChunks is a helper that scans file chunk rows
func (r *FileChunkRepository) DeleteByPath( func (r *FileChunkRepository) scanFileChunks(rows *sql.Rows) ([]*FileChunk, error) {
ctx context.Context, tx *sql.Tx, path string, var fileChunks []*FileChunk
) error { for rows.Next() {
query := ` var fc FileChunk
DELETE FROM file_chunks var fileIDStr, chunkHashStr string
WHERE file_id = (SELECT id FROM files WHERE path = ?) err := rows.Scan(&fileIDStr, &fc.Idx, &chunkHashStr)
` if err != nil {
return nil, fmt.Errorf("scanning file chunk: %w", err)
}
fc.FileID, err = types.ParseFileID(fileIDStr)
if err != nil {
return nil, fmt.Errorf("parsing file ID: %w", err)
}
fc.ChunkHash = types.ChunkHash(chunkHashStr)
fileChunks = append(fileChunks, &fc)
}
return fileChunks, rows.Err()
}
func (r *FileChunkRepository) DeleteByPath(ctx context.Context, tx *sql.Tx, path string) error {
query := `DELETE FROM file_chunks WHERE file_id = (SELECT id FROM files WHERE path = ?)`
var err error var err error
if tx != nil { if tx != nil {
@@ -154,9 +134,7 @@ func (r *FileChunkRepository) DeleteByPath(
} }
// DeleteByFileID deletes all chunks for a file by its UUID // DeleteByFileID deletes all chunks for a file by its UUID
func (r *FileChunkRepository) DeleteByFileID( func (r *FileChunkRepository) DeleteByFileID(ctx context.Context, tx *sql.Tx, fileID types.FileID) error {
ctx context.Context, tx *sql.Tx, fileID types.FileID,
) error {
query := `DELETE FROM file_chunks WHERE file_id = ?` query := `DELETE FROM file_chunks WHERE file_id = ?`
var err error var err error
@@ -174,11 +152,7 @@ func (r *FileChunkRepository) DeleteByFileID(
} }
// DeleteByFileIDs deletes all chunks for multiple files in a single statement. // DeleteByFileIDs deletes all chunks for multiple files in a single statement.
// func (r *FileChunkRepository) DeleteByFileIDs(ctx context.Context, tx *sql.Tx, fileIDs []types.FileID) error {
//nolint:dupl // symmetric implementation for a parallel association table
func (r *FileChunkRepository) DeleteByFileIDs(
ctx context.Context, tx *sql.Tx, fileIDs []types.FileID,
) error {
if len(fileIDs) == 0 { if len(fileIDs) == 0 {
return nil return nil
} }
@@ -187,15 +161,14 @@ func (r *FileChunkRepository) DeleteByFileIDs(
const batchSize = 500 const batchSize = 500
for i := 0; i < len(fileIDs); i += batchSize { for i := 0; i < len(fileIDs); i += batchSize {
end := min(i+batchSize, len(fileIDs)) end := i + batchSize
if end > len(fileIDs) {
end = len(fileIDs)
}
batch := fileIDs[i:end] batch := fileIDs[i:end]
//nolint:gosec // G202: concatenates constant SQL and "?" placeholders only query := "DELETE FROM file_chunks WHERE file_id IN (?" + repeatPlaceholder(len(batch)-1) + ")"
query := "DELETE FROM file_chunks WHERE file_id IN (?" + args := make([]interface{}, len(batch))
repeatPlaceholder(len(batch)-1) + ")"
args := make([]any, len(batch))
for j, id := range batch { for j, id := range batch {
args[j] = id.String() args[j] = id.String()
} }
@@ -206,7 +179,6 @@ func (r *FileChunkRepository) DeleteByFileIDs(
} else { } else {
_, err = r.db.ExecWithLog(ctx, query, args...) _, err = r.db.ExecWithLog(ctx, query, args...)
} }
if err != nil { if err != nil {
return fmt.Errorf("batch deleting file_chunks: %w", err) return fmt.Errorf("batch deleting file_chunks: %w", err)
} }
@@ -217,44 +189,32 @@ func (r *FileChunkRepository) DeleteByFileIDs(
// CreateBatch inserts multiple file_chunks in a single statement for efficiency. // CreateBatch inserts multiple file_chunks in a single statement for efficiency.
// Batches are automatically split to stay within SQLite's variable limit. // Batches are automatically split to stay within SQLite's variable limit.
func (r *FileChunkRepository) CreateBatch( func (r *FileChunkRepository) CreateBatch(ctx context.Context, tx *sql.Tx, fcs []FileChunk) error {
ctx context.Context, tx *sql.Tx, fcs []FileChunk,
) error {
if len(fcs) == 0 { if len(fcs) == 0 {
return nil return nil
} }
// Each file_chunks row binds this many SQL variables. // SQLite has a limit on variables (typically 999 or 32766).
const fileChunkCols = 3 // Each FileChunk has 3 values, so batch at 300 to be safe.
// SQLite has a limit on variables (typically 999 or 32766), so batch
// at 300 rows to be safe.
const batchSize = 300 const batchSize = 300
for i := 0; i < len(fcs); i += batchSize { for i := 0; i < len(fcs); i += batchSize {
end := min(i+batchSize, len(fcs)) end := i + batchSize
if end > len(fcs) {
end = len(fcs)
}
batch := fcs[i:end] batch := fcs[i:end]
// Build the query with multiple value sets // Build the query with multiple value sets
query := "INSERT INTO file_chunks (file_id, idx, chunk_hash) VALUES " query := "INSERT INTO file_chunks (file_id, idx, chunk_hash) VALUES "
args := make([]interface{}, 0, len(batch)*3)
args := make([]any, 0, len(batch)*fileChunkCols)
var querySb211 strings.Builder
for j, fc := range batch { for j, fc := range batch {
if j > 0 { if j > 0 {
querySb211.WriteString(", ") query += ", "
} }
query += "(?, ?, ?)"
querySb211.WriteString("(?, ?, ?)")
args = append(args, fc.FileID.String(), fc.Idx, fc.ChunkHash.String()) args = append(args, fc.FileID.String(), fc.Idx, fc.ChunkHash.String())
} }
query += querySb211.String() //nolint:gosec // G202: appends "?" placeholders only
query += " ON CONFLICT(file_id, idx) DO NOTHING" query += " ON CONFLICT(file_id, idx) DO NOTHING"
var err error var err error
@@ -263,7 +223,6 @@ func (r *FileChunkRepository) CreateBatch(
} else { } else {
_, err = r.db.ExecWithLog(ctx, query, args...) _, err = r.db.ExecWithLog(ctx, query, args...)
} }
if err != nil { if err != nil {
return fmt.Errorf("batch inserting file_chunks: %w", err) return fmt.Errorf("batch inserting file_chunks: %w", err)
} }
@@ -273,50 +232,17 @@ func (r *FileChunkRepository) CreateBatch(
} }
// GetByFile is an alias for GetByPath for compatibility // GetByFile is an alias for GetByPath for compatibility
func (r *FileChunkRepository) GetByFile( func (r *FileChunkRepository) GetByFile(ctx context.Context, path string) ([]*FileChunk, error) {
ctx context.Context, path string,
) ([]*FileChunk, error) {
LogSQL("GetByFile", "Starting", path) LogSQL("GetByFile", "Starting", path)
result, err := r.GetByPath(ctx, path) result, err := r.GetByPath(ctx, path)
LogSQL("GetByFile", "Complete", path, "count", len(result)) LogSQL("GetByFile", "Complete", path, "count", len(result))
return result, err return result, err
} }
// GetByFileTx retrieves file chunks within a transaction // GetByFileTx retrieves file chunks within a transaction
func (r *FileChunkRepository) GetByFileTx( func (r *FileChunkRepository) GetByFileTx(ctx context.Context, tx *sql.Tx, path string) ([]*FileChunk, error) {
ctx context.Context, tx *sql.Tx, path string,
) ([]*FileChunk, error) {
LogSQL("GetByFileTx", "Starting", path) LogSQL("GetByFileTx", "Starting", path)
result, err := r.GetByPathTx(ctx, tx, path) result, err := r.GetByPathTx(ctx, tx, path)
LogSQL("GetByFileTx", "Complete", path, "count", len(result)) LogSQL("GetByFileTx", "Complete", path, "count", len(result))
return result, err return result, err
} }
// scanFileChunks is a helper that scans file chunk rows
func (r *FileChunkRepository) scanFileChunks(rows *sql.Rows) ([]*FileChunk, error) {
var fileChunks []*FileChunk
for rows.Next() {
var (
fc FileChunk
fileIDStr, chunkHashStr string
)
err := rows.Scan(&fileIDStr, &fc.Idx, &chunkHashStr)
if err != nil {
return nil, fmt.Errorf("scanning file chunk: %w", err)
}
fc.FileID, err = types.ParseFileID(fileIDStr)
if err != nil {
return nil, fmt.Errorf("parsing file ID: %w", err)
}
fc.ChunkHash = types.ChunkHash(chunkHashStr)
fileChunks = append(fileChunks, &fc)
}
return fileChunks, rows.Err()
}

View File

@@ -1,4 +1,4 @@
package database_test package database
import ( import (
"context" "context"
@@ -6,25 +6,21 @@ import (
"testing" "testing"
"time" "time"
"sneak.berlin/go/vaultik/internal/database" "git.eeqj.de/sneak/vaultik/internal/types"
"sneak.berlin/go/vaultik/internal/types"
) )
func TestFileChunkRepository(t *testing.T) { func TestFileChunkRepository(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t) db, cleanup := setupTestDB(t)
defer cleanup() defer cleanup()
ctx := context.Background() ctx := context.Background()
repo := database.NewFileChunkRepository(db) repo := NewFileChunkRepository(db)
fileRepo := database.NewFileRepository(db) fileRepo := NewFileRepository(db)
repos := database.NewRepositories(db)
// Create test file first // Create test file first
testTime := time.Now().Truncate(time.Second) testTime := time.Now().Truncate(time.Second)
file := &database.File{ file := &File{
Path: testFileTxt, Path: "/test/file.txt",
MTime: testTime, MTime: testTime,
Size: 3072, Size: 3072,
Mode: 0644, Mode: 0644,
@@ -32,51 +28,63 @@ func TestFileChunkRepository(t *testing.T) {
GID: 1000, GID: 1000,
LinkTarget: "", LinkTarget: "",
} }
err := fileRepo.Create(ctx, nil, file)
mustCreateFile(t, fileRepo, file) if err != nil {
mustCreateChunks(t, repos, chunk1Hash, chunk2Hash, chunk3Hash) t.Fatalf("failed to create file: %v", err)
// Test Create
fc1 := &database.FileChunk{
FileID: file.ID,
Idx: 0,
ChunkHash: types.ChunkHash(chunk1Hash),
} }
err := repo.Create(ctx, nil, fc1) // Create chunks first
chunks := []types.ChunkHash{"chunk1", "chunk2", "chunk3"}
chunkRepo := NewChunkRepository(db)
for _, chunkHash := range chunks {
chunk := &Chunk{
ChunkHash: chunkHash,
Size: 1024,
}
err = chunkRepo.Create(ctx, nil, chunk)
if err != nil {
t.Fatalf("failed to create chunk %s: %v", chunkHash, err)
}
}
// Test Create
fc1 := &FileChunk{
FileID: file.ID,
Idx: 0,
ChunkHash: types.ChunkHash("chunk1"),
}
err = repo.Create(ctx, nil, fc1)
if err != nil { if err != nil {
t.Fatalf("failed to create file chunk: %v", err) t.Fatalf("failed to create file chunk: %v", err)
} }
// Add more chunks for the same file // Add more chunks for the same file
fc2 := &database.FileChunk{ fc2 := &FileChunk{
FileID: file.ID, FileID: file.ID,
Idx: 1, Idx: 1,
ChunkHash: types.ChunkHash(chunk2Hash), ChunkHash: types.ChunkHash("chunk2"),
} }
err = repo.Create(ctx, nil, fc2) err = repo.Create(ctx, nil, fc2)
if err != nil { if err != nil {
t.Fatalf("failed to create second file chunk: %v", err) t.Fatalf("failed to create second file chunk: %v", err)
} }
fc3 := &database.FileChunk{ fc3 := &FileChunk{
FileID: file.ID, FileID: file.ID,
Idx: 2, Idx: 2,
ChunkHash: types.ChunkHash(chunk3Hash), ChunkHash: types.ChunkHash("chunk3"),
} }
err = repo.Create(ctx, nil, fc3) err = repo.Create(ctx, nil, fc3)
if err != nil { if err != nil {
t.Fatalf("failed to create third file chunk: %v", err) t.Fatalf("failed to create third file chunk: %v", err)
} }
// Test GetByFile // Test GetByFile
fileChunks, err := repo.GetByFile(ctx, testFileTxt) fileChunks, err := repo.GetByFile(ctx, "/test/file.txt")
if err != nil { if err != nil {
t.Fatalf("failed to get file chunks: %v", err) t.Fatalf("failed to get file chunks: %v", err)
} }
if len(fileChunks) != 3 { if len(fileChunks) != 3 {
t.Errorf("expected 3 chunks, got %d", len(fileChunks)) t.Errorf("expected 3 chunks, got %d", len(fileChunks))
} }
@@ -93,41 +101,6 @@ func TestFileChunkRepository(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("failed to create duplicate file chunk: %v", err) t.Fatalf("failed to create duplicate file chunk: %v", err)
} }
}
func TestFileChunkRepositoryDeleteByFileID(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
repo := database.NewFileChunkRepository(db)
fileRepo := database.NewFileRepository(db)
repos := database.NewRepositories(db)
file := &database.File{
Path: testFileTxt,
MTime: time.Now().Truncate(time.Second),
Size: 1024,
Mode: 0644,
UID: 1000,
GID: 1000,
}
mustCreateFile(t, fileRepo, file)
mustCreateChunks(t, repos, chunk1Hash)
fc := &database.FileChunk{
FileID: file.ID,
Idx: 0,
ChunkHash: types.ChunkHash(chunk1Hash),
}
err := repo.Create(ctx, nil, fc)
if err != nil {
t.Fatalf("failed to create file chunk: %v", err)
}
// Test DeleteByFileID // Test DeleteByFileID
err = repo.DeleteByFileID(ctx, nil, file.ID) err = repo.DeleteByFileID(ctx, nil, file.ID)
@@ -135,33 +108,30 @@ func TestFileChunkRepositoryDeleteByFileID(t *testing.T) {
t.Fatalf("failed to delete file chunks: %v", err) t.Fatalf("failed to delete file chunks: %v", err)
} }
fileChunks, err := repo.GetByFileID(ctx, file.ID) fileChunks, err = repo.GetByFileID(ctx, file.ID)
if err != nil { if err != nil {
t.Fatalf("failed to get deleted file chunks: %v", err) t.Fatalf("failed to get deleted file chunks: %v", err)
} }
if len(fileChunks) != 0 { if len(fileChunks) != 0 {
t.Errorf("expected 0 chunks after delete, got %d", len(fileChunks)) t.Errorf("expected 0 chunks after delete, got %d", len(fileChunks))
} }
} }
func TestFileChunkRepositoryMultipleFiles(t *testing.T) { func TestFileChunkRepositoryMultipleFiles(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t) db, cleanup := setupTestDB(t)
defer cleanup() defer cleanup()
ctx := context.Background() ctx := context.Background()
repo := database.NewFileChunkRepository(db) repo := NewFileChunkRepository(db)
fileRepo := database.NewFileRepository(db) fileRepo := NewFileRepository(db)
// Create test files // Create test files
testTime := time.Now().Truncate(time.Second) testTime := time.Now().Truncate(time.Second)
filePaths := []string{testFilePath1, testFilePath2, "/file3.txt"} filePaths := []string{"/file1.txt", "/file2.txt", "/file3.txt"}
files := make([]*database.File, len(filePaths)) files := make([]*File, len(filePaths))
for i, path := range filePaths { for i, path := range filePaths {
file := &database.File{ file := &File{
Path: types.FilePath(path), Path: types.FilePath(path),
MTime: testTime, MTime: testTime,
Size: 2048, Size: 2048,
@@ -170,23 +140,22 @@ func TestFileChunkRepositoryMultipleFiles(t *testing.T) {
GID: 1000, GID: 1000,
LinkTarget: "", LinkTarget: "",
} }
err := fileRepo.Create(ctx, nil, file)
mustCreateFile(t, fileRepo, file) if err != nil {
t.Fatalf("failed to create file %s: %v", path, err)
}
files[i] = file files[i] = file
} }
// Create all chunks first // Create all chunks first
chunkRepo := database.NewChunkRepository(db) chunkRepo := NewChunkRepository(db)
for i := range files { for i := range files {
for j := range 2 { for j := 0; j < 2; j++ {
chunkHash := types.ChunkHash(fmt.Sprintf("file%d_chunk%d", i, j)) chunkHash := types.ChunkHash(fmt.Sprintf("file%d_chunk%d", i, j))
chunk := &database.Chunk{ chunk := &Chunk{
ChunkHash: chunkHash, ChunkHash: chunkHash,
Size: 1024, Size: 1024,
} }
err := chunkRepo.Create(ctx, nil, chunk) err := chunkRepo.Create(ctx, nil, chunk)
if err != nil { if err != nil {
t.Fatalf("failed to create chunk %s: %v", chunkHash, err) t.Fatalf("failed to create chunk %s: %v", chunkHash, err)
@@ -196,13 +165,12 @@ func TestFileChunkRepositoryMultipleFiles(t *testing.T) {
// Create chunks for multiple files // Create chunks for multiple files
for i, file := range files { for i, file := range files {
for j := range 2 { for j := 0; j < 2; j++ {
fc := &database.FileChunk{ fc := &FileChunk{
FileID: file.ID, FileID: file.ID,
Idx: j, Idx: j,
ChunkHash: types.ChunkHash(fmt.Sprintf("file%d_chunk%d", i, j)), ChunkHash: types.ChunkHash(fmt.Sprintf("file%d_chunk%d", i, j)),
} }
err := repo.Create(ctx, nil, fc) err := repo.Create(ctx, nil, fc)
if err != nil { if err != nil {
t.Fatalf("failed to create file chunk: %v", err) t.Fatalf("failed to create file chunk: %v", err)
@@ -216,7 +184,6 @@ func TestFileChunkRepositoryMultipleFiles(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("failed to get chunks for file %d: %v", i, err) t.Fatalf("failed to get chunks for file %d: %v", i, err)
} }
if len(chunks) != 2 { if len(chunks) != 2 {
t.Errorf("expected 2 chunks for file %d, got %d", i, len(chunks)) t.Errorf("expected 2 chunks for file %d, got %d", i, len(chunks))
} }

View File

@@ -3,29 +3,21 @@ package database
import ( import (
"context" "context"
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
"strings"
"time" "time"
"sneak.berlin/go/vaultik/internal/log" "git.eeqj.de/sneak/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/types" "git.eeqj.de/sneak/vaultik/internal/types"
) )
// FileRepository provides access to the files table, which stores file
// metadata (path, times, permissions, ownership, symlink targets).
type FileRepository struct { type FileRepository struct {
db *DB db *DB
} }
// NewFileRepository creates a FileRepository backed by db.
func NewFileRepository(db *DB) *FileRepository { func NewFileRepository(db *DB) *FileRepository {
return &FileRepository{db: db} return &FileRepository{db: db}
} }
// Create inserts or updates a file row (upsert on path), using tx when
// non-nil. The file's ID is generated when zero and updated from the
// database's RETURNING clause.
func (r *FileRepository) Create(ctx context.Context, tx *sql.Tx, file *File) error { func (r *FileRepository) Create(ctx context.Context, tx *sql.Tx, file *File) error {
// Generate UUID if not provided // Generate UUID if not provided
if file.ID.IsZero() { if file.ID.IsZero() {
@@ -46,25 +38,13 @@ func (r *FileRepository) Create(ctx context.Context, tx *sql.Tx, file *File) err
RETURNING id RETURNING id
` `
var ( var idStr string
idStr string var err error
err error
)
if tx != nil { if tx != nil {
LogSQL("Execute", query, LogSQL("Execute", query, file.ID.String(), file.Path.String(), file.SourcePath.String(), file.MTime.Unix(), file.Size, file.Mode, file.UID, file.GID, file.LinkTarget.String())
file.ID.String(), file.Path.String(), file.SourcePath.String(), err = tx.QueryRowContext(ctx, query, file.ID.String(), file.Path.String(), file.SourcePath.String(), file.MTime.Unix(), file.Size, file.Mode, file.UID, file.GID, file.LinkTarget.String()).Scan(&idStr)
file.MTime.Unix(), file.Size, file.Mode, file.UID, file.GID,
file.LinkTarget.String())
err = tx.QueryRowContext(ctx, query,
file.ID.String(), file.Path.String(), file.SourcePath.String(),
file.MTime.Unix(), file.Size, file.Mode, file.UID, file.GID,
file.LinkTarget.String()).Scan(&idStr)
} else { } else {
err = r.db.QueryRowWithLog(ctx, query, err = r.db.QueryRowWithLog(ctx, query, file.ID.String(), file.Path.String(), file.SourcePath.String(), file.MTime.Unix(), file.Size, file.Mode, file.UID, file.GID, file.LinkTarget.String()).Scan(&idStr)
file.ID.String(), file.Path.String(), file.SourcePath.String(),
file.MTime.Unix(), file.Size, file.Mode, file.UID, file.GID,
file.LinkTarget.String()).Scan(&idStr)
} }
if err != nil { if err != nil {
@@ -80,8 +60,6 @@ func (r *FileRepository) Create(ctx context.Context, tx *sql.Tx, file *File) err
return nil return nil
} }
// GetByPath returns the file at the given path, or nil if the path is not
// in the index.
func (r *FileRepository) GetByPath(ctx context.Context, path string) (*File, error) { func (r *FileRepository) GetByPath(ctx context.Context, path string) (*File, error) {
query := ` query := `
SELECT id, path, source_path, mtime, size, mode, uid, gid, link_target SELECT id, path, source_path, mtime, size, mode, uid, gid, link_target
@@ -90,10 +68,9 @@ func (r *FileRepository) GetByPath(ctx context.Context, path string) (*File, err
` `
file, err := r.scanFile(r.db.conn.QueryRowContext(ctx, query, path)) file, err := r.scanFile(r.db.conn.QueryRowContext(ctx, query, path))
if errors.Is(err, sql.ErrNoRows) { if err == sql.ErrNoRows {
return nil, nil //nolint:nilnil // nil,nil signals not-found; callers check nil return nil, nil
} }
if err != nil { if err != nil {
return nil, fmt.Errorf("querying file: %w", err) return nil, fmt.Errorf("querying file: %w", err)
} }
@@ -110,10 +87,9 @@ func (r *FileRepository) GetByID(ctx context.Context, id types.FileID) (*File, e
` `
file, err := r.scanFile(r.db.conn.QueryRowContext(ctx, query, id.String())) file, err := r.scanFile(r.db.conn.QueryRowContext(ctx, query, id.String()))
if errors.Is(err, sql.ErrNoRows) { if err == sql.ErrNoRows {
return nil, nil //nolint:nilnil // nil,nil signals not-found; callers check nil return nil, nil
} }
if err != nil { if err != nil {
return nil, fmt.Errorf("querying file: %w", err) return nil, fmt.Errorf("querying file: %w", err)
} }
@@ -121,11 +97,7 @@ func (r *FileRepository) GetByID(ctx context.Context, id types.FileID) (*File, e
return file, nil return file, nil
} }
// GetByPathTx returns the file at the given path within a transaction, or func (r *FileRepository) GetByPathTx(ctx context.Context, tx *sql.Tx, path string) (*File, error) {
// nil if the path is not in the index.
func (r *FileRepository) GetByPathTx(
ctx context.Context, tx *sql.Tx, path string,
) (*File, error) {
query := ` query := `
SELECT id, path, source_path, mtime, size, mode, uid, gid, link_target SELECT id, path, source_path, mtime, size, mode, uid, gid, link_target
FROM files FROM files
@@ -136,10 +108,9 @@ func (r *FileRepository) GetByPathTx(
file, err := r.scanFile(tx.QueryRowContext(ctx, query, path)) file, err := r.scanFile(tx.QueryRowContext(ctx, query, path))
LogSQL("GetByPathTx Scan complete", query, path) LogSQL("GetByPathTx Scan complete", query, path)
if errors.Is(err, sql.ErrNoRows) { if err == sql.ErrNoRows {
return nil, nil //nolint:nilnil // nil,nil signals not-found; callers check nil return nil, nil
} }
if err != nil { if err != nil {
return nil, fmt.Errorf("querying file: %w", err) return nil, fmt.Errorf("querying file: %w", err)
} }
@@ -147,16 +118,79 @@ func (r *FileRepository) GetByPathTx(
return file, nil return file, nil
} }
// fileRowScanner abstracts *sql.Row and *sql.Rows for scanning a file row. // scanFile is a helper that scans a single file row
type fileRowScanner interface { func (r *FileRepository) scanFile(row *sql.Row) (*File, error) {
Scan(dest ...any) error var file File
var idStr, pathStr, sourcePathStr string
var mtimeUnix int64
var linkTarget sql.NullString
err := row.Scan(
&idStr,
&pathStr,
&sourcePathStr,
&mtimeUnix,
&file.Size,
&file.Mode,
&file.UID,
&file.GID,
&linkTarget,
)
if err != nil {
return nil, err
}
file.ID, err = types.ParseFileID(idStr)
if err != nil {
return nil, fmt.Errorf("parsing file ID: %w", err)
}
file.Path = types.FilePath(pathStr)
file.SourcePath = types.SourcePath(sourcePathStr)
file.MTime = time.Unix(mtimeUnix, 0).UTC()
if linkTarget.Valid {
file.LinkTarget = types.FilePath(linkTarget.String)
}
return &file, nil
} }
// ListModifiedSince returns all files whose recorded mtime is at or after // scanFileRows is a helper that scans a file row from rows iterator
// since, ordered by path. func (r *FileRepository) scanFileRows(rows *sql.Rows) (*File, error) {
func (r *FileRepository) ListModifiedSince( var file File
ctx context.Context, since time.Time, var idStr, pathStr, sourcePathStr string
) ([]*File, error) { var mtimeUnix int64
var linkTarget sql.NullString
err := rows.Scan(
&idStr,
&pathStr,
&sourcePathStr,
&mtimeUnix,
&file.Size,
&file.Mode,
&file.UID,
&file.GID,
&linkTarget,
)
if err != nil {
return nil, err
}
file.ID, err = types.ParseFileID(idStr)
if err != nil {
return nil, fmt.Errorf("parsing file ID: %w", err)
}
file.Path = types.FilePath(pathStr)
file.SourcePath = types.SourcePath(sourcePathStr)
file.MTime = time.Unix(mtimeUnix, 0).UTC()
if linkTarget.Valid {
file.LinkTarget = types.FilePath(linkTarget.String)
}
return &file, nil
}
func (r *FileRepository) ListModifiedSince(ctx context.Context, since time.Time) ([]*File, error) {
query := ` query := `
SELECT id, path, source_path, mtime, size, mode, uid, gid, link_target SELECT id, path, source_path, mtime, size, mode, uid, gid, link_target
FROM files FROM files
@@ -168,29 +202,20 @@ func (r *FileRepository) ListModifiedSince(
if err != nil { if err != nil {
return nil, fmt.Errorf("querying files: %w", err) return nil, fmt.Errorf("querying files: %w", err)
} }
defer CloseRows(rows)
defer func() {
err := rows.Close()
if err != nil {
Fatalf("failed to close rows: %v", err)
}
}()
var files []*File var files []*File
for rows.Next() { for rows.Next() {
file, err := r.scanFileRows(rows) file, err := r.scanFileRows(rows)
if err != nil { if err != nil {
return nil, fmt.Errorf("scanning file: %w", err) return nil, fmt.Errorf("scanning file: %w", err)
} }
files = append(files, file) files = append(files, file)
} }
return files, rows.Err() return files, rows.Err()
} }
// Delete removes the file row at the given path, using tx when non-nil.
func (r *FileRepository) Delete(ctx context.Context, tx *sql.Tx, path string) error { func (r *FileRepository) Delete(ctx context.Context, tx *sql.Tx, path string) error {
query := `DELETE FROM files WHERE path = ?` query := `DELETE FROM files WHERE path = ?`
@@ -209,9 +234,7 @@ func (r *FileRepository) Delete(ctx context.Context, tx *sql.Tx, path string) er
} }
// DeleteByID deletes a file by its UUID // DeleteByID deletes a file by its UUID
func (r *FileRepository) DeleteByID( func (r *FileRepository) DeleteByID(ctx context.Context, tx *sql.Tx, id types.FileID) error {
ctx context.Context, tx *sql.Tx, id types.FileID,
) error {
query := `DELETE FROM files WHERE id = ?` query := `DELETE FROM files WHERE id = ?`
var err error var err error
@@ -228,11 +251,7 @@ func (r *FileRepository) DeleteByID(
return nil return nil
} }
// ListByPrefix returns all files whose path starts with prefix, ordered by func (r *FileRepository) ListByPrefix(ctx context.Context, prefix string) ([]*File, error) {
// path.
func (r *FileRepository) ListByPrefix(
ctx context.Context, prefix string,
) ([]*File, error) {
query := ` query := `
SELECT id, path, source_path, mtime, size, mode, uid, gid, link_target SELECT id, path, source_path, mtime, size, mode, uid, gid, link_target
FROM files FROM files
@@ -244,22 +263,14 @@ func (r *FileRepository) ListByPrefix(
if err != nil { if err != nil {
return nil, fmt.Errorf("querying files: %w", err) return nil, fmt.Errorf("querying files: %w", err)
} }
defer CloseRows(rows)
defer func() {
err := rows.Close()
if err != nil {
Fatalf("failed to close rows: %v", err)
}
}()
var files []*File var files []*File
for rows.Next() { for rows.Next() {
file, err := r.scanFileRows(rows) file, err := r.scanFileRows(rows)
if err != nil { if err != nil {
return nil, fmt.Errorf("scanning file: %w", err) return nil, fmt.Errorf("scanning file: %w", err)
} }
files = append(files, file) files = append(files, file)
} }
@@ -278,22 +289,14 @@ func (r *FileRepository) ListAll(ctx context.Context) ([]*File, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("querying files: %w", err) return nil, fmt.Errorf("querying files: %w", err)
} }
defer CloseRows(rows)
defer func() {
err := rows.Close()
if err != nil {
Fatalf("failed to close rows: %v", err)
}
}()
var files []*File var files []*File
for rows.Next() { for rows.Next() {
file, err := r.scanFileRows(rows) file, err := r.scanFileRows(rows)
if err != nil { if err != nil {
return nil, fmt.Errorf("scanning file: %w", err) return nil, fmt.Errorf("scanning file: %w", err)
} }
files = append(files, file) files = append(files, file)
} }
@@ -302,47 +305,30 @@ func (r *FileRepository) ListAll(ctx context.Context) ([]*File, error) {
// CreateBatch inserts or updates multiple files in a single statement for efficiency. // CreateBatch inserts or updates multiple files in a single statement for efficiency.
// File IDs must be pre-generated before calling this method. // File IDs must be pre-generated before calling this method.
func (r *FileRepository) CreateBatch( func (r *FileRepository) CreateBatch(ctx context.Context, tx *sql.Tx, files []*File) error {
ctx context.Context, tx *sql.Tx, files []*File,
) error {
if len(files) == 0 { if len(files) == 0 {
return nil return nil
} }
// Each files row binds this many SQL variables. // Each File has 9 values, so batch at 100 to be safe with SQLite's variable limit
const fileCols = 9
// Batch at 100 rows to be safe with SQLite's variable limit.
const batchSize = 100 const batchSize = 100
for i := 0; i < len(files); i += batchSize { for i := 0; i < len(files); i += batchSize {
end := min(i+batchSize, len(files)) end := i + batchSize
if end > len(files) {
end = len(files)
}
batch := files[i:end] batch := files[i:end]
query := `INSERT INTO files query := `INSERT INTO files (id, path, source_path, mtime, size, mode, uid, gid, link_target) VALUES `
(id, path, source_path, mtime, size, mode, uid, gid, link_target) args := make([]interface{}, 0, len(batch)*9)
VALUES `
args := make([]any, 0, len(batch)*fileCols)
var querySb325 strings.Builder
for j, f := range batch { for j, f := range batch {
if j > 0 { if j > 0 {
querySb325.WriteString(", ") query += ", "
} }
query += "(?, ?, ?, ?, ?, ?, ?, ?, ?)"
querySb325.WriteString("(?, ?, ?, ?, ?, ?, ?, ?, ?)") args = append(args, f.ID.String(), f.Path.String(), f.SourcePath.String(), f.MTime.Unix(), f.Size, f.Mode, f.UID, f.GID, f.LinkTarget.String())
args = append(args,
f.ID.String(), f.Path.String(), f.SourcePath.String(),
f.MTime.Unix(), f.Size, f.Mode, f.UID, f.GID,
f.LinkTarget.String())
} }
query += querySb325.String() //nolint:gosec // G202: appends "?" placeholders only
query += ` ON CONFLICT(path) DO UPDATE SET query += ` ON CONFLICT(path) DO UPDATE SET
source_path = excluded.source_path, source_path = excluded.source_path,
mtime = excluded.mtime, mtime = excluded.mtime,
@@ -358,7 +344,6 @@ func (r *FileRepository) CreateBatch(
} else { } else {
_, err = r.db.ExecWithLog(ctx, query, args...) _, err = r.db.ExecWithLog(ctx, query, args...)
} }
if err != nil { if err != nil {
return fmt.Errorf("batch inserting files: %w", err) return fmt.Errorf("batch inserting files: %w", err)
} }
@@ -389,53 +374,3 @@ func (r *FileRepository) DeleteOrphaned(ctx context.Context) error {
return nil return nil
} }
// scanFile is a helper that scans a single file row
func (r *FileRepository) scanFile(row *sql.Row) (*File, error) {
return r.scanFileFrom(row)
}
// scanFileRows is a helper that scans a file row from rows iterator
func (r *FileRepository) scanFileRows(rows *sql.Rows) (*File, error) {
return r.scanFileFrom(rows)
}
// scanFileFrom scans one file row from any row scanner.
func (r *FileRepository) scanFileFrom(row fileRowScanner) (*File, error) {
var (
file File
idStr, pathStr, sourcePathStr string
mtimeUnix int64
linkTarget sql.NullString
)
err := row.Scan(
&idStr,
&pathStr,
&sourcePathStr,
&mtimeUnix,
&file.Size,
&file.Mode,
&file.UID,
&file.GID,
&linkTarget,
)
if err != nil {
return nil, err
}
file.ID, err = types.ParseFileID(idStr)
if err != nil {
return nil, fmt.Errorf("parsing file ID: %w", err)
}
file.Path = types.FilePath(pathStr)
file.SourcePath = types.SourcePath(sourcePathStr)
file.MTime = time.Unix(mtimeUnix, 0).UTC()
if linkTarget.Valid {
file.LinkTarget = types.FilePath(linkTarget.String)
}
return &file, nil
}

View File

@@ -1,32 +1,43 @@
package database_test package database
import ( import (
"context" "context"
"database/sql" "database/sql"
"errors" "fmt"
"os" "os"
"path/filepath"
"testing" "testing"
"time" "time"
"sneak.berlin/go/vaultik/internal/database"
) )
// errTestRollback is the sentinel returned from transaction bodies to func setupTestDB(t *testing.T) (*DB, func()) {
// force a rollback in tests. ctx := context.Background()
var errTestRollback = errors.New("test rollback") dbPath := filepath.Join(t.TempDir(), "test.db")
db, err := New(ctx, dbPath)
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
cleanup := func() {
if err := db.Close(); err != nil {
t.Errorf("failed to close database: %v", err)
}
}
return db, cleanup
}
func TestFileRepository(t *testing.T) { func TestFileRepository(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t) db, cleanup := setupTestDB(t)
defer cleanup() defer cleanup()
ctx := context.Background() ctx := context.Background()
repo := database.NewFileRepository(db) repo := NewFileRepository(db)
// Test Create // Test Create
file := &database.File{ file := &File{
Path: testFileTxt, Path: "/test/file.txt",
MTime: time.Now().Truncate(time.Second), MTime: time.Now().Truncate(time.Second),
Size: 1024, Size: 1024,
Mode: 0644, Mode: 0644,
@@ -45,23 +56,18 @@ func TestFileRepository(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("failed to get file: %v", err) t.Fatalf("failed to get file: %v", err)
} }
if retrieved == nil { if retrieved == nil {
t.Fatal("expected file, got nil") t.Fatal("expected file, got nil")
} }
if retrieved.Path != file.Path { if retrieved.Path != file.Path {
t.Errorf("path mismatch: got %s, want %s", retrieved.Path, file.Path) t.Errorf("path mismatch: got %s, want %s", retrieved.Path, file.Path)
} }
if !retrieved.MTime.Equal(file.MTime) { if !retrieved.MTime.Equal(file.MTime) {
t.Errorf("mtime mismatch: got %v, want %v", retrieved.MTime, file.MTime) t.Errorf("mtime mismatch: got %v, want %v", retrieved.MTime, file.MTime)
} }
if retrieved.Size != file.Size { if retrieved.Size != file.Size {
t.Errorf("size mismatch: got %d, want %d", retrieved.Size, file.Size) t.Errorf("size mismatch: got %d, want %d", retrieved.Size, file.Size)
} }
if retrieved.Mode != file.Mode { if retrieved.Mode != file.Mode {
t.Errorf("mode mismatch: got %o, want %o", retrieved.Mode, file.Mode) t.Errorf("mode mismatch: got %o, want %o", retrieved.Mode, file.Mode)
} }
@@ -69,7 +75,6 @@ func TestFileRepository(t *testing.T) {
// Test Update (upsert) // Test Update (upsert)
file.Size = 2048 file.Size = 2048
file.MTime = time.Now().Truncate(time.Second) file.MTime = time.Now().Truncate(time.Second)
err = repo.Create(ctx, nil, file) err = repo.Create(ctx, nil, file)
if err != nil { if err != nil {
t.Fatalf("failed to update file: %v", err) t.Fatalf("failed to update file: %v", err)
@@ -79,41 +84,15 @@ func TestFileRepository(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("failed to get updated file: %v", err) t.Fatalf("failed to get updated file: %v", err)
} }
if retrieved.Size != 2048 { if retrieved.Size != 2048 {
t.Errorf("size not updated: got %d, want %d", retrieved.Size, 2048) t.Errorf("size not updated: got %d, want %d", retrieved.Size, 2048)
} }
}
func TestFileRepositoryListDelete(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
repo := database.NewFileRepository(db)
file := &database.File{
Path: testFileTxt,
MTime: time.Now().Truncate(time.Second),
Size: 1024,
Mode: 0644,
UID: 1000,
GID: 1000,
}
err := repo.Create(ctx, nil, file)
if err != nil {
t.Fatalf("failed to create file: %v", err)
}
// Test ListModifiedSince // Test ListModifiedSince
files, err := repo.ListModifiedSince(ctx, time.Now().Add(-1*time.Hour)) files, err := repo.ListModifiedSince(ctx, time.Now().Add(-1*time.Hour))
if err != nil { if err != nil {
t.Fatalf("failed to list files: %v", err) t.Fatalf("failed to list files: %v", err)
} }
if len(files) != 1 { if len(files) != 1 {
t.Errorf("expected 1 file, got %d", len(files)) t.Errorf("expected 1 file, got %d", len(files))
} }
@@ -124,27 +103,24 @@ func TestFileRepositoryListDelete(t *testing.T) {
t.Fatalf("failed to delete file: %v", err) t.Fatalf("failed to delete file: %v", err)
} }
retrieved, err := repo.GetByPath(ctx, file.Path.String()) retrieved, err = repo.GetByPath(ctx, file.Path.String())
if err != nil { if err != nil {
t.Fatalf("error getting deleted file: %v", err) t.Fatalf("error getting deleted file: %v", err)
} }
if retrieved != nil { if retrieved != nil {
t.Error("expected nil for deleted file") t.Error("expected nil for deleted file")
} }
} }
func TestFileRepositorySymlink(t *testing.T) { func TestFileRepositorySymlink(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t) db, cleanup := setupTestDB(t)
defer cleanup() defer cleanup()
ctx := context.Background() ctx := context.Background()
repo := database.NewFileRepository(db) repo := NewFileRepository(db)
// Test symlink // Test symlink
symlink := &database.File{ symlink := &File{
Path: "/test/link", Path: "/test/link",
MTime: time.Now().Truncate(time.Second), MTime: time.Now().Truncate(time.Second),
Size: 0, Size: 0,
@@ -163,30 +139,25 @@ func TestFileRepositorySymlink(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("failed to get symlink: %v", err) t.Fatalf("failed to get symlink: %v", err)
} }
if !retrieved.IsSymlink() { if !retrieved.IsSymlink() {
t.Error("expected IsSymlink() to be true") t.Error("expected IsSymlink() to be true")
} }
if retrieved.LinkTarget != symlink.LinkTarget { if retrieved.LinkTarget != symlink.LinkTarget {
t.Errorf("link target mismatch: got %s, want %s", t.Errorf("link target mismatch: got %s, want %s", retrieved.LinkTarget, symlink.LinkTarget)
retrieved.LinkTarget, symlink.LinkTarget)
} }
} }
func TestFileRepositoryTransaction(t *testing.T) { func TestFileRepositoryTransaction(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t) db, cleanup := setupTestDB(t)
defer cleanup() defer cleanup()
ctx := context.Background() ctx := context.Background()
repos := database.NewRepositories(db) repos := NewRepositories(db)
// Test transaction rollback // Test transaction rollback
err := repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error { err := repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
file := &database.File{ file := &File{
Path: testTxFile, Path: "/test/tx_file.txt",
MTime: time.Now().Truncate(time.Second), MTime: time.Now().Truncate(time.Second),
Size: 1024, Size: 1024,
Mode: 0644, Mode: 0644,
@@ -194,24 +165,23 @@ func TestFileRepositoryTransaction(t *testing.T) {
GID: 1000, GID: 1000,
} }
err := repos.Files.Create(ctx, tx, file) if err := repos.Files.Create(ctx, tx, file); err != nil {
if err != nil {
return err return err
} }
// Return error to trigger rollback // Return error to trigger rollback
return errTestRollback return fmt.Errorf("test rollback")
}) })
if !errors.Is(err, errTestRollback) {
if err == nil || err.Error() != "test rollback" {
t.Fatalf("expected rollback error, got: %v", err) t.Fatalf("expected rollback error, got: %v", err)
} }
// Verify file was not created // Verify file was not created
retrieved, err := repos.Files.GetByPath(ctx, testTxFile) retrieved, err := repos.Files.GetByPath(ctx, "/test/tx_file.txt")
if err != nil { if err != nil {
t.Fatalf("error checking for file: %v", err) t.Fatalf("error checking for file: %v", err)
} }
if retrieved != nil { if retrieved != nil {
t.Error("file should not exist after rollback") t.Error("file should not exist after rollback")
} }

View File

@@ -1,81 +0,0 @@
package database
import (
"context"
"path/filepath"
"testing"
"sneak.berlin/go/vaultik/internal/types"
)
// Common fixture values shared by the internal repository tests.
const (
internalTestHost = "test-host"
internalTestSnapshotID = "test-snapshot"
internalTestFilePath = "/test.txt"
internalTestFile1 = "/file1.txt"
internalTestFile2 = "/file2.txt"
// countFilesQuery counts the rows of the files table.
countFilesQuery = "SELECT COUNT(*) FROM files"
)
// mustCreateFileRow inserts the file row, failing the test on error.
func mustCreateFileRow(t *testing.T, repos *Repositories, file *File) {
t.Helper()
err := repos.Files.Create(context.Background(), nil, file)
if err != nil {
t.Fatalf("failed to create file %s: %v", file.Path, err)
}
}
// mustAddFileToSnapshot associates a file with a snapshot, failing the
// test on error.
func mustAddFileToSnapshot(
t *testing.T, repos *Repositories, snapshotID string, fileID types.FileID,
) {
t.Helper()
err := repos.Snapshots.AddFileByID(context.Background(), nil, snapshotID, fileID)
if err != nil {
t.Fatal(err)
}
}
// setupTestDB creates an on-disk test database in a per-test temp
// directory and returns it along with a cleanup func that closes it.
func setupTestDB(t *testing.T) (*DB, func()) {
t.Helper()
ctx := context.Background()
dbPath := filepath.Join(t.TempDir(), "test.db")
db, err := New(ctx, dbPath)
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
cleanup := func() {
err := db.Close()
if err != nil {
t.Errorf("failed to close database: %v", err)
}
}
return db, cleanup
}
// countRow runs a single-integer COUNT-style query and returns the value.
func countRow(t *testing.T, db *DB, query string, args ...any) int {
t.Helper()
var count int
err := db.conn.QueryRowContext(context.Background(), query, args...).Scan(&count)
if err != nil {
t.Fatal(err)
}
return count
}

View File

@@ -1,52 +0,0 @@
package database_test
import (
"context"
"path/filepath"
"testing"
"sneak.berlin/go/vaultik/internal/database"
)
// Common fixture values shared by the repository tests.
const (
testFilePath1 = "/file1.txt"
testFilePath2 = "/file2.txt"
testFileTxt = "/test/file.txt"
testTxFile = "/test/tx_file.txt"
testHostname = "test-host"
testVersion = "1.0.0"
)
// mustCreateFile inserts the given file row, failing the test on error.
func mustCreateFile(t *testing.T, repo *database.FileRepository, file *database.File) {
t.Helper()
err := repo.Create(context.Background(), nil, file)
if err != nil {
t.Fatalf("failed to create file %s: %v", file.Path, err)
}
}
// setupTestDB creates an on-disk test database in a per-test temp
// directory and returns it along with a cleanup func that closes it.
func setupTestDB(t *testing.T) (*database.DB, func()) {
t.Helper()
ctx := context.Background()
dbPath := filepath.Join(t.TempDir(), "test.db")
db, err := database.New(ctx, dbPath)
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
cleanup := func() {
err := db.Close()
if err != nil {
t.Errorf("failed to close database: %v", err)
}
}
return db, cleanup
}

View File

@@ -1,58 +0,0 @@
package database
import (
"context"
"database/sql"
"errors"
"fmt"
)
// LocalMetaKeyStorageURL is the key under which the destination store's
// URL is recorded when a mutating command first binds the local index
// to a specific backup destination.
const LocalMetaKeyStorageURL = "storage_url"
// LocalMetaRepository provides keyed access to host-local settings
// stored in the local_meta table.
type LocalMetaRepository struct {
db *DB
}
// NewLocalMetaRepository creates a LocalMetaRepository backed by db.
func NewLocalMetaRepository(db *DB) *LocalMetaRepository {
return &LocalMetaRepository{db: db}
}
// Get returns the value stored at key, or the empty string if the key
// is not set. A missing key is not an error — the caller distinguishes
// "unset" (bind on first use) from "set to something" (compare).
func (r *LocalMetaRepository) Get(ctx context.Context, key string) (string, error) {
var value string
err := r.db.conn.QueryRowContext(ctx,
"SELECT value FROM local_meta WHERE key = ?", key,
).Scan(&value)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("reading local_meta %q: %w", key, err)
}
return value, nil
}
// Set writes key=value, replacing any prior value.
func (r *LocalMetaRepository) Set(ctx context.Context, key, value string) error {
_, err := r.db.ExecWithLog(ctx,
`INSERT INTO local_meta (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
key, value,
)
if err != nil {
return fmt.Errorf("writing local_meta %q: %w", key, err)
}
return nil
}

View File

@@ -1,64 +0,0 @@
package database_test
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/database"
)
func TestLocalMetaEmptyOnFresh(t *testing.T) {
t.Parallel()
db, err := database.NewTestDB()
require.NoError(t, err)
defer func() { _ = db.Close() }()
repos := database.NewRepositories(db)
got, err := repos.LocalMeta.Get(context.Background(), database.LocalMetaKeyStorageURL)
require.NoError(t, err)
require.Empty(t, got, "fresh DB must return empty for unset keys, not error")
}
func TestLocalMetaSetGetRoundTrip(t *testing.T) {
t.Parallel()
db, err := database.NewTestDB()
require.NoError(t, err)
defer func() { _ = db.Close() }()
repos := database.NewRepositories(db)
ctx := context.Background()
require.NoError(t, repos.LocalMeta.Set(
ctx, database.LocalMetaKeyStorageURL, "file:///mnt/backups"))
got, err := repos.LocalMeta.Get(ctx, database.LocalMetaKeyStorageURL)
require.NoError(t, err)
require.Equal(t, "file:///mnt/backups", got)
}
func TestLocalMetaSetOverwrites(t *testing.T) {
t.Parallel()
db, err := database.NewTestDB()
require.NoError(t, err)
defer func() { _ = db.Close() }()
repos := database.NewRepositories(db)
ctx := context.Background()
require.NoError(t, repos.LocalMeta.Set(
ctx, database.LocalMetaKeyStorageURL, "s3://old"))
require.NoError(t, repos.LocalMeta.Set(
ctx, database.LocalMetaKeyStorageURL, "s3://new"))
got, err := repos.LocalMeta.Get(ctx, database.LocalMetaKeyStorageURL)
require.NoError(t, err)
require.Equal(t, "s3://new", got)
}

View File

@@ -1,9 +1,11 @@
// Package database provides data models and repository interfaces for the Vaultik backup system.
// It includes types for files, chunks, blobs, snapshots, and their relationships.
package database package database
import ( import (
"time" "time"
"sneak.berlin/go/vaultik/internal/types" "git.eeqj.de/sneak/vaultik/internal/types"
) )
// File represents a file or directory in the backup system. // File represents a file or directory in the backup system.
@@ -13,10 +15,7 @@ import (
type File struct { type File struct {
ID types.FileID // UUID primary key ID types.FileID // UUID primary key
Path types.FilePath // Absolute path of the file Path types.FilePath // Absolute path of the file
SourcePath types.SourcePath // The source directory this file came from (for restore path stripping)
// SourcePath is the source directory this file came from (used for
// restore path stripping).
SourcePath types.SourcePath
MTime time.Time MTime time.Time
Size int64 Size int64
Mode uint32 Mode uint32
@@ -57,10 +56,7 @@ type Chunk struct {
// -> encrypted with age -> hashed -> uploaded to S3 with the hash as filename. // -> encrypted with age -> hashed -> uploaded to S3 with the hash as filename.
type Blob struct { type Blob struct {
ID types.BlobID // UUID assigned when blob creation starts ID types.BlobID // UUID assigned when blob creation starts
Hash types.BlobHash // SHA256 of final compressed+encrypted content (empty until finalized)
// Hash is the SHA256 of the final compressed+encrypted content
// (empty until finalized).
Hash types.BlobHash
CreatedTS time.Time // When blob creation started CreatedTS time.Time // When blob creation started
FinishedTS *time.Time // When blob was finalized (nil if still packing) FinishedTS *time.Time // When blob was finalized (nil if still packing)
UncompressedSize int64 // Total size of raw chunks before compression UncompressedSize int64 // Total size of raw chunks before compression
@@ -79,10 +75,9 @@ type BlobChunk struct {
Length int64 Length int64
} }
// ChunkFile represents the reverse mapping showing which files contain a // ChunkFile represents the reverse mapping showing which files contain a specific chunk.
// specific chunk. This is used during deduplication to identify all files // This is used during deduplication to identify all files that share a chunk,
// that share a chunk, which is important for garbage collection and // which is important for garbage collection and integrity verification.
// integrity verification.
type ChunkFile struct { type ChunkFile struct {
ChunkHash types.ChunkHash ChunkHash types.ChunkHash
FileID types.FileID FileID types.FileID
@@ -102,10 +97,7 @@ type Snapshot struct {
ChunkCount int64 ChunkCount int64
BlobCount int64 BlobCount int64
TotalSize int64 // Total size of all referenced files TotalSize int64 // Total size of all referenced files
BlobSize int64 // Total size of all referenced blobs (compressed and encrypted)
// BlobSize is the total size of all referenced blobs (compressed and
// encrypted).
BlobSize int64
BlobUncompressedSize int64 // Total uncompressed size of all referenced blobs BlobUncompressedSize int64 // Total uncompressed size of all referenced blobs
CompressionRatio float64 // Compression ratio (BlobSize / BlobUncompressedSize) CompressionRatio float64 // Compression ratio (BlobSize / BlobUncompressedSize)
CompressionLevel int // Compression level used for this snapshot CompressionLevel int // Compression level used for this snapshot

View File

@@ -6,18 +6,12 @@ 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"
) )
// indexDirPerm restricts the local index directory to the owning user;
// the index describes the backed-up file tree and must stay private.
const indexDirPerm = 0o700
// Module provides database dependencies // Module provides database dependencies
//
//nolint:gochecknoglobals // fx module definitions are package globals by convention
var Module = fx.Module("database", var Module = fx.Module("database",
fx.Provide( fx.Provide(
provideDatabase, provideDatabase,
@@ -28,9 +22,7 @@ var Module = fx.Module("database",
func provideDatabase(lc fx.Lifecycle, cfg *config.Config) (*DB, error) { func provideDatabase(lc fx.Lifecycle, cfg *config.Config) (*DB, error) {
// Ensure the index directory exists // Ensure the index directory exists
indexDir := filepath.Dir(cfg.IndexPath) indexDir := filepath.Dir(cfg.IndexPath)
if err := os.MkdirAll(indexDir, 0700); err != nil {
err := os.MkdirAll(indexDir, indexDirPerm)
if err != nil {
return nil, fmt.Errorf("creating index directory: %w", err) return nil, fmt.Errorf("creating index directory: %w", err)
} }
@@ -40,18 +32,13 @@ func provideDatabase(lc fx.Lifecycle, cfg *config.Config) (*DB, error) {
} }
lc.Append(fx.Hook{ lc.Append(fx.Hook{
OnStop: func(_ context.Context) error { OnStop: func(ctx context.Context) error {
log.Debug("Database module OnStop hook called") log.Debug("Database module OnStop hook called")
if err := db.Close(); err != nil {
err := db.Close()
if err != nil {
log.Error("Failed to close database in OnStop hook", "error", err) log.Error("Failed to close database in OnStop hook", "error", err)
return err return err
} }
log.Debug("Database closed successfully in OnStop hook") log.Debug("Database closed successfully in OnStop hook")
return nil return nil
}, },
}) })

View File

@@ -19,7 +19,6 @@ type Repositories struct {
ChunkFiles *ChunkFileRepository ChunkFiles *ChunkFileRepository
Snapshots *SnapshotRepository Snapshots *SnapshotRepository
Uploads *UploadRepository Uploads *UploadRepository
LocalMeta *LocalMetaRepository
} }
// NewRepositories creates a new Repositories instance with all repository types. // NewRepositories creates a new Repositories instance with all repository types.
@@ -35,7 +34,6 @@ func NewRepositories(db *DB) *Repositories {
ChunkFiles: NewChunkFileRepository(db), ChunkFiles: NewChunkFileRepository(db),
Snapshots: NewSnapshotRepository(db), Snapshots: NewSnapshotRepository(db),
Uploads: NewUploadRepository(db.conn), Uploads: NewUploadRepository(db.conn),
LocalMeta: NewLocalMetaRepository(db),
} }
} }
@@ -50,26 +48,21 @@ type TxFunc func(ctx context.Context, tx *sql.Tx) error
// This method should be used for all write operations to ensure atomicity. // This method should be used for all write operations to ensure atomicity.
func (r *Repositories) WithTx(ctx context.Context, fn TxFunc) error { func (r *Repositories) WithTx(ctx context.Context, fn TxFunc) error {
LogSQL("WithTx", "Beginning transaction", "") LogSQL("WithTx", "Beginning transaction", "")
tx, err := r.db.BeginTx(ctx, nil) tx, err := r.db.BeginTx(ctx, nil)
if err != nil { if err != nil {
return fmt.Errorf("beginning transaction: %w", err) return fmt.Errorf("beginning transaction: %w", err)
} }
LogSQL("WithTx", "Transaction started", "") LogSQL("WithTx", "Transaction started", "")
defer func() { defer func() {
if p := recover(); p != nil { if p := recover(); p != nil {
rollbackErr := tx.Rollback() if rollbackErr := tx.Rollback(); rollbackErr != nil {
if rollbackErr != nil { Fatal("failed to rollback transaction: %v", rollbackErr)
Fatalf("failed to rollback transaction: %v", rollbackErr)
} }
panic(p) panic(p)
} else if err != nil { } else if err != nil {
rollbackErr := tx.Rollback() if rollbackErr := tx.Rollback(); rollbackErr != nil {
if rollbackErr != nil { Fatal("failed to rollback transaction: %v", rollbackErr)
Fatalf("failed to rollback transaction: %v", rollbackErr)
} }
} }
}() }()
@@ -95,7 +88,6 @@ func (r *Repositories) WithReadTx(ctx context.Context, fn TxFunc) error {
opts := &sql.TxOptions{ opts := &sql.TxOptions{
ReadOnly: true, ReadOnly: true,
} }
tx, err := r.db.BeginTx(ctx, opts) tx, err := r.db.BeginTx(ctx, opts)
if err != nil { if err != nil {
return fmt.Errorf("beginning read transaction: %w", err) return fmt.Errorf("beginning read transaction: %w", err)
@@ -103,16 +95,13 @@ func (r *Repositories) WithReadTx(ctx context.Context, fn TxFunc) error {
defer func() { defer func() {
if p := recover(); p != nil { if p := recover(); p != nil {
rollbackErr := tx.Rollback() if rollbackErr := tx.Rollback(); rollbackErr != nil {
if rollbackErr != nil { Fatal("failed to rollback transaction: %v", rollbackErr)
Fatalf("failed to rollback transaction: %v", rollbackErr)
} }
panic(p) panic(p)
} else if err != nil { } else if err != nil {
rollbackErr := tx.Rollback() if rollbackErr := tx.Rollback(); rollbackErr != nil {
if rollbackErr != nil { Fatal("failed to rollback transaction: %v", rollbackErr)
Fatalf("failed to rollback transaction: %v", rollbackErr)
} }
} }
}() }()

View File

@@ -1,162 +1,124 @@
package database_test package database
import ( import (
"context" "context"
"database/sql" "database/sql"
"errors" "fmt"
"testing" "testing"
"time" "time"
"sneak.berlin/go/vaultik/internal/database" "git.eeqj.de/sneak/vaultik/internal/types"
"sneak.berlin/go/vaultik/internal/types"
) )
// errIntentionalRollback forces a transaction rollback in tests. func TestRepositoriesTransaction(t *testing.T) {
var errIntentionalRollback = errors.New("intentional rollback") db, cleanup := setupTestDB(t)
defer cleanup()
// createTxTestData returns a transaction body that creates a file with ctx := context.Background()
// two chunks packed into one blob. repos := NewRepositories(db)
func createTxTestData(
repos *database.Repositories, // Test successful transaction with multiple operations
) func(context.Context, *sql.Tx) error { err := repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
return func(ctx context.Context, tx *sql.Tx) error { // Create a file
file := &database.File{ file := &File{
Path: testTxFile, Path: "/test/tx_file.txt",
MTime: time.Now().Truncate(time.Second), MTime: time.Now().Truncate(time.Second),
Size: 1024, Size: 1024,
Mode: 0644, Mode: 0644,
UID: 1000, UID: 1000,
GID: 1000, GID: 1000,
} }
if err := repos.Files.Create(ctx, tx, file); err != nil {
err := repos.Files.Create(ctx, tx, file)
if err != nil {
return err return err
} }
err = createTxFileChunks(ctx, tx, repos, file.ID)
if err != nil {
return err
}
return createTxBlob(ctx, tx, repos)
}
}
// createTxFileChunks creates the two test chunks and maps them to the file.
func createTxFileChunks(
ctx context.Context, tx *sql.Tx,
repos *database.Repositories, fileID types.FileID,
) error {
// Create chunks // Create chunks
chunk1 := &database.Chunk{ chunk1 := &Chunk{
ChunkHash: types.ChunkHash("tx_chunk1"), ChunkHash: types.ChunkHash("tx_chunk1"),
Size: 512, Size: 512,
} }
if err := repos.Chunks.Create(ctx, tx, chunk1); err != nil {
err := repos.Chunks.Create(ctx, tx, chunk1)
if err != nil {
return err return err
} }
chunk2 := &database.Chunk{ chunk2 := &Chunk{
ChunkHash: types.ChunkHash("tx_chunk2"), ChunkHash: types.ChunkHash("tx_chunk2"),
Size: 512, Size: 512,
} }
if err := repos.Chunks.Create(ctx, tx, chunk2); err != nil {
err = repos.Chunks.Create(ctx, tx, chunk2)
if err != nil {
return err return err
} }
// Map chunks to file // Map chunks to file
fc1 := &database.FileChunk{ fc1 := &FileChunk{
FileID: fileID, FileID: file.ID,
Idx: 0, Idx: 0,
ChunkHash: chunk1.ChunkHash, ChunkHash: chunk1.ChunkHash,
} }
if err := repos.FileChunks.Create(ctx, tx, fc1); err != nil {
err = repos.FileChunks.Create(ctx, tx, fc1)
if err != nil {
return err return err
} }
fc2 := &database.FileChunk{ fc2 := &FileChunk{
FileID: fileID, FileID: file.ID,
Idx: 1, Idx: 1,
ChunkHash: chunk2.ChunkHash, ChunkHash: chunk2.ChunkHash,
} }
if err := repos.FileChunks.Create(ctx, tx, fc2); err != nil {
return err
}
return repos.FileChunks.Create(ctx, tx, fc2) // Create blob
} blob := &Blob{
// createTxBlob creates the test blob and maps both chunks into it.
func createTxBlob(
ctx context.Context, tx *sql.Tx, repos *database.Repositories,
) error {
blob := &database.Blob{
ID: types.NewBlobID(), ID: types.NewBlobID(),
Hash: types.BlobHash("tx_blob1"), Hash: types.BlobHash("tx_blob1"),
CreatedTS: time.Now().Truncate(time.Second), CreatedTS: time.Now().Truncate(time.Second),
} }
if err := repos.Blobs.Create(ctx, tx, blob); err != nil {
err := repos.Blobs.Create(ctx, tx, blob)
if err != nil {
return err return err
} }
// Map chunks to blob // Map chunks to blob
bc1 := &database.BlobChunk{ bc1 := &BlobChunk{
BlobID: blob.ID, BlobID: blob.ID,
ChunkHash: types.ChunkHash("tx_chunk1"), ChunkHash: chunk1.ChunkHash,
Offset: 0, Offset: 0,
Length: 512, Length: 512,
} }
if err := repos.BlobChunks.Create(ctx, tx, bc1); err != nil {
err = repos.BlobChunks.Create(ctx, tx, bc1)
if err != nil {
return err return err
} }
bc2 := &database.BlobChunk{ bc2 := &BlobChunk{
BlobID: blob.ID, BlobID: blob.ID,
ChunkHash: types.ChunkHash("tx_chunk2"), ChunkHash: chunk2.ChunkHash,
Offset: 512, Offset: 512,
Length: 512, Length: 512,
} }
if err := repos.BlobChunks.Create(ctx, tx, bc2); err != nil {
return err
}
return repos.BlobChunks.Create(ctx, tx, bc2) return nil
} })
func TestRepositoriesTransaction(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
repos := database.NewRepositories(db)
err := repos.WithTx(ctx, createTxTestData(repos))
if err != nil { if err != nil {
t.Fatalf("transaction failed: %v", err) t.Fatalf("transaction failed: %v", err)
} }
// Verify all data was committed // Verify all data was committed
file, err := repos.Files.GetByPath(ctx, testTxFile) file, err := repos.Files.GetByPath(ctx, "/test/tx_file.txt")
if err != nil { if err != nil {
t.Fatalf("failed to get file: %v", err) t.Fatalf("failed to get file: %v", err)
} }
if file == nil { if file == nil {
t.Error("expected file after transaction") t.Error("expected file after transaction")
} }
chunks, err := repos.FileChunks.GetByFile(ctx, testTxFile) chunks, err := repos.FileChunks.GetByFile(ctx, "/test/tx_file.txt")
if err != nil { if err != nil {
t.Fatalf("failed to get file chunks: %v", err) t.Fatalf("failed to get file chunks: %v", err)
} }
if len(chunks) != 2 { if len(chunks) != 2 {
t.Errorf("expected 2 file chunks, got %d", len(chunks)) t.Errorf("expected 2 file chunks, got %d", len(chunks))
} }
@@ -165,25 +127,22 @@ func TestRepositoriesTransaction(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("failed to get blob: %v", err) t.Fatalf("failed to get blob: %v", err)
} }
if blob == nil { if blob == nil {
t.Error("expected blob after transaction") t.Error("expected blob after transaction")
} }
} }
func TestRepositoriesTransactionRollback(t *testing.T) { func TestRepositoriesTransactionRollback(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t) db, cleanup := setupTestDB(t)
defer cleanup() defer cleanup()
ctx := context.Background() ctx := context.Background()
repos := database.NewRepositories(db) repos := NewRepositories(db)
// Test transaction rollback // Test transaction rollback
err := repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error { err := repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
// Create a file // Create a file
file := &database.File{ file := &File{
Path: "/test/rollback_file.txt", Path: "/test/rollback_file.txt",
MTime: time.Now().Truncate(time.Second), MTime: time.Now().Truncate(time.Second),
Size: 1024, Size: 1024,
@@ -191,27 +150,24 @@ func TestRepositoriesTransactionRollback(t *testing.T) {
UID: 1000, UID: 1000,
GID: 1000, GID: 1000,
} }
if err := repos.Files.Create(ctx, tx, file); err != nil {
err := repos.Files.Create(ctx, tx, file)
if err != nil {
return err return err
} }
// Create a chunk // Create a chunk
chunk := &database.Chunk{ chunk := &Chunk{
ChunkHash: types.ChunkHash("rollback_chunk"), ChunkHash: types.ChunkHash("rollback_chunk"),
Size: 1024, Size: 1024,
} }
if err := repos.Chunks.Create(ctx, tx, chunk); err != nil {
err = repos.Chunks.Create(ctx, tx, chunk)
if err != nil {
return err return err
} }
// Return error to trigger rollback // Return error to trigger rollback
return errIntentionalRollback return fmt.Errorf("intentional rollback")
}) })
if !errors.Is(err, errIntentionalRollback) {
if err == nil || err.Error() != "intentional rollback" {
t.Fatalf("expected rollback error, got: %v", err) t.Fatalf("expected rollback error, got: %v", err)
} }
@@ -220,7 +176,6 @@ func TestRepositoriesTransactionRollback(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("error checking for file: %v", err) t.Fatalf("error checking for file: %v", err)
} }
if file != nil { if file != nil {
t.Error("file should not exist after rollback") t.Error("file should not exist after rollback")
} }
@@ -229,23 +184,20 @@ func TestRepositoriesTransactionRollback(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("error checking for chunk: %v", err) t.Fatalf("error checking for chunk: %v", err)
} }
if chunk != nil { if chunk != nil {
t.Error("chunk should not exist after rollback") t.Error("chunk should not exist after rollback")
} }
} }
func TestRepositoriesReadTransaction(t *testing.T) { func TestRepositoriesReadTransaction(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t) db, cleanup := setupTestDB(t)
defer cleanup() defer cleanup()
ctx := context.Background() ctx := context.Background()
repos := database.NewRepositories(db) repos := NewRepositories(db)
// First, create some data // First, create some data
file := &database.File{ file := &File{
Path: "/test/read_file.txt", Path: "/test/read_file.txt",
MTime: time.Now().Truncate(time.Second), MTime: time.Now().Truncate(time.Second),
Size: 1024, Size: 1024,
@@ -253,25 +205,22 @@ func TestRepositoriesReadTransaction(t *testing.T) {
UID: 1000, UID: 1000,
GID: 1000, GID: 1000,
} }
err := repos.Files.Create(ctx, nil, file) err := repos.Files.Create(ctx, nil, file)
if err != nil { if err != nil {
t.Fatalf("failed to create file: %v", err) t.Fatalf("failed to create file: %v", err)
} }
// Test read-only transaction // Test read-only transaction
var retrievedFile *database.File var retrievedFile *File
err = repos.WithReadTx(ctx, func(ctx context.Context, tx *sql.Tx) error { err = repos.WithReadTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
var err error var err error
retrievedFile, err = repos.Files.GetByPathTx(ctx, tx, "/test/read_file.txt") retrievedFile, err = repos.Files.GetByPathTx(ctx, tx, "/test/read_file.txt")
if err != nil { if err != nil {
return err return err
} }
// Try to write in read-only transaction (should fail) // Try to write in read-only transaction (should fail)
_ = repos.Files.Create(ctx, tx, &database.File{ _ = repos.Files.Create(ctx, tx, &File{
Path: "/test/should_fail.txt", Path: "/test/should_fail.txt",
MTime: time.Now(), MTime: time.Now(),
Size: 0, Size: 0,
@@ -283,6 +232,7 @@ func TestRepositoriesReadTransaction(t *testing.T) {
return nil return nil
}) })
if err != nil { if err != nil {
t.Fatalf("read transaction failed: %v", err) t.Fatalf("read transaction failed: %v", err)
} }

View File

@@ -1,24 +1,17 @@
//nolint:testpackage // inspects the unexported database connection
package database package database
import ( import (
"context" "context"
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
"testing" "testing"
"time" "time"
"sneak.berlin/go/vaultik/internal/types" "git.eeqj.de/sneak/vaultik/internal/types"
) )
// errTxIntentionalRollback forces a transaction rollback in tests.
var errTxIntentionalRollback = errors.New("intentional rollback")
// TestFileRepositoryUUIDGeneration tests that files get unique UUIDs // TestFileRepositoryUUIDGeneration tests that files get unique UUIDs
func TestFileRepositoryUUIDGeneration(t *testing.T) { func TestFileRepositoryUUIDGeneration(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t) db, cleanup := setupTestDB(t)
defer cleanup() defer cleanup()
@@ -28,7 +21,7 @@ func TestFileRepositoryUUIDGeneration(t *testing.T) {
// Create multiple files // Create multiple files
files := []*File{ files := []*File{
{ {
Path: internalTestFile1, Path: "/file1.txt",
MTime: time.Now().Truncate(time.Second), MTime: time.Now().Truncate(time.Second),
Size: 1024, Size: 1024,
Mode: 0644, Mode: 0644,
@@ -36,7 +29,7 @@ func TestFileRepositoryUUIDGeneration(t *testing.T) {
GID: 1000, GID: 1000,
}, },
{ {
Path: internalTestFile2, Path: "/file2.txt",
MTime: time.Now().Truncate(time.Second), MTime: time.Now().Truncate(time.Second),
Size: 2048, Size: 2048,
Mode: 0644, Mode: 0644,
@@ -46,7 +39,6 @@ func TestFileRepositoryUUIDGeneration(t *testing.T) {
} }
uuids := make(map[string]bool) uuids := make(map[string]bool)
for _, file := range files { for _, file := range files {
err := repo.Create(ctx, nil, file) err := repo.Create(ctx, nil, file)
if err != nil { if err != nil {
@@ -62,15 +54,12 @@ func TestFileRepositoryUUIDGeneration(t *testing.T) {
if uuids[file.ID.String()] { if uuids[file.ID.String()] {
t.Errorf("duplicate UUID generated: %s", file.ID) t.Errorf("duplicate UUID generated: %s", file.ID)
} }
uuids[file.ID.String()] = true uuids[file.ID.String()] = true
} }
} }
// TestFileRepositoryGetByID tests retrieving files by UUID // TestFileRepositoryGetByID tests retrieving files by UUID
func TestFileRepositoryGetByID(t *testing.T) { func TestFileRepositoryGetByID(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t) db, cleanup := setupTestDB(t)
defer cleanup() defer cleanup()
@@ -79,7 +68,7 @@ func TestFileRepositoryGetByID(t *testing.T) {
// Create a file // Create a file
file := &File{ file := &File{
Path: internalTestFilePath, Path: "/test.txt",
MTime: time.Now().Truncate(time.Second), MTime: time.Now().Truncate(time.Second),
Size: 1024, Size: 1024,
Mode: 0644, Mode: 0644,
@@ -101,20 +90,16 @@ func TestFileRepositoryGetByID(t *testing.T) {
if retrieved.ID != file.ID { if retrieved.ID != file.ID {
t.Errorf("ID mismatch: expected %s, got %s", file.ID, retrieved.ID) t.Errorf("ID mismatch: expected %s, got %s", file.ID, retrieved.ID)
} }
if retrieved.Path != file.Path { if retrieved.Path != file.Path {
t.Errorf("Path mismatch: expected %s, got %s", file.Path, retrieved.Path) t.Errorf("Path mismatch: expected %s, got %s", file.Path, retrieved.Path)
} }
// Test non-existent ID: generate a new UUID that won't exist in the // Test non-existent ID
// database. nonExistentID := types.NewFileID() // Generate a new UUID that won't exist in the database
nonExistentID := types.NewFileID()
nonExistent, err := repo.GetByID(ctx, nonExistentID) nonExistent, err := repo.GetByID(ctx, nonExistentID)
if err != nil { if err != nil {
t.Fatalf("GetByID should not return error for non-existent ID: %v", err) t.Fatalf("GetByID should not return error for non-existent ID: %v", err)
} }
if nonExistent != nil { if nonExistent != nil {
t.Error("expected nil for non-existent ID") t.Error("expected nil for non-existent ID")
} }
@@ -122,8 +107,6 @@ func TestFileRepositoryGetByID(t *testing.T) {
// TestOrphanedFileCleanup tests the cleanup of orphaned files // TestOrphanedFileCleanup tests the cleanup of orphaned files
func TestOrphanedFileCleanup(t *testing.T) { func TestOrphanedFileCleanup(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t) db, cleanup := setupTestDB(t)
defer cleanup() defer cleanup()
@@ -152,7 +135,6 @@ func TestOrphanedFileCleanup(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("failed to create file1: %v", err) t.Fatalf("failed to create file1: %v", err)
} }
err = repos.Files.Create(ctx, nil, file2) err = repos.Files.Create(ctx, nil, file2)
if err != nil { if err != nil {
t.Fatalf("failed to create file2: %v", err) t.Fatalf("failed to create file2: %v", err)
@@ -160,18 +142,20 @@ func TestOrphanedFileCleanup(t *testing.T) {
// Create a snapshot and reference only file2 // Create a snapshot and reference only file2
snapshot := &Snapshot{ snapshot := &Snapshot{
ID: internalTestSnapshotID, ID: "test-snapshot",
Hostname: internalTestHost, Hostname: "test-host",
StartedAt: time.Now(), StartedAt: time.Now(),
} }
err = repos.Snapshots.Create(ctx, nil, snapshot) err = repos.Snapshots.Create(ctx, nil, snapshot)
if err != nil { if err != nil {
t.Fatalf("failed to create snapshot: %v", err) t.Fatalf("failed to create snapshot: %v", err)
} }
// Add file2 to snapshot // Add file2 to snapshot
mustAddFileToSnapshot(t, repos, snapshot.ID.String(), file2.ID) err = repos.Snapshots.AddFileByID(ctx, nil, snapshot.ID.String(), file2.ID)
if err != nil {
t.Fatalf("failed to add file to snapshot: %v", err)
}
// Run orphaned cleanup // Run orphaned cleanup
err = repos.Files.DeleteOrphaned(ctx) err = repos.Files.DeleteOrphaned(ctx)
@@ -184,7 +168,6 @@ func TestOrphanedFileCleanup(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("error getting file: %v", err) t.Fatalf("error getting file: %v", err)
} }
if orphanedFile != nil { if orphanedFile != nil {
t.Error("orphaned file should have been deleted") t.Error("orphaned file should have been deleted")
} }
@@ -194,7 +177,6 @@ func TestOrphanedFileCleanup(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("error getting file: %v", err) t.Fatalf("error getting file: %v", err)
} }
if referencedFile == nil { if referencedFile == nil {
t.Error("referenced file should not have been deleted") t.Error("referenced file should not have been deleted")
} }
@@ -202,8 +184,6 @@ func TestOrphanedFileCleanup(t *testing.T) {
// TestOrphanedChunkCleanup tests the cleanup of orphaned chunks // TestOrphanedChunkCleanup tests the cleanup of orphaned chunks
func TestOrphanedChunkCleanup(t *testing.T) { func TestOrphanedChunkCleanup(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t) db, cleanup := setupTestDB(t)
defer cleanup() defer cleanup()
@@ -224,7 +204,6 @@ func TestOrphanedChunkCleanup(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("failed to create chunk1: %v", err) t.Fatalf("failed to create chunk1: %v", err)
} }
err = repos.Chunks.Create(ctx, nil, chunk2) err = repos.Chunks.Create(ctx, nil, chunk2)
if err != nil { if err != nil {
t.Fatalf("failed to create chunk2: %v", err) t.Fatalf("failed to create chunk2: %v", err)
@@ -232,14 +211,13 @@ func TestOrphanedChunkCleanup(t *testing.T) {
// Create a file and reference only chunk2 // Create a file and reference only chunk2
file := &File{ file := &File{
Path: internalTestFilePath, Path: "/test.txt",
MTime: time.Now().Truncate(time.Second), MTime: time.Now().Truncate(time.Second),
Size: 1024, Size: 1024,
Mode: 0644, Mode: 0644,
UID: 1000, UID: 1000,
GID: 1000, GID: 1000,
} }
err = repos.Files.Create(ctx, nil, file) err = repos.Files.Create(ctx, nil, file)
if err != nil { if err != nil {
t.Fatalf("failed to create file: %v", err) t.Fatalf("failed to create file: %v", err)
@@ -251,7 +229,6 @@ func TestOrphanedChunkCleanup(t *testing.T) {
Idx: 0, Idx: 0,
ChunkHash: chunk2.ChunkHash, ChunkHash: chunk2.ChunkHash,
} }
err = repos.FileChunks.Create(ctx, nil, fc) err = repos.FileChunks.Create(ctx, nil, fc)
if err != nil { if err != nil {
t.Fatalf("failed to create file chunk: %v", err) t.Fatalf("failed to create file chunk: %v", err)
@@ -268,7 +245,6 @@ func TestOrphanedChunkCleanup(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("error getting chunk: %v", err) t.Fatalf("error getting chunk: %v", err)
} }
if orphanedChunk != nil { if orphanedChunk != nil {
t.Error("orphaned chunk should have been deleted") t.Error("orphaned chunk should have been deleted")
} }
@@ -278,7 +254,6 @@ func TestOrphanedChunkCleanup(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("error getting chunk: %v", err) t.Fatalf("error getting chunk: %v", err)
} }
if referencedChunk == nil { if referencedChunk == nil {
t.Error("referenced chunk should not have been deleted") t.Error("referenced chunk should not have been deleted")
} }
@@ -286,8 +261,6 @@ func TestOrphanedChunkCleanup(t *testing.T) {
// TestOrphanedBlobCleanup tests the cleanup of orphaned blobs // TestOrphanedBlobCleanup tests the cleanup of orphaned blobs
func TestOrphanedBlobCleanup(t *testing.T) { func TestOrphanedBlobCleanup(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t) db, cleanup := setupTestDB(t)
defer cleanup() defer cleanup()
@@ -310,7 +283,6 @@ func TestOrphanedBlobCleanup(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("failed to create blob1: %v", err) t.Fatalf("failed to create blob1: %v", err)
} }
err = repos.Blobs.Create(ctx, nil, blob2) err = repos.Blobs.Create(ctx, nil, blob2)
if err != nil { if err != nil {
t.Fatalf("failed to create blob2: %v", err) t.Fatalf("failed to create blob2: %v", err)
@@ -318,11 +290,10 @@ func TestOrphanedBlobCleanup(t *testing.T) {
// Create a snapshot and reference only blob2 // Create a snapshot and reference only blob2
snapshot := &Snapshot{ snapshot := &Snapshot{
ID: internalTestSnapshotID, ID: "test-snapshot",
Hostname: internalTestHost, Hostname: "test-host",
StartedAt: time.Now(), StartedAt: time.Now(),
} }
err = repos.Snapshots.Create(ctx, nil, snapshot) err = repos.Snapshots.Create(ctx, nil, snapshot)
if err != nil { if err != nil {
t.Fatalf("failed to create snapshot: %v", err) t.Fatalf("failed to create snapshot: %v", err)
@@ -345,7 +316,6 @@ func TestOrphanedBlobCleanup(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("error getting blob: %v", err) t.Fatalf("error getting blob: %v", err)
} }
if orphanedBlob != nil { if orphanedBlob != nil {
t.Error("orphaned blob should have been deleted") t.Error("orphaned blob should have been deleted")
} }
@@ -355,7 +325,6 @@ func TestOrphanedBlobCleanup(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("error getting blob: %v", err) t.Fatalf("error getting blob: %v", err)
} }
if referencedBlob == nil { if referencedBlob == nil {
t.Error("referenced blob should not have been deleted") t.Error("referenced blob should not have been deleted")
} }
@@ -363,8 +332,6 @@ func TestOrphanedBlobCleanup(t *testing.T) {
// TestFileChunkRepositoryWithUUIDs tests file-chunk relationships with UUIDs // TestFileChunkRepositoryWithUUIDs tests file-chunk relationships with UUIDs
func TestFileChunkRepositoryWithUUIDs(t *testing.T) { func TestFileChunkRepositoryWithUUIDs(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t) db, cleanup := setupTestDB(t)
defer cleanup() defer cleanup()
@@ -373,15 +340,17 @@ func TestFileChunkRepositoryWithUUIDs(t *testing.T) {
// Create a file // Create a file
file := &File{ file := &File{
Path: internalTestFilePath, Path: "/test.txt",
MTime: time.Now().Truncate(time.Second), MTime: time.Now().Truncate(time.Second),
Size: 3072, Size: 3072,
Mode: 0644, Mode: 0644,
UID: 1000, UID: 1000,
GID: 1000, GID: 1000,
} }
err := repos.Files.Create(ctx, nil, file)
mustCreateFileRow(t, repos, file) if err != nil {
t.Fatalf("failed to create file: %v", err)
}
// Create chunks // Create chunks
chunks := []types.ChunkHash{"chunk1", "chunk2", "chunk3"} chunks := []types.ChunkHash{"chunk1", "chunk2", "chunk3"}
@@ -390,8 +359,7 @@ func TestFileChunkRepositoryWithUUIDs(t *testing.T) {
ChunkHash: chunkHash, ChunkHash: chunkHash,
Size: 1024, Size: 1024,
} }
err = repos.Chunks.Create(ctx, nil, chunk)
err := repos.Chunks.Create(ctx, nil, chunk)
if err != nil { if err != nil {
t.Fatalf("failed to create chunk: %v", err) t.Fatalf("failed to create chunk: %v", err)
} }
@@ -402,7 +370,6 @@ func TestFileChunkRepositoryWithUUIDs(t *testing.T) {
Idx: i, Idx: i,
ChunkHash: chunkHash, ChunkHash: chunkHash,
} }
err = repos.FileChunks.Create(ctx, nil, fc) err = repos.FileChunks.Create(ctx, nil, fc)
if err != nil { if err != nil {
t.Fatalf("failed to create file chunk: %v", err) t.Fatalf("failed to create file chunk: %v", err)
@@ -414,7 +381,6 @@ func TestFileChunkRepositoryWithUUIDs(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("failed to get file chunks: %v", err) t.Fatalf("failed to get file chunks: %v", err)
} }
if len(fileChunks) != 3 { if len(fileChunks) != 3 {
t.Errorf("expected 3 chunks, got %d", len(fileChunks)) t.Errorf("expected 3 chunks, got %d", len(fileChunks))
} }
@@ -429,7 +395,6 @@ func TestFileChunkRepositoryWithUUIDs(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("failed to get file chunks after delete: %v", err) t.Fatalf("failed to get file chunks after delete: %v", err)
} }
if len(fileChunks) != 0 { if len(fileChunks) != 0 {
t.Errorf("expected 0 chunks after delete, got %d", len(fileChunks)) t.Errorf("expected 0 chunks after delete, got %d", len(fileChunks))
} }
@@ -437,8 +402,6 @@ func TestFileChunkRepositoryWithUUIDs(t *testing.T) {
// TestChunkFileRepositoryWithUUIDs tests chunk-file relationships with UUIDs // TestChunkFileRepositoryWithUUIDs tests chunk-file relationships with UUIDs
func TestChunkFileRepositoryWithUUIDs(t *testing.T) { func TestChunkFileRepositoryWithUUIDs(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t) db, cleanup := setupTestDB(t)
defer cleanup() defer cleanup()
@@ -447,7 +410,7 @@ func TestChunkFileRepositoryWithUUIDs(t *testing.T) {
// Create files // Create files
file1 := &File{ file1 := &File{
Path: internalTestFile1, Path: "/file1.txt",
MTime: time.Now().Truncate(time.Second), MTime: time.Now().Truncate(time.Second),
Size: 1024, Size: 1024,
Mode: 0644, Mode: 0644,
@@ -455,7 +418,7 @@ func TestChunkFileRepositoryWithUUIDs(t *testing.T) {
GID: 1000, GID: 1000,
} }
file2 := &File{ file2 := &File{
Path: internalTestFile2, Path: "/file2.txt",
MTime: time.Now().Truncate(time.Second), MTime: time.Now().Truncate(time.Second),
Size: 1024, Size: 1024,
Mode: 0644, Mode: 0644,
@@ -463,16 +426,21 @@ func TestChunkFileRepositoryWithUUIDs(t *testing.T) {
GID: 1000, GID: 1000,
} }
mustCreateFileRow(t, repos, file1) err := repos.Files.Create(ctx, nil, file1)
mustCreateFileRow(t, repos, file2) if err != nil {
t.Fatalf("failed to create file1: %v", err)
}
err = repos.Files.Create(ctx, nil, file2)
if err != nil {
t.Fatalf("failed to create file2: %v", err)
}
// Create a chunk that appears in both files (deduplication) // Create a chunk that appears in both files (deduplication)
chunk := &Chunk{ chunk := &Chunk{
ChunkHash: types.ChunkHash("shared-chunk"), ChunkHash: types.ChunkHash("shared-chunk"),
Size: 1024, Size: 1024,
} }
err = repos.Chunks.Create(ctx, nil, chunk)
err := repos.Chunks.Create(ctx, nil, chunk)
if err != nil { if err != nil {
t.Fatalf("failed to create chunk: %v", err) t.Fatalf("failed to create chunk: %v", err)
} }
@@ -495,7 +463,6 @@ func TestChunkFileRepositoryWithUUIDs(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("failed to create chunk file 1: %v", err) t.Fatalf("failed to create chunk file 1: %v", err)
} }
err = repos.ChunkFiles.Create(ctx, nil, cf2) err = repos.ChunkFiles.Create(ctx, nil, cf2)
if err != nil { if err != nil {
t.Fatalf("failed to create chunk file 2: %v", err) t.Fatalf("failed to create chunk file 2: %v", err)
@@ -506,7 +473,6 @@ func TestChunkFileRepositoryWithUUIDs(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("failed to get chunk files: %v", err) t.Fatalf("failed to get chunk files: %v", err)
} }
if len(chunkFiles) != 2 { if len(chunkFiles) != 2 {
t.Errorf("expected 2 files for chunk, got %d", len(chunkFiles)) t.Errorf("expected 2 files for chunk, got %d", len(chunkFiles))
} }
@@ -516,7 +482,6 @@ func TestChunkFileRepositoryWithUUIDs(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("failed to get chunks by file ID: %v", err) t.Fatalf("failed to get chunks by file ID: %v", err)
} }
if len(chunkFiles) != 1 { if len(chunkFiles) != 1 {
t.Errorf("expected 1 chunk for file, got %d", len(chunkFiles)) t.Errorf("expected 1 chunk for file, got %d", len(chunkFiles))
} }
@@ -524,8 +489,6 @@ func TestChunkFileRepositoryWithUUIDs(t *testing.T) {
// TestSnapshotRepositoryExtendedFields tests snapshot with version and git revision // TestSnapshotRepositoryExtendedFields tests snapshot with version and git revision
func TestSnapshotRepositoryExtendedFields(t *testing.T) { func TestSnapshotRepositoryExtendedFields(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t) db, cleanup := setupTestDB(t)
defer cleanup() defer cleanup()
@@ -535,7 +498,7 @@ func TestSnapshotRepositoryExtendedFields(t *testing.T) {
// Create snapshot with extended fields // Create snapshot with extended fields
snapshot := &Snapshot{ snapshot := &Snapshot{
ID: "test-20250722-120000Z", ID: "test-20250722-120000Z",
Hostname: internalTestHost, Hostname: "test-host",
VaultikVersion: "0.0.1", VaultikVersion: "0.0.1",
VaultikGitRevision: "abc123def456", VaultikGitRevision: "abc123def456",
StartedAt: time.Now(), StartedAt: time.Now(),
@@ -563,39 +526,31 @@ func TestSnapshotRepositoryExtendedFields(t *testing.T) {
} }
if retrieved.VaultikVersion != snapshot.VaultikVersion { if retrieved.VaultikVersion != snapshot.VaultikVersion {
t.Errorf("version mismatch: expected %s, got %s", t.Errorf("version mismatch: expected %s, got %s", snapshot.VaultikVersion, retrieved.VaultikVersion)
snapshot.VaultikVersion, retrieved.VaultikVersion)
} }
if retrieved.VaultikGitRevision != snapshot.VaultikGitRevision { if retrieved.VaultikGitRevision != snapshot.VaultikGitRevision {
t.Errorf("git revision mismatch: expected %s, got %s", t.Errorf("git revision mismatch: expected %s, got %s", snapshot.VaultikGitRevision, retrieved.VaultikGitRevision)
snapshot.VaultikGitRevision, retrieved.VaultikGitRevision)
} }
if retrieved.CompressionLevel != snapshot.CompressionLevel { if retrieved.CompressionLevel != snapshot.CompressionLevel {
t.Errorf("compression level mismatch: expected %d, got %d", t.Errorf("compression level mismatch: expected %d, got %d", snapshot.CompressionLevel, retrieved.CompressionLevel)
snapshot.CompressionLevel, retrieved.CompressionLevel)
} }
if retrieved.BlobUncompressedSize != snapshot.BlobUncompressedSize { if retrieved.BlobUncompressedSize != snapshot.BlobUncompressedSize {
t.Errorf("uncompressed size mismatch: expected %d, got %d", t.Errorf("uncompressed size mismatch: expected %d, got %d", snapshot.BlobUncompressedSize, retrieved.BlobUncompressedSize)
snapshot.BlobUncompressedSize, retrieved.BlobUncompressedSize)
} }
if retrieved.UploadDurationMs != snapshot.UploadDurationMs { if retrieved.UploadDurationMs != snapshot.UploadDurationMs {
t.Errorf("upload duration mismatch: expected %d, got %d", t.Errorf("upload duration mismatch: expected %d, got %d", snapshot.UploadDurationMs, retrieved.UploadDurationMs)
snapshot.UploadDurationMs, retrieved.UploadDurationMs)
} }
} }
// TestComplexOrphanedDataScenario tests a complex scenario with multiple relationships // TestComplexOrphanedDataScenario tests a complex scenario with multiple relationships
// createOrphanScenarioFixtures creates two snapshots and three files for func TestComplexOrphanedDataScenario(t *testing.T) {
// the orphaned-data cleanup scenario. db, cleanup := setupTestDB(t)
func createOrphanScenarioFixtures( defer cleanup()
ctx context.Context, t *testing.T, repos *Repositories,
) (*Snapshot, *Snapshot, []*File) {
t.Helper()
ctx := context.Background()
repos := NewRepositories(db)
// Create snapshots
snapshot1 := &Snapshot{ snapshot1 := &Snapshot{
ID: "snapshot1", ID: "snapshot1",
Hostname: "host1", Hostname: "host1",
@@ -611,7 +566,6 @@ func createOrphanScenarioFixtures(
if err != nil { if err != nil {
t.Fatalf("failed to create snapshot1: %v", err) t.Fatalf("failed to create snapshot1: %v", err)
} }
err = repos.Snapshots.Create(ctx, nil, snapshot2) err = repos.Snapshots.Create(ctx, nil, snapshot2)
if err != nil { if err != nil {
t.Fatalf("failed to create snapshot2: %v", err) t.Fatalf("failed to create snapshot2: %v", err)
@@ -628,44 +582,40 @@ func createOrphanScenarioFixtures(
UID: 1000, UID: 1000,
GID: 1000, GID: 1000,
} }
err = repos.Files.Create(ctx, nil, files[i]) err = repos.Files.Create(ctx, nil, files[i])
if err != nil { if err != nil {
t.Fatalf("failed to create file%d: %v", i, err) t.Fatalf("failed to create file%d: %v", i, err)
} }
} }
return snapshot1, snapshot2, files
}
func TestComplexOrphanedDataScenario(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
repos := NewRepositories(db)
snapshot1, snapshot2, files := createOrphanScenarioFixtures(ctx, t, repos)
// Add files to snapshots // Add files to snapshots
// Snapshot1: file0, file1 // Snapshot1: file0, file1
// Snapshot2: file1, file2 // Snapshot2: file1, file2
// file0: only in snapshot1 // file0: only in snapshot1
// file1: in both snapshots // file1: in both snapshots
// file2: only in snapshot2 // file2: only in snapshot2
mustAddFileToSnapshot(t, repos, snapshot1.ID.String(), files[0].ID) err = repos.Snapshots.AddFileByID(ctx, nil, snapshot1.ID.String(), files[0].ID)
mustAddFileToSnapshot(t, repos, snapshot1.ID.String(), files[1].ID) if err != nil {
mustAddFileToSnapshot(t, repos, snapshot2.ID.String(), files[1].ID) t.Fatal(err)
mustAddFileToSnapshot(t, repos, snapshot2.ID.String(), files[2].ID) }
err = repos.Snapshots.AddFileByID(ctx, nil, snapshot1.ID.String(), files[1].ID)
// Delete snapshot1 if err != nil {
err := repos.Snapshots.DeleteSnapshotFiles(ctx, snapshot1.ID.String()) t.Fatal(err)
}
err = repos.Snapshots.AddFileByID(ctx, nil, snapshot2.ID.String(), files[1].ID)
if err != nil {
t.Fatal(err)
}
err = repos.Snapshots.AddFileByID(ctx, nil, snapshot2.ID.String(), files[2].ID)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
// Delete snapshot1
err = repos.Snapshots.DeleteSnapshotFiles(ctx, snapshot1.ID.String())
if err != nil {
t.Fatal(err)
}
err = repos.Snapshots.Delete(ctx, snapshot1.ID.String()) err = repos.Snapshots.Delete(ctx, snapshot1.ID.String())
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -683,7 +633,6 @@ func TestComplexOrphanedDataScenario(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("error getting file0: %v", err) t.Fatalf("error getting file0: %v", err)
} }
if file0 != nil { if file0 != nil {
t.Error("file0 should have been deleted") t.Error("file0 should have been deleted")
} }
@@ -693,7 +642,6 @@ func TestComplexOrphanedDataScenario(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("error getting file1: %v", err) t.Fatalf("error getting file1: %v", err)
} }
if file1 == nil { if file1 == nil {
t.Error("file1 should still exist") t.Error("file1 should still exist")
} }
@@ -703,7 +651,6 @@ func TestComplexOrphanedDataScenario(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("error getting file2: %v", err) t.Fatalf("error getting file2: %v", err)
} }
if file2 == nil { if file2 == nil {
t.Error("file2 should still exist") t.Error("file2 should still exist")
} }
@@ -711,8 +658,6 @@ func TestComplexOrphanedDataScenario(t *testing.T) {
// TestCascadeDelete tests that cascade deletes work properly // TestCascadeDelete tests that cascade deletes work properly
func TestCascadeDelete(t *testing.T) { func TestCascadeDelete(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t) db, cleanup := setupTestDB(t)
defer cleanup() defer cleanup()
@@ -728,19 +673,17 @@ func TestCascadeDelete(t *testing.T) {
UID: 1000, UID: 1000,
GID: 1000, GID: 1000,
} }
err := repos.Files.Create(ctx, nil, file) err := repos.Files.Create(ctx, nil, file)
if err != nil { if err != nil {
t.Fatalf("failed to create file: %v", err) t.Fatalf("failed to create file: %v", err)
} }
// Create chunks and file-chunk mappings // Create chunks and file-chunk mappings
for i := range 3 { for i := 0; i < 3; i++ {
chunk := &Chunk{ chunk := &Chunk{
ChunkHash: types.ChunkHash(fmt.Sprintf("cascade-chunk-%d", i)), ChunkHash: types.ChunkHash(fmt.Sprintf("cascade-chunk-%d", i)),
Size: 1024, Size: 1024,
} }
err = repos.Chunks.Create(ctx, nil, chunk) err = repos.Chunks.Create(ctx, nil, chunk)
if err != nil { if err != nil {
t.Fatalf("failed to create chunk: %v", err) t.Fatalf("failed to create chunk: %v", err)
@@ -751,7 +694,6 @@ func TestCascadeDelete(t *testing.T) {
Idx: i, Idx: i,
ChunkHash: chunk.ChunkHash, ChunkHash: chunk.ChunkHash,
} }
err = repos.FileChunks.Create(ctx, nil, fc) err = repos.FileChunks.Create(ctx, nil, fc)
if err != nil { if err != nil {
t.Fatalf("failed to create file chunk: %v", err) t.Fatalf("failed to create file chunk: %v", err)
@@ -763,7 +705,6 @@ func TestCascadeDelete(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(fileChunks) != 3 { if len(fileChunks) != 3 {
t.Errorf("expected 3 file chunks, got %d", len(fileChunks)) t.Errorf("expected 3 file chunks, got %d", len(fileChunks))
} }
@@ -779,7 +720,6 @@ func TestCascadeDelete(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(fileChunks) != 0 { if len(fileChunks) != 0 {
t.Errorf("expected 0 file chunks after cascade delete, got %d", len(fileChunks)) t.Errorf("expected 0 file chunks after cascade delete, got %d", len(fileChunks))
} }
@@ -787,8 +727,6 @@ func TestCascadeDelete(t *testing.T) {
// TestTransactionIsolation tests that transactions properly isolate changes // TestTransactionIsolation tests that transactions properly isolate changes
func TestTransactionIsolation(t *testing.T) { func TestTransactionIsolation(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t) db, cleanup := setupTestDB(t)
defer cleanup() defer cleanup()
@@ -806,7 +744,6 @@ func TestTransactionIsolation(t *testing.T) {
UID: 1000, UID: 1000,
GID: 1000, GID: 1000,
} }
err := repos.Files.Create(ctx, tx, file) err := repos.Files.Create(ctx, tx, file)
if err != nil { if err != nil {
return err return err
@@ -817,8 +754,9 @@ func TestTransactionIsolation(t *testing.T) {
// For now, we'll just test that rollback works // For now, we'll just test that rollback works
// Return an error to trigger rollback // Return an error to trigger rollback
return errTxIntentionalRollback return fmt.Errorf("intentional rollback")
}) })
if err == nil { if err == nil {
t.Fatal("expected error from transaction") t.Fatal("expected error from transaction")
} }
@@ -828,22 +766,37 @@ func TestTransactionIsolation(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(files) != 0 { if len(files) != 0 {
t.Error("file should not exist after rollback") t.Error("file should not exist after rollback")
} }
} }
// TestConcurrentOrphanedCleanup tests that concurrent cleanup operations // TestConcurrentOrphanedCleanup tests that concurrent cleanup operations don't interfere
// don't interfere. func TestConcurrentOrphanedCleanup(t *testing.T) {
// createConcurrentCleanupFiles creates 20 files and associates the db, cleanup := setupTestDB(t)
// even-numbered ones with the snapshot, leaving the rest orphaned. defer cleanup()
func createConcurrentCleanupFiles(
ctx context.Context, t *testing.T, repos *Repositories, snapshotID string,
) {
t.Helper()
for i := range 20 { ctx := context.Background()
repos := NewRepositories(db)
// Set a 5-second busy timeout to handle concurrent operations
if _, err := db.conn.Exec("PRAGMA busy_timeout = 5000"); err != nil {
t.Fatalf("failed to set busy timeout: %v", err)
}
// Create a snapshot
snapshot := &Snapshot{
ID: "concurrent-test",
Hostname: "test-host",
StartedAt: time.Now(),
}
err := repos.Snapshots.Create(ctx, nil, snapshot)
if err != nil {
t.Fatal(err)
}
// Create many files, some orphaned
for i := 0; i < 20; i++ {
file := &File{ file := &File{
Path: types.FilePath(fmt.Sprintf("/concurrent-%d.txt", i)), Path: types.FilePath(fmt.Sprintf("/concurrent-%d.txt", i)),
MTime: time.Now().Truncate(time.Second), MTime: time.Now().Truncate(time.Second),
@@ -852,63 +805,31 @@ func createConcurrentCleanupFiles(
UID: 1000, UID: 1000,
GID: 1000, GID: 1000,
} }
err = repos.Files.Create(ctx, nil, file)
err := repos.Files.Create(ctx, nil, file)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
// Add even-numbered files to snapshot // Add even-numbered files to snapshot
if i%2 == 0 { if i%2 == 0 {
err = repos.Snapshots.AddFileByID(ctx, nil, snapshotID, file.ID) err = repos.Snapshots.AddFileByID(ctx, nil, snapshot.ID.String(), file.ID)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
} }
} }
}
func TestConcurrentOrphanedCleanup(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
repos := NewRepositories(db)
// Set a 5-second busy timeout to handle concurrent operations
_, err := db.conn.ExecContext(ctx, "PRAGMA busy_timeout = 5000")
if err != nil {
t.Fatalf("failed to set busy timeout: %v", err)
}
// Create a snapshot
snapshot := &Snapshot{
ID: "concurrent-test",
Hostname: internalTestHost,
StartedAt: time.Now(),
}
err = repos.Snapshots.Create(ctx, nil, snapshot)
if err != nil {
t.Fatal(err)
}
createConcurrentCleanupFiles(ctx, t, repos, snapshot.ID.String())
// Run multiple cleanup operations concurrently // Run multiple cleanup operations concurrently
// Note: SQLite has limited support for concurrent writes, so we expect some to fail // Note: SQLite has limited support for concurrent writes, so we expect some to fail
done := make(chan error, 3) done := make(chan error, 3)
for i := 0; i < 3; i++ {
for range 3 {
go func() { go func() {
done <- repos.Files.DeleteOrphaned(ctx) done <- repos.Files.DeleteOrphaned(ctx)
}() }()
} }
// Wait for all to complete // Wait for all to complete
for i := range 3 { for i := 0; i < 3; i++ {
err := <-done err := <-done
if err != nil { if err != nil {
t.Errorf("cleanup %d failed: %v", i, err) t.Errorf("cleanup %d failed: %v", i, err)
@@ -929,12 +850,10 @@ func TestConcurrentOrphanedCleanup(t *testing.T) {
// Verify all remaining files are even-numbered // Verify all remaining files are even-numbered
for _, file := range files { for _, file := range files {
var num int var num int
_, err := fmt.Sscanf(file.Path.String(), "/concurrent-%d.txt", &num) _, err := fmt.Sscanf(file.Path.String(), "/concurrent-%d.txt", &num)
if err != nil { if err != nil {
t.Logf("failed to parse file number from %s: %v", file.Path, err) t.Logf("failed to parse file number from %s: %v", file.Path, err)
} }
if num%2 != 0 { if num%2 != 0 {
t.Errorf("odd-numbered file %s should have been deleted", file.Path) t.Errorf("odd-numbered file %s should have been deleted", file.Path)
} }

View File

@@ -1,4 +1,3 @@
//nolint:testpackage // inspects the unexported database connection
package database package database
import ( import (
@@ -7,51 +6,15 @@ import (
"time" "time"
) )
// logSnapshotFileIDs logs every file_id present in snapshot_files. // TestOrphanedFileCleanupDebug tests orphaned file cleanup with debug output
func logSnapshotFileIDs(t *testing.T, db *DB) { func TestOrphanedFileCleanupDebug(t *testing.T) {
t.Helper() db, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background() ctx := context.Background()
repos := NewRepositories(db)
rows, err := db.conn.QueryContext(ctx, "SELECT file_id FROM snapshot_files") // Create files
if err != nil {
t.Fatal(err)
}
defer func() {
err := rows.Close()
if err != nil {
t.Logf("failed to close rows: %v", err)
}
}()
t.Log("Files in snapshot_files:")
for rows.Next() {
var fileID string
err := rows.Scan(&fileID)
if err != nil {
t.Fatal(err)
}
t.Logf(" - %s", fileID)
}
err = rows.Err()
if err != nil {
t.Fatal(err)
}
}
// TestOrphanedFileCleanupDebug tests orphaned file cleanup with debug output
// createOrphanDebugFixtures creates one orphaned file, one referenced
// file, and the snapshot that will reference the latter.
func createOrphanDebugFixtures(
ctx context.Context, t *testing.T, repos *Repositories,
) (*File, *File, *Snapshot) {
t.Helper()
file1 := &File{ file1 := &File{
Path: "/orphaned.txt", Path: "/orphaned.txt",
MTime: time.Now().Truncate(time.Second), MTime: time.Now().Truncate(time.Second),
@@ -73,65 +36,72 @@ func createOrphanDebugFixtures(
if err != nil { if err != nil {
t.Fatalf("failed to create file1: %v", err) t.Fatalf("failed to create file1: %v", err)
} }
t.Logf("Created file1 with ID: %s", file1.ID) t.Logf("Created file1 with ID: %s", file1.ID)
err = repos.Files.Create(ctx, nil, file2) err = repos.Files.Create(ctx, nil, file2)
if err != nil { if err != nil {
t.Fatalf("failed to create file2: %v", err) t.Fatalf("failed to create file2: %v", err)
} }
t.Logf("Created file2 with ID: %s", file2.ID) t.Logf("Created file2 with ID: %s", file2.ID)
// Create a snapshot and reference only file2 // Create a snapshot and reference only file2
snapshot := &Snapshot{ snapshot := &Snapshot{
ID: internalTestSnapshotID, ID: "test-snapshot",
Hostname: internalTestHost, Hostname: "test-host",
StartedAt: time.Now(), StartedAt: time.Now(),
} }
err = repos.Snapshots.Create(ctx, nil, snapshot) err = repos.Snapshots.Create(ctx, nil, snapshot)
if err != nil { if err != nil {
t.Fatalf("failed to create snapshot: %v", err) t.Fatalf("failed to create snapshot: %v", err)
} }
t.Logf("Created snapshot: %s", snapshot.ID) t.Logf("Created snapshot: %s", snapshot.ID)
return file1, file2, snapshot
}
func TestOrphanedFileCleanupDebug(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
repos := NewRepositories(db)
file1, file2, snapshot := createOrphanDebugFixtures(ctx, t, repos)
// Check snapshot_files before adding // Check snapshot_files before adding
count := countRow(t, db, "SELECT COUNT(*) FROM snapshot_files") var count int
err = db.conn.QueryRow("SELECT COUNT(*) FROM snapshot_files").Scan(&count)
if err != nil {
t.Fatal(err)
}
t.Logf("snapshot_files count before add: %d", count) t.Logf("snapshot_files count before add: %d", count)
// Add file2 to snapshot // Add file2 to snapshot
err := repos.Snapshots.AddFileByID(ctx, nil, snapshot.ID.String(), file2.ID) err = repos.Snapshots.AddFileByID(ctx, nil, snapshot.ID.String(), file2.ID)
if err != nil { if err != nil {
t.Fatalf("failed to add file to snapshot: %v", err) t.Fatalf("failed to add file to snapshot: %v", err)
} }
t.Logf("Added file2 to snapshot") t.Logf("Added file2 to snapshot")
// Check snapshot_files after adding // Check snapshot_files after adding
count = countRow(t, db, "SELECT COUNT(*) FROM snapshot_files") err = db.conn.QueryRow("SELECT COUNT(*) FROM snapshot_files").Scan(&count)
if err != nil {
t.Fatal(err)
}
t.Logf("snapshot_files count after add: %d", count) t.Logf("snapshot_files count after add: %d", count)
// Check which files are referenced // Check which files are referenced
logSnapshotFileIDs(t, db) rows, err := db.conn.Query("SELECT file_id FROM snapshot_files")
if err != nil {
t.Fatal(err)
}
defer func() {
if err := rows.Close(); err != nil {
t.Logf("failed to close rows: %v", err)
}
}()
t.Log("Files in snapshot_files:")
for rows.Next() {
var fileID string
if err := rows.Scan(&fileID); err != nil {
t.Fatal(err)
}
t.Logf(" - %s", fileID)
}
// Check files before cleanup // Check files before cleanup
count = countRow(t, db, countFilesQuery) err = db.conn.QueryRow("SELECT COUNT(*) FROM files").Scan(&count)
if err != nil {
t.Fatal(err)
}
t.Logf("Files count before cleanup: %d", count) t.Logf("Files count before cleanup: %d", count)
// Run orphaned cleanup // Run orphaned cleanup
@@ -139,11 +109,13 @@ func TestOrphanedFileCleanupDebug(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("failed to delete orphaned files: %v", err) t.Fatalf("failed to delete orphaned files: %v", err)
} }
t.Log("Ran orphaned cleanup") t.Log("Ran orphaned cleanup")
// Check files after cleanup // Check files after cleanup
count = countRow(t, db, countFilesQuery) err = db.conn.QueryRow("SELECT COUNT(*) FROM files").Scan(&count)
if err != nil {
t.Fatal(err)
}
t.Logf("Files count after cleanup: %d", count) t.Logf("Files count after cleanup: %d", count)
// List remaining files // List remaining files
@@ -151,9 +123,7 @@ func TestOrphanedFileCleanupDebug(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
t.Log("Remaining files:") t.Log("Remaining files:")
for _, f := range files { for _, f := range files {
t.Logf(" - ID: %s, Path: %s", f.ID, f.Path) t.Logf(" - ID: %s, Path: %s", f.ID, f.Path)
} }
@@ -163,16 +133,19 @@ func TestOrphanedFileCleanupDebug(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("error getting file: %v", err) t.Fatalf("error getting file: %v", err)
} }
if orphanedFile != nil { if orphanedFile != nil {
t.Error("orphaned file should have been deleted") t.Error("orphaned file should have been deleted")
// Let's check why it wasn't deleted // Let's check why it wasn't deleted
stillReferenced := countRow(t, db, ` var exists bool
err = db.conn.QueryRow(`
SELECT EXISTS( SELECT EXISTS(
SELECT 1 FROM snapshot_files SELECT 1 FROM snapshot_files
WHERE file_id = ? WHERE file_id = ?
)`, file1.ID) )`, file1.ID).Scan(&exists)
t.Logf("File1 exists in snapshot_files: %v", stillReferenced != 0) if err != nil {
t.Fatal(err)
}
t.Logf("File1 exists in snapshot_files: %v", exists)
} else { } else {
t.Log("Orphaned file was correctly deleted") t.Log("Orphaned file was correctly deleted")
} }
@@ -182,7 +155,6 @@ func TestOrphanedFileCleanupDebug(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("error getting file: %v", err) t.Fatalf("error getting file: %v", err)
} }
if referencedFile == nil { if referencedFile == nil {
t.Error("referenced file should not have been deleted") t.Error("referenced file should not have been deleted")
} else { } else {

View File

@@ -1,4 +1,3 @@
//nolint:testpackage // inspects the unexported database connection
package database package database
import ( import (
@@ -8,20 +7,23 @@ import (
"testing" "testing"
"time" "time"
"sneak.berlin/go/vaultik/internal/types" "git.eeqj.de/sneak/vaultik/internal/types"
) )
// fileEdgeCase describes one Create edge-case scenario. // TestFileRepositoryEdgeCases tests edge cases for file repository
type fileEdgeCase struct { func TestFileRepositoryEdgeCases(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
repo := NewFileRepository(db)
tests := []struct {
name string name string
file *File file *File
wantErr bool wantErr bool
errMsg string errMsg string
} }{
// fileEdgeCases returns the Create edge-case table.
func fileEdgeCases() []fileEdgeCase {
return []fileEdgeCase{
{ {
name: "empty path", name: "empty path",
file: &File{ file: &File{
@@ -49,7 +51,6 @@ func fileEdgeCases() []fileEdgeCase {
{ {
name: "path with special characters", name: "path with special characters",
file: &File{ file: &File{
//nolint:gosmopolitan // non-ASCII path is deliberate test data
Path: "/test/file with spaces and 特殊文字.txt", Path: "/test/file with spaces and 特殊文字.txt",
MTime: time.Now(), MTime: time.Now(),
Size: 1024, Size: 1024,
@@ -85,33 +86,18 @@ func fileEdgeCases() []fileEdgeCase {
wantErr: false, wantErr: false,
}, },
} }
}
// TestFileRepositoryEdgeCases tests edge cases for file repository for i, tt := range tests {
func TestFileRepositoryEdgeCases(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t)
t.Cleanup(cleanup)
ctx := context.Background()
repo := NewFileRepository(db)
for i, tt := range fileEdgeCases() {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// Add a unique suffix to paths to avoid UNIQUE constraint violations // Add a unique suffix to paths to avoid UNIQUE constraint violations
if tt.file.Path != "" { if tt.file.Path != "" {
tt.file.Path = types.FilePath(fmt.Sprintf("%s_%d_%d", tt.file.Path = types.FilePath(fmt.Sprintf("%s_%d_%d", tt.file.Path, i, time.Now().UnixNano()))
tt.file.Path, i, time.Now().UnixNano()))
} }
err := repo.Create(ctx, nil, tt.file) err := repo.Create(ctx, nil, tt.file)
if (err != nil) != tt.wantErr { if (err != nil) != tt.wantErr {
t.Errorf("Create() error = %v, wantErr %v", err, tt.wantErr) t.Errorf("Create() error = %v, wantErr %v", err, tt.wantErr)
} }
if err != nil && tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { if err != nil && tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("Create() error = %v, want error containing %q", err, tt.errMsg) t.Errorf("Create() error = %v, want error containing %q", err, tt.errMsg)
} }
@@ -119,12 +105,16 @@ func TestFileRepositoryEdgeCases(t *testing.T) {
} }
} }
// testDuplicateFilePaths exercises the UPSERT behavior for duplicate paths. // TestDuplicateHandling tests handling of duplicate entries
func testDuplicateFilePaths(t *testing.T, repos *Repositories) { func TestDuplicateHandling(t *testing.T) {
t.Helper() db, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background() ctx := context.Background()
repos := NewRepositories(db)
// Test duplicate file paths - Create uses UPSERT logic
t.Run("duplicate file paths", func(t *testing.T) {
file1 := &File{ file1 := &File{
Path: "/duplicate.txt", Path: "/duplicate.txt",
MTime: time.Now(), MTime: time.Now(),
@@ -146,7 +136,6 @@ func testDuplicateFilePaths(t *testing.T, repos *Repositories) {
if err != nil { if err != nil {
t.Fatalf("failed to create file1: %v", err) t.Fatalf("failed to create file1: %v", err)
} }
originalID := file1.ID originalID := file1.ID
// Create with same path should update the existing record (UPSERT behavior) // Create with same path should update the existing record (UPSERT behavior)
@@ -168,17 +157,31 @@ func testDuplicateFilePaths(t *testing.T, repos *Repositories) {
// ID might be different due to the UPSERT // ID might be different due to the UPSERT
if retrievedFile.ID != file2.ID { if retrievedFile.ID != file2.ID {
t.Logf("File ID changed from %s to %s during upsert", t.Logf("File ID changed from %s to %s during upsert", originalID, retrievedFile.ID)
originalID, retrievedFile.ID)
} }
} })
// testDuplicateFileChunks exercises idempotent file-chunk mapping creation. // Test duplicate chunk hashes
func testDuplicateFileChunks(t *testing.T, repos *Repositories) { t.Run("duplicate chunk hashes", func(t *testing.T) {
t.Helper() chunk := &Chunk{
ChunkHash: types.ChunkHash("duplicate-chunk"),
Size: 1024,
}
ctx := context.Background() err := repos.Chunks.Create(ctx, nil, chunk)
if err != nil {
t.Fatalf("failed to create chunk: %v", err)
}
// Creating the same chunk again should be idempotent (ON CONFLICT DO NOTHING)
err = repos.Chunks.Create(ctx, nil, chunk)
if err != nil {
t.Errorf("duplicate chunk creation should be idempotent, got error: %v", err)
}
})
// Test duplicate file-chunk mappings
t.Run("duplicate file-chunk mappings", func(t *testing.T) {
file := &File{ file := &File{
Path: "/test-dup-fc.txt", Path: "/test-dup-fc.txt",
MTime: time.Now(), MTime: time.Now(),
@@ -187,7 +190,6 @@ func testDuplicateFileChunks(t *testing.T, repos *Repositories) {
UID: 1000, UID: 1000,
GID: 1000, GID: 1000,
} }
err := repos.Files.Create(ctx, nil, file) err := repos.Files.Create(ctx, nil, file)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -197,7 +199,6 @@ func testDuplicateFileChunks(t *testing.T, repos *Repositories) {
ChunkHash: types.ChunkHash("test-chunk-dup"), ChunkHash: types.ChunkHash("test-chunk-dup"),
Size: 1024, Size: 1024,
} }
err = repos.Chunks.Create(ctx, nil, chunk) err = repos.Chunks.Create(ctx, nil, chunk)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -219,66 +220,19 @@ func testDuplicateFileChunks(t *testing.T, repos *Repositories) {
if err != nil { if err != nil {
t.Error("file-chunk creation should be idempotent") t.Error("file-chunk creation should be idempotent")
} }
}
// TestDuplicateHandling tests handling of duplicate entries
func TestDuplicateHandling(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t)
t.Cleanup(cleanup)
ctx := context.Background()
repos := NewRepositories(db)
// Test duplicate file paths - Create uses UPSERT logic
t.Run("duplicate file paths", func(t *testing.T) {
t.Parallel()
testDuplicateFilePaths(t, repos)
})
// Test duplicate chunk hashes
t.Run("duplicate chunk hashes", func(t *testing.T) {
t.Parallel()
chunk := &Chunk{
ChunkHash: types.ChunkHash("duplicate-chunk"),
Size: 1024,
}
err := repos.Chunks.Create(ctx, nil, chunk)
if err != nil {
t.Fatalf("failed to create chunk: %v", err)
}
// Creating the same chunk again should be idempotent (ON CONFLICT DO NOTHING)
err = repos.Chunks.Create(ctx, nil, chunk)
if err != nil {
t.Errorf("duplicate chunk creation should be idempotent, got error: %v", err)
}
})
// Test duplicate file-chunk mappings
t.Run("duplicate file-chunk mappings", func(t *testing.T) {
t.Parallel()
testDuplicateFileChunks(t, repos)
}) })
} }
// TestNullHandling tests handling of NULL values // TestNullHandling tests handling of NULL values
func TestNullHandling(t *testing.T) { func TestNullHandling(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t) db, cleanup := setupTestDB(t)
t.Cleanup(cleanup) defer cleanup()
ctx := context.Background() ctx := context.Background()
repos := NewRepositories(db) repos := NewRepositories(db)
// Test file with no link target // Test file with no link target
t.Run("file without link target", func(t *testing.T) { t.Run("file without link target", func(t *testing.T) {
t.Parallel()
file := &File{ file := &File{
Path: "/regular.txt", Path: "/regular.txt",
MTime: time.Now(), MTime: time.Now(),
@@ -306,11 +260,9 @@ func TestNullHandling(t *testing.T) {
// Test snapshot with NULL completed_at // Test snapshot with NULL completed_at
t.Run("incomplete snapshot", func(t *testing.T) { t.Run("incomplete snapshot", func(t *testing.T) {
t.Parallel()
snapshot := &Snapshot{ snapshot := &Snapshot{
ID: "incomplete-test", ID: "incomplete-test",
Hostname: internalTestHost, Hostname: "test-host",
StartedAt: time.Now(), StartedAt: time.Now(),
CompletedAt: nil, // Should remain NULL until completed CompletedAt: nil, // Should remain NULL until completed
} }
@@ -332,18 +284,6 @@ func TestNullHandling(t *testing.T) {
// Test blob with NULL uploaded_ts // Test blob with NULL uploaded_ts
t.Run("blob not uploaded", func(t *testing.T) { t.Run("blob not uploaded", func(t *testing.T) {
t.Parallel()
verifyBlobNullUploadTS(ctx, t, repos)
})
}
// verifyBlobNullUploadTS checks that a blob created without an upload
// timestamp round-trips with UploadedTS nil.
func verifyBlobNullUploadTS(
ctx context.Context, t *testing.T, repos *Repositories,
) {
t.Helper()
blob := &Blob{ blob := &Blob{
ID: types.NewBlobID(), ID: types.NewBlobID(),
Hash: types.BlobHash("test-hash"), Hash: types.BlobHash("test-hash"),
@@ -364,54 +304,11 @@ func verifyBlobNullUploadTS(
if retrieved.UploadedTS != nil { if retrieved.UploadedTS != nil {
t.Error("expected nil UploadedTS for non-uploaded blob") t.Error("expected nil UploadedTS for non-uploaded blob")
} }
} })
// createLargeDatasetFiles creates fileCount files and adds every other
// one to the snapshot.
func createLargeDatasetFiles(
t *testing.T,
repos *Repositories,
snapshotID string,
fileCount int,
) {
t.Helper()
ctx := context.Background()
start := time.Now()
for i := range fileCount {
file := &File{
Path: types.FilePath(fmt.Sprintf("/large/file%05d.txt", i)),
MTime: time.Now(),
Size: int64(i * 1024),
Mode: 0644,
UID: uint32(1000 + (i % 10)),
GID: uint32(1000 + (i % 10)),
}
err := repos.Files.Create(ctx, nil, file)
if err != nil {
t.Fatalf("failed to create file %d: %v", i, err)
}
// Add half to snapshot
if i%2 == 0 {
err = repos.Snapshots.AddFileByID(ctx, nil, snapshotID, file.ID)
if err != nil {
t.Fatal(err)
}
}
}
t.Logf("Created %d files in %v", fileCount, time.Since(start))
} }
// TestLargeDatasets tests operations with large amounts of data // TestLargeDatasets tests operations with large amounts of data
//
//nolint:tparallel // subtests share one database and are order-dependent
func TestLargeDatasets(t *testing.T) { func TestLargeDatasets(t *testing.T) {
t.Parallel()
if testing.Short() { if testing.Short() {
t.Skip("skipping large dataset test in short mode") t.Skip("skipping large dataset test in short mode")
} }
@@ -425,10 +322,9 @@ func TestLargeDatasets(t *testing.T) {
// Create a snapshot // Create a snapshot
snapshot := &Snapshot{ snapshot := &Snapshot{
ID: "large-dataset-test", ID: "large-dataset-test",
Hostname: internalTestHost, Hostname: "test-host",
StartedAt: time.Now(), StartedAt: time.Now(),
} }
err := repos.Snapshots.Create(ctx, nil, snapshot) err := repos.Snapshots.Create(ctx, nil, snapshot)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -436,39 +332,56 @@ func TestLargeDatasets(t *testing.T) {
// Create many files // Create many files
const fileCount = 1000 const fileCount = 1000
fileIDs := make([]types.FileID, fileCount)
//nolint:paralleltest // phases share one database and are order-dependent
t.Run("create many files", func(t *testing.T) { t.Run("create many files", func(t *testing.T) {
createLargeDatasetFiles(t, repos, snapshot.ID.String(), fileCount) start := time.Now()
for i := 0; i < fileCount; i++ {
file := &File{
Path: types.FilePath(fmt.Sprintf("/large/file%05d.txt", i)),
MTime: time.Now(),
Size: int64(i * 1024),
Mode: 0644,
UID: uint32(1000 + (i % 10)),
GID: uint32(1000 + (i % 10)),
}
err := repos.Files.Create(ctx, nil, file)
if err != nil {
t.Fatalf("failed to create file %d: %v", i, err)
}
fileIDs[i] = file.ID
// Add half to snapshot
if i%2 == 0 {
err = repos.Snapshots.AddFileByID(ctx, nil, snapshot.ID.String(), file.ID)
if err != nil {
t.Fatal(err)
}
}
}
t.Logf("Created %d files in %v", fileCount, time.Since(start))
}) })
// Test ListByPrefix performance // Test ListByPrefix performance
//nolint:paralleltest // phases share one database and are order-dependent
t.Run("list by prefix performance", func(t *testing.T) { t.Run("list by prefix performance", func(t *testing.T) {
start := time.Now() start := time.Now()
files, err := repos.Files.ListByPrefix(ctx, "/large/") files, err := repos.Files.ListByPrefix(ctx, "/large/")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(files) != fileCount { if len(files) != fileCount {
t.Errorf("expected %d files, got %d", fileCount, len(files)) t.Errorf("expected %d files, got %d", fileCount, len(files))
} }
t.Logf("Listed %d files in %v", len(files), time.Since(start)) t.Logf("Listed %d files in %v", len(files), time.Since(start))
}) })
// Test orphaned cleanup performance // Test orphaned cleanup performance
//nolint:paralleltest // phases share one database and are order-dependent
t.Run("orphaned cleanup performance", func(t *testing.T) { t.Run("orphaned cleanup performance", func(t *testing.T) {
start := time.Now() start := time.Now()
err := repos.Files.DeleteOrphaned(ctx) err := repos.Files.DeleteOrphaned(ctx)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
t.Logf("Cleaned up orphaned files in %v", time.Since(start)) t.Logf("Cleaned up orphaned files in %v", time.Since(start))
// Verify correct number remain // Verify correct number remain
@@ -476,33 +389,26 @@ func TestLargeDatasets(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(files) != fileCount/2 { if len(files) != fileCount/2 {
t.Errorf("expected %d files after cleanup, got %d", t.Errorf("expected %d files after cleanup, got %d", fileCount/2, len(files))
fileCount/2, len(files))
} }
}) })
} }
// TestErrorPropagation tests that errors are properly propagated // TestErrorPropagation tests that errors are properly propagated
func TestErrorPropagation(t *testing.T) { func TestErrorPropagation(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t) db, cleanup := setupTestDB(t)
t.Cleanup(cleanup) defer cleanup()
ctx := context.Background() ctx := context.Background()
repos := NewRepositories(db) repos := NewRepositories(db)
// Test GetByID with non-existent ID // Test GetByID with non-existent ID
t.Run("GetByID non-existent", func(t *testing.T) { t.Run("GetByID non-existent", func(t *testing.T) {
t.Parallel()
file, err := repos.Files.GetByID(ctx, types.NewFileID()) file, err := repos.Files.GetByID(ctx, types.NewFileID())
if err != nil { if err != nil {
t.Errorf("GetByID should not return error for non-existent ID, got: %v", err) t.Errorf("GetByID should not return error for non-existent ID, got: %v", err)
} }
if file != nil { if file != nil {
t.Error("expected nil file for non-existent ID") t.Error("expected nil file for non-existent ID")
} }
@@ -510,14 +416,10 @@ func TestErrorPropagation(t *testing.T) {
// Test GetByPath with non-existent path // Test GetByPath with non-existent path
t.Run("GetByPath non-existent", func(t *testing.T) { t.Run("GetByPath non-existent", func(t *testing.T) {
t.Parallel()
file, err := repos.Files.GetByPath(ctx, "/non/existent/path.txt") file, err := repos.Files.GetByPath(ctx, "/non/existent/path.txt")
if err != nil { if err != nil {
t.Errorf("GetByPath should not return error for non-existent path, got: %v", t.Errorf("GetByPath should not return error for non-existent path, got: %v", err)
err)
} }
if file != nil { if file != nil {
t.Error("expected nil file for non-existent path") t.Error("expected nil file for non-existent path")
} }
@@ -525,19 +427,15 @@ func TestErrorPropagation(t *testing.T) {
// Test invalid foreign key reference // Test invalid foreign key reference
t.Run("invalid foreign key", func(t *testing.T) { t.Run("invalid foreign key", func(t *testing.T) {
t.Parallel()
fc := &FileChunk{ fc := &FileChunk{
FileID: types.NewFileID(), FileID: types.NewFileID(),
Idx: 0, Idx: 0,
ChunkHash: types.ChunkHash("some-chunk"), ChunkHash: types.ChunkHash("some-chunk"),
} }
err := repos.FileChunks.Create(ctx, nil, fc) err := repos.FileChunks.Create(ctx, nil, fc)
if err == nil { if err == nil {
t.Error("expected error for invalid foreign key") t.Error("expected error for invalid foreign key")
} }
if !strings.Contains(err.Error(), "FOREIGN KEY") { if !strings.Contains(err.Error(), "FOREIGN KEY") {
t.Errorf("expected foreign key error, got: %v", err) t.Errorf("expected foreign key error, got: %v", err)
} }
@@ -546,10 +444,8 @@ func TestErrorPropagation(t *testing.T) {
// TestQueryInjection tests that the system is safe from SQL injection // TestQueryInjection tests that the system is safe from SQL injection
func TestQueryInjection(t *testing.T) { func TestQueryInjection(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t) db, cleanup := setupTestDB(t)
t.Cleanup(cleanup) defer cleanup()
ctx := context.Background() ctx := context.Background()
repos := NewRepositories(db) repos := NewRepositories(db)
@@ -564,8 +460,6 @@ func TestQueryInjection(t *testing.T) {
for _, injection := range injectionTests { for _, injection := range injectionTests {
t.Run("injection attempt", func(t *testing.T) { t.Run("injection attempt", func(t *testing.T) {
t.Parallel()
// Try injection in file path // Try injection in file path
file := &File{ file := &File{
Path: types.FilePath(injection), Path: types.FilePath(injection),
@@ -581,8 +475,7 @@ func TestQueryInjection(t *testing.T) {
// Verify tables still exist // Verify tables still exist
var count int var count int
err := db.conn.QueryRow("SELECT COUNT(*) FROM files").Scan(&count)
err := db.conn.QueryRowContext(ctx, countFilesQuery).Scan(&count)
if err != nil { if err != nil {
t.Fatal("files table was damaged by injection") t.Fatal("files table was damaged by injection")
} }
@@ -592,8 +485,6 @@ func TestQueryInjection(t *testing.T) {
// TestTimezoneHandling tests that times are properly handled in UTC // TestTimezoneHandling tests that times are properly handled in UTC
func TestTimezoneHandling(t *testing.T) { func TestTimezoneHandling(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t) db, cleanup := setupTestDB(t)
defer cleanup() defer cleanup()

View File

@@ -1,5 +1,6 @@
-- Migration 001: Initial Vaultik schema -- Vaultik Database Schema
-- All core tables for tracking files, chunks, blobs, snapshots, and uploads. -- Note: This database does not support migrations. If the schema changes,
-- delete the local database and perform a full backup to recreate it.
-- Files table: stores metadata about files in the filesystem -- Files table: stores metadata about files in the filesystem
CREATE TABLE IF NOT EXISTS files ( CREATE TABLE IF NOT EXISTS files (
@@ -133,17 +134,3 @@ CREATE TABLE IF NOT EXISTS uploads (
-- Index for efficient snapshot lookups -- Index for efficient snapshot lookups
CREATE INDEX IF NOT EXISTS idx_uploads_snapshot_id ON uploads(snapshot_id); CREATE INDEX IF NOT EXISTS idx_uploads_snapshot_id ON uploads(snapshot_id);
-- Local metadata: keyed, host-local settings that bind the state of the
-- local index database to external context. The primary use is
-- storage_url: once a backup writes blobs to a destination, the local
-- index is only valid against that destination — if the configured
-- storage_url later changes, the scanner would silently think already-
-- known chunks are still on the new (empty) destination and skip
-- uploading them, corrupting future snapshots. On every mutating
-- command startup, we compare the configured storage_url to the stored
-- one and refuse to proceed on mismatch.
CREATE TABLE IF NOT EXISTS local_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);

View File

@@ -1,9 +0,0 @@
-- Migration 000: Schema migrations tracking table
-- Applied as a bootstrap step before the normal migration loop.
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
INSERT OR IGNORE INTO schema_migrations (version) VALUES (0);

View File

@@ -0,0 +1,11 @@
-- Track blob upload metrics
CREATE TABLE IF NOT EXISTS uploads (
blob_hash TEXT PRIMARY KEY,
uploaded_at TIMESTAMP NOT NULL,
size INTEGER NOT NULL,
duration_ms INTEGER NOT NULL,
FOREIGN KEY (blob_hash) REFERENCES blobs(blob_hash)
);
CREATE INDEX idx_uploads_uploaded_at ON uploads(uploaded_at);
CREATE INDEX idx_uploads_duration ON uploads(duration_ms);

View File

@@ -3,60 +3,43 @@ package database
import ( import (
"context" "context"
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
"strings"
"time" "time"
"sneak.berlin/go/vaultik/internal/types" "git.eeqj.de/sneak/vaultik/internal/types"
) )
// SnapshotRepository provides access to the snapshots table and its
// snapshot_files / snapshot_blobs association tables.
type SnapshotRepository struct { type SnapshotRepository struct {
db *DB db *DB
} }
// NewSnapshotRepository creates a SnapshotRepository backed by db.
func NewSnapshotRepository(db *DB) *SnapshotRepository { func NewSnapshotRepository(db *DB) *SnapshotRepository {
return &SnapshotRepository{db: db} return &SnapshotRepository{db: db}
} }
// Create inserts a snapshot row, using tx when non-nil. func (r *SnapshotRepository) Create(ctx context.Context, tx *sql.Tx, snapshot *Snapshot) error {
func (r *SnapshotRepository) Create(
ctx context.Context, tx *sql.Tx, snapshot *Snapshot,
) error {
query := ` query := `
INSERT INTO snapshots (id, hostname, vaultik_version, INSERT INTO snapshots (id, hostname, vaultik_version, vaultik_git_revision, started_at, completed_at,
vaultik_git_revision, started_at, completed_at, file_count, chunk_count, blob_count, total_size, blob_size, blob_uncompressed_size,
file_count, chunk_count, blob_count, total_size, blob_size, compression_ratio, compression_level, upload_bytes, upload_duration_ms)
blob_uncompressed_size, compression_ratio, compression_level,
upload_bytes, upload_duration_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
` `
var completedAt *int64 var completedAt *int64
if snapshot.CompletedAt != nil { if snapshot.CompletedAt != nil {
ts := snapshot.CompletedAt.Unix() ts := snapshot.CompletedAt.Unix()
completedAt = &ts completedAt = &ts
} }
args := []any{
snapshot.ID, snapshot.Hostname, snapshot.VaultikVersion,
snapshot.VaultikGitRevision, snapshot.StartedAt.Unix(),
completedAt, snapshot.FileCount, snapshot.ChunkCount,
snapshot.BlobCount, snapshot.TotalSize, snapshot.BlobSize,
snapshot.BlobUncompressedSize, snapshot.CompressionRatio,
snapshot.CompressionLevel, snapshot.UploadBytes,
snapshot.UploadDurationMs,
}
var err error var err error
if tx != nil { if tx != nil {
_, err = tx.ExecContext(ctx, query, args...) _, err = tx.ExecContext(ctx, query, snapshot.ID, snapshot.Hostname, snapshot.VaultikVersion, snapshot.VaultikGitRevision, snapshot.StartedAt.Unix(),
completedAt, snapshot.FileCount, snapshot.ChunkCount, snapshot.BlobCount, snapshot.TotalSize, snapshot.BlobSize, snapshot.BlobUncompressedSize,
snapshot.CompressionRatio, snapshot.CompressionLevel, snapshot.UploadBytes, snapshot.UploadDurationMs)
} else { } else {
_, err = r.db.ExecWithLog(ctx, query, args...) _, err = r.db.ExecWithLog(ctx, query, snapshot.ID, snapshot.Hostname, snapshot.VaultikVersion, snapshot.VaultikGitRevision, snapshot.StartedAt.Unix(),
completedAt, snapshot.FileCount, snapshot.ChunkCount, snapshot.BlobCount, snapshot.TotalSize, snapshot.BlobSize, snapshot.BlobUncompressedSize,
snapshot.CompressionRatio, snapshot.CompressionLevel, snapshot.UploadBytes, snapshot.UploadDurationMs)
} }
if err != nil { if err != nil {
@@ -66,14 +49,7 @@ func (r *SnapshotRepository) Create(
return nil return nil
} }
// UpdateCounts updates a snapshot's file/chunk/blob counters and sizes, func (r *SnapshotRepository) UpdateCounts(ctx context.Context, tx *sql.Tx, snapshotID string, fileCount, chunkCount, blobCount, totalSize, blobSize int64) error {
// recomputing the compression ratio, using tx when non-nil.
func (r *SnapshotRepository) UpdateCounts(
ctx context.Context,
tx *sql.Tx,
snapshotID string,
fileCount, chunkCount, blobCount, totalSize, blobSize int64,
) error {
compressionRatio := 1.0 compressionRatio := 1.0
if totalSize > 0 { if totalSize > 0 {
compressionRatio = float64(blobSize) / float64(totalSize) compressionRatio = float64(blobSize) / float64(totalSize)
@@ -92,13 +68,9 @@ func (r *SnapshotRepository) UpdateCounts(
var err error var err error
if tx != nil { if tx != nil {
_, err = tx.ExecContext(ctx, query, _, err = tx.ExecContext(ctx, query, fileCount, chunkCount, blobCount, totalSize, blobSize, compressionRatio, snapshotID)
fileCount, chunkCount, blobCount, totalSize, blobSize,
compressionRatio, snapshotID)
} else { } else {
_, err = r.db.ExecWithLog(ctx, query, _, err = r.db.ExecWithLog(ctx, query, fileCount, chunkCount, blobCount, totalSize, blobSize, compressionRatio, snapshotID)
fileCount, chunkCount, blobCount, totalSize, blobSize,
compressionRatio, snapshotID)
} }
if err != nil { if err != nil {
@@ -109,19 +81,27 @@ func (r *SnapshotRepository) UpdateCounts(
} }
// UpdateExtendedStats updates extended statistics for a snapshot // UpdateExtendedStats updates extended statistics for a snapshot
func (r *SnapshotRepository) UpdateExtendedStats( func (r *SnapshotRepository) UpdateExtendedStats(ctx context.Context, tx *sql.Tx, snapshotID string, blobUncompressedSize int64, compressionLevel int, uploadDurationMs int64) error {
ctx context.Context, // Calculate compression ratio based on uncompressed vs compressed sizes
tx *sql.Tx, var compressionRatio float64
snapshotID string, if blobUncompressedSize > 0 {
blobUncompressedSize int64, // Get current blob_size from DB to calculate ratio
compressionLevel int, var blobSize int64
uploadDurationMs int64, queryGet := `SELECT blob_size FROM snapshots WHERE id = ?`
) error { if tx != nil {
compressionRatio, err := r.extendedCompressionRatio( err := tx.QueryRowContext(ctx, queryGet, snapshotID).Scan(&blobSize)
ctx, tx, snapshotID, blobUncompressedSize,
)
if err != nil { if err != nil {
return err return fmt.Errorf("getting blob size: %w", err)
}
} else {
err := r.db.conn.QueryRowContext(ctx, queryGet, snapshotID).Scan(&blobSize)
if err != nil {
return fmt.Errorf("getting blob size: %w", err)
}
}
compressionRatio = float64(blobSize) / float64(blobUncompressedSize)
} else {
compressionRatio = 1.0
} }
query := ` query := `
@@ -134,28 +114,20 @@ func (r *SnapshotRepository) UpdateExtendedStats(
WHERE id = ? WHERE id = ?
` `
var err error
if tx != nil { if tx != nil {
_, err = tx.ExecContext(ctx, query, _, err = tx.ExecContext(ctx, query, blobUncompressedSize, compressionRatio, compressionLevel, uploadDurationMs, snapshotID)
blobUncompressedSize, compressionRatio, compressionLevel,
uploadDurationMs, snapshotID)
} else { } else {
_, err = r.db.ExecWithLog(ctx, query, _, err = r.db.ExecWithLog(ctx, query, blobUncompressedSize, compressionRatio, compressionLevel, uploadDurationMs, snapshotID)
blobUncompressedSize, compressionRatio, compressionLevel,
uploadDurationMs, snapshotID)
} }
if err != nil { if err != nil {
return fmt.Errorf("updating extended stats: %w", err) return fmt.Errorf("updating extended stats: %w", err)
} }
return nil return nil
} }
// GetByID returns the snapshot with the given ID, or nil if no such func (r *SnapshotRepository) GetByID(ctx context.Context, snapshotID string) (*Snapshot, error) {
// snapshot exists.
func (r *SnapshotRepository) GetByID(
ctx context.Context, snapshotID string,
) (*Snapshot, error) {
query := ` query := `
SELECT id, hostname, vaultik_version, vaultik_git_revision, started_at, completed_at, SELECT id, hostname, vaultik_version, vaultik_git_revision, started_at, completed_at,
file_count, chunk_count, blob_count, total_size, blob_size, blob_uncompressed_size, file_count, chunk_count, blob_count, total_size, blob_size, blob_uncompressed_size,
@@ -164,11 +136,9 @@ func (r *SnapshotRepository) GetByID(
WHERE id = ? WHERE id = ?
` `
var ( var snapshot Snapshot
snapshot Snapshot var startedAtUnix int64
startedAtUnix int64 var completedAtUnix *int64
completedAtUnix *int64
)
err := r.db.conn.QueryRowContext(ctx, query, snapshotID).Scan( err := r.db.conn.QueryRowContext(ctx, query, snapshotID).Scan(
&snapshot.ID, &snapshot.ID,
@@ -189,10 +159,9 @@ func (r *SnapshotRepository) GetByID(
&snapshot.UploadDurationMs, &snapshot.UploadDurationMs,
) )
if errors.Is(err, sql.ErrNoRows) { if err == sql.ErrNoRows {
return nil, nil //nolint:nilnil // nil,nil signals not-found; callers check nil return nil, nil
} }
if err != nil { if err != nil {
return nil, fmt.Errorf("querying snapshot: %w", err) return nil, fmt.Errorf("querying snapshot: %w", err)
} }
@@ -206,14 +175,9 @@ func (r *SnapshotRepository) GetByID(
return &snapshot, nil return &snapshot, nil
} }
// ListRecent returns up to limit snapshots, most recently started first. func (r *SnapshotRepository) ListRecent(ctx context.Context, limit int) ([]*Snapshot, error) {
func (r *SnapshotRepository) ListRecent(
ctx context.Context, limit int,
) ([]*Snapshot, error) {
query := ` query := `
SELECT id, hostname, vaultik_version, vaultik_git_revision, SELECT id, hostname, vaultik_version, vaultik_git_revision, started_at, completed_at, file_count, chunk_count, blob_count, total_size, blob_size, compression_ratio
started_at, completed_at, file_count, chunk_count, blob_count,
total_size, blob_size, compression_ratio
FROM snapshots FROM snapshots
ORDER BY started_at DESC ORDER BY started_at DESC
LIMIT ? LIMIT ?
@@ -223,21 +187,46 @@ func (r *SnapshotRepository) ListRecent(
if err != nil { if err != nil {
return nil, fmt.Errorf("querying snapshots: %w", err) return nil, fmt.Errorf("querying snapshots: %w", err)
} }
defer CloseRows(rows)
defer func() { var snapshots []*Snapshot
err := rows.Close() for rows.Next() {
var snapshot Snapshot
var startedAtUnix int64
var completedAtUnix *int64
err := rows.Scan(
&snapshot.ID,
&snapshot.Hostname,
&snapshot.VaultikVersion,
&snapshot.VaultikGitRevision,
&startedAtUnix,
&completedAtUnix,
&snapshot.FileCount,
&snapshot.ChunkCount,
&snapshot.BlobCount,
&snapshot.TotalSize,
&snapshot.BlobSize,
&snapshot.CompressionRatio,
)
if err != nil { if err != nil {
Fatalf("failed to close rows: %v", err) return nil, fmt.Errorf("scanning snapshot: %w", err)
} }
}()
return r.scanSnapshotRows(rows) snapshot.StartedAt = time.Unix(startedAtUnix, 0)
if completedAtUnix != nil {
t := time.Unix(*completedAtUnix, 0)
snapshot.CompletedAt = &t
}
snapshots = append(snapshots, &snapshot)
}
return snapshots, rows.Err()
} }
// MarkComplete marks a snapshot as completed with the current timestamp // MarkComplete marks a snapshot as completed with the current timestamp
func (r *SnapshotRepository) MarkComplete( func (r *SnapshotRepository) MarkComplete(ctx context.Context, tx *sql.Tx, snapshotID string) error {
ctx context.Context, tx *sql.Tx, snapshotID string,
) error {
query := ` query := `
UPDATE snapshots UPDATE snapshots
SET completed_at = ? SET completed_at = ?
@@ -261,9 +250,7 @@ func (r *SnapshotRepository) MarkComplete(
} }
// AddFile adds a file to a snapshot // AddFile adds a file to a snapshot
func (r *SnapshotRepository) AddFile( func (r *SnapshotRepository) AddFile(ctx context.Context, tx *sql.Tx, snapshotID string, filePath string) error {
ctx context.Context, tx *sql.Tx, snapshotID string, filePath string,
) error {
query := ` query := `
INSERT OR IGNORE INTO snapshot_files (snapshot_id, file_id) INSERT OR IGNORE INTO snapshot_files (snapshot_id, file_id)
SELECT ?, id FROM files WHERE path = ? SELECT ?, id FROM files WHERE path = ?
@@ -284,9 +271,7 @@ func (r *SnapshotRepository) AddFile(
} }
// AddFileByID adds a file to a snapshot by file ID // AddFileByID adds a file to a snapshot by file ID
func (r *SnapshotRepository) AddFileByID( func (r *SnapshotRepository) AddFileByID(ctx context.Context, tx *sql.Tx, snapshotID string, fileID types.FileID) error {
ctx context.Context, tx *sql.Tx, snapshotID string, fileID types.FileID,
) error {
query := ` query := `
INSERT OR IGNORE INTO snapshot_files (snapshot_id, file_id) INSERT OR IGNORE INTO snapshot_files (snapshot_id, file_id)
VALUES (?, ?) VALUES (?, ?)
@@ -307,49 +292,37 @@ func (r *SnapshotRepository) AddFileByID(
} }
// AddFilesByIDBatch adds multiple files to a snapshot in batched inserts // AddFilesByIDBatch adds multiple files to a snapshot in batched inserts
func (r *SnapshotRepository) AddFilesByIDBatch( func (r *SnapshotRepository) AddFilesByIDBatch(ctx context.Context, tx *sql.Tx, snapshotID string, fileIDs []types.FileID) error {
ctx context.Context, tx *sql.Tx, snapshotID string, fileIDs []types.FileID,
) error {
if len(fileIDs) == 0 { if len(fileIDs) == 0 {
return nil return nil
} }
// Each snapshot_files row binds this many SQL variables. // Each entry has 2 values, so batch at 400 to be safe
const snapshotFileCols = 2
// Batch at 400 rows to be safe with SQLite's variable limit.
const batchSize = 400 const batchSize = 400
for i := 0; i < len(fileIDs); i += batchSize { for i := 0; i < len(fileIDs); i += batchSize {
end := min(i+batchSize, len(fileIDs)) end := i + batchSize
if end > len(fileIDs) {
end = len(fileIDs)
}
batch := fileIDs[i:end] batch := fileIDs[i:end]
query := "INSERT OR IGNORE INTO snapshot_files (snapshot_id, file_id) VALUES " query := "INSERT OR IGNORE INTO snapshot_files (snapshot_id, file_id) VALUES "
args := make([]interface{}, 0, len(batch)*2)
args := make([]any, 0, len(batch)*snapshotFileCols)
var querySb312 strings.Builder
for j, fileID := range batch { for j, fileID := range batch {
if j > 0 { if j > 0 {
querySb312.WriteString(", ") query += ", "
} }
query += "(?, ?)"
querySb312.WriteString("(?, ?)")
args = append(args, snapshotID, fileID.String()) args = append(args, snapshotID, fileID.String())
} }
query += querySb312.String() //nolint:gosec // G202: appends "?" placeholders only
var err error var err error
if tx != nil { if tx != nil {
_, err = tx.ExecContext(ctx, query, args...) _, err = tx.ExecContext(ctx, query, args...)
} else { } else {
_, err = r.db.ExecWithLog(ctx, query, args...) _, err = r.db.ExecWithLog(ctx, query, args...)
} }
if err != nil { if err != nil {
return fmt.Errorf("batch adding files to snapshot: %w", err) return fmt.Errorf("batch adding files to snapshot: %w", err)
} }
@@ -358,57 +331,8 @@ func (r *SnapshotRepository) AddFilesByIDBatch(
return nil return nil
} }
// PopulateReferencedBlobs ensures snapshot_blobs contains an entry for
// every blob that holds a chunk referenced by any file in the snapshot.
// This is necessary because the AddBlob hook only runs when a blob is
// newly uploaded during a snapshot — fully-deduplicated snapshots (where
// every chunk already exists in storage from a prior run) would otherwise
// have an empty snapshot_blobs set and be impossible to restore.
//
// Returns the number of rows inserted (i.e. blobs that were previously
// referenced indirectly via file_chunks but not yet recorded in
// snapshot_blobs for this snapshot).
func (r *SnapshotRepository) PopulateReferencedBlobs(
ctx context.Context, tx *sql.Tx, snapshotID string,
) (int64, error) {
query := `
INSERT OR IGNORE INTO snapshot_blobs (snapshot_id, blob_id, blob_hash)
SELECT DISTINCT ?, blobs.id, blobs.blob_hash
FROM blobs
JOIN blob_chunks ON blob_chunks.blob_id = blobs.id
JOIN file_chunks ON file_chunks.chunk_hash = blob_chunks.chunk_hash
JOIN snapshot_files ON snapshot_files.file_id = file_chunks.file_id
WHERE snapshot_files.snapshot_id = ?
AND blobs.blob_hash IS NOT NULL
`
var (
result sql.Result
err error
)
if tx != nil {
result, err = tx.ExecContext(ctx, query, snapshotID, snapshotID)
} else {
result, err = r.db.ExecWithLog(ctx, query, snapshotID, snapshotID)
}
if err != nil {
return 0, fmt.Errorf("populating referenced blobs: %w", err)
}
n, _ := result.RowsAffected()
return n, nil
}
// AddBlob adds a blob to a snapshot // AddBlob adds a blob to a snapshot
func (r *SnapshotRepository) AddBlob( func (r *SnapshotRepository) AddBlob(ctx context.Context, tx *sql.Tx, snapshotID string, blobID types.BlobID, blobHash types.BlobHash) error {
ctx context.Context,
tx *sql.Tx,
snapshotID string,
blobID types.BlobID,
blobHash types.BlobHash,
) error {
query := ` query := `
INSERT OR IGNORE INTO snapshot_blobs (snapshot_id, blob_id, blob_hash) INSERT OR IGNORE INTO snapshot_blobs (snapshot_id, blob_id, blob_hash)
VALUES (?, ?, ?) VALUES (?, ?, ?)
@@ -429,9 +353,7 @@ func (r *SnapshotRepository) AddBlob(
} }
// GetBlobHashes returns all blob hashes for a snapshot // GetBlobHashes returns all blob hashes for a snapshot
func (r *SnapshotRepository) GetBlobHashes( func (r *SnapshotRepository) GetBlobHashes(ctx context.Context, snapshotID string) ([]string, error) {
ctx context.Context, snapshotID string,
) ([]string, error) {
query := ` query := `
SELECT sb.blob_hash SELECT sb.blob_hash
FROM snapshot_blobs sb FROM snapshot_blobs sb
@@ -443,35 +365,22 @@ func (r *SnapshotRepository) GetBlobHashes(
if err != nil { if err != nil {
return nil, fmt.Errorf("querying blob hashes: %w", err) return nil, fmt.Errorf("querying blob hashes: %w", err)
} }
defer CloseRows(rows)
defer func() {
err := rows.Close()
if err != nil {
Fatalf("failed to close rows: %v", err)
}
}()
var blobs []string var blobs []string
for rows.Next() { for rows.Next() {
var blobHash string var blobHash string
if err := rows.Scan(&blobHash); err != nil {
err := rows.Scan(&blobHash)
if err != nil {
return nil, fmt.Errorf("scanning blob hash: %w", err) return nil, fmt.Errorf("scanning blob hash: %w", err)
} }
blobs = append(blobs, blobHash) blobs = append(blobs, blobHash)
} }
return blobs, rows.Err() return blobs, rows.Err()
} }
// GetSnapshotTotalCompressedSize returns the total compressed size of all // GetSnapshotTotalCompressedSize returns the total compressed size of all blobs referenced by a snapshot
// blobs referenced by a snapshot. func (r *SnapshotRepository) GetSnapshotTotalCompressedSize(ctx context.Context, snapshotID string) (int64, error) {
func (r *SnapshotRepository) GetSnapshotTotalCompressedSize(
ctx context.Context, snapshotID string,
) (int64, error) {
query := ` query := `
SELECT COALESCE(SUM(b.compressed_size), 0) SELECT COALESCE(SUM(b.compressed_size), 0)
FROM snapshot_blobs sb FROM snapshot_blobs sb
@@ -480,7 +389,6 @@ func (r *SnapshotRepository) GetSnapshotTotalCompressedSize(
` `
var totalSize int64 var totalSize int64
err := r.db.conn.QueryRowContext(ctx, query, snapshotID).Scan(&totalSize) err := r.db.conn.QueryRowContext(ctx, query, snapshotID).Scan(&totalSize)
if err != nil { if err != nil {
return 0, fmt.Errorf("querying total compressed size: %w", err) return 0, fmt.Errorf("querying total compressed size: %w", err)
@@ -489,81 +397,10 @@ func (r *SnapshotRepository) GetSnapshotTotalCompressedSize(
return totalSize, nil return totalSize, nil
} }
// GetSnapshotUncompressedChunkSize returns the sum of plaintext sizes of all unique
// chunks referenced by a snapshot (via snapshot_files → file_chunks → chunks).
func (r *SnapshotRepository) GetSnapshotUncompressedChunkSize(
ctx context.Context, snapshotID string,
) (int64, error) {
query := `
SELECT COALESCE(SUM(c.size), 0)
FROM (
SELECT DISTINCT fc.chunk_hash
FROM snapshot_files sf
JOIN file_chunks fc ON sf.file_id = fc.file_id
WHERE sf.snapshot_id = ?
) sc
JOIN chunks c ON sc.chunk_hash = c.chunk_hash
`
var totalSize int64
err := r.db.conn.QueryRowContext(ctx, query, snapshotID).Scan(&totalSize)
if err != nil {
return 0, fmt.Errorf("querying uncompressed chunk size: %w", err)
}
return totalSize, nil
}
// GetSnapshotNewChunkSize returns the sum of plaintext sizes of chunks that are
// referenced by this snapshot but not by any earlier completed snapshot known to
// the local database. The result is the marginal uncompressed data this snapshot
// added to the dedup pool — i.e., the delta from prior snapshots.
func (r *SnapshotRepository) GetSnapshotNewChunkSize(
ctx context.Context, snapshotID string,
) (int64, error) {
query := `
WITH this_snap_chunks AS (
SELECT DISTINCT fc.chunk_hash
FROM snapshot_files sf
JOIN file_chunks fc ON sf.file_id = fc.file_id
WHERE sf.snapshot_id = ?
),
prior_chunks AS (
SELECT DISTINCT fc.chunk_hash
FROM snapshots s
JOIN snapshot_files sf ON sf.snapshot_id = s.id
JOIN file_chunks fc ON fc.file_id = sf.file_id
WHERE s.completed_at IS NOT NULL
AND s.id != ?
AND s.started_at < (SELECT started_at FROM snapshots WHERE id = ?)
)
SELECT COALESCE(SUM(c.size), 0)
FROM chunks c
JOIN this_snap_chunks t ON c.chunk_hash = t.chunk_hash
WHERE c.chunk_hash NOT IN (SELECT chunk_hash FROM prior_chunks)
`
var totalSize int64
err := r.db.conn.QueryRowContext(
ctx, query, snapshotID, snapshotID, snapshotID,
).Scan(&totalSize)
if err != nil {
return 0, fmt.Errorf("querying new chunk size: %w", err)
}
return totalSize, nil
}
// GetIncompleteSnapshots returns all snapshots that haven't been completed // GetIncompleteSnapshots returns all snapshots that haven't been completed
func (r *SnapshotRepository) GetIncompleteSnapshots( func (r *SnapshotRepository) GetIncompleteSnapshots(ctx context.Context) ([]*Snapshot, error) {
ctx context.Context,
) ([]*Snapshot, error) {
query := ` query := `
SELECT id, hostname, vaultik_version, vaultik_git_revision, SELECT id, hostname, vaultik_version, vaultik_git_revision, started_at, completed_at, file_count, chunk_count, blob_count, total_size, blob_size, compression_ratio
started_at, completed_at, file_count, chunk_count, blob_count,
total_size, blob_size, compression_ratio
FROM snapshots FROM snapshots
WHERE completed_at IS NULL WHERE completed_at IS NULL
ORDER BY started_at DESC ORDER BY started_at DESC
@@ -573,25 +410,48 @@ func (r *SnapshotRepository) GetIncompleteSnapshots(
if err != nil { if err != nil {
return nil, fmt.Errorf("querying incomplete snapshots: %w", err) return nil, fmt.Errorf("querying incomplete snapshots: %w", err)
} }
defer CloseRows(rows)
defer func() { var snapshots []*Snapshot
err := rows.Close() for rows.Next() {
var snapshot Snapshot
var startedAtUnix int64
var completedAtUnix *int64
err := rows.Scan(
&snapshot.ID,
&snapshot.Hostname,
&snapshot.VaultikVersion,
&snapshot.VaultikGitRevision,
&startedAtUnix,
&completedAtUnix,
&snapshot.FileCount,
&snapshot.ChunkCount,
&snapshot.BlobCount,
&snapshot.TotalSize,
&snapshot.BlobSize,
&snapshot.CompressionRatio,
)
if err != nil { if err != nil {
Fatalf("failed to close rows: %v", err) return nil, fmt.Errorf("scanning snapshot: %w", err)
} }
}()
return r.scanSnapshotRows(rows) snapshot.StartedAt = time.Unix(startedAtUnix, 0)
if completedAtUnix != nil {
t := time.Unix(*completedAtUnix, 0)
snapshot.CompletedAt = &t
}
snapshots = append(snapshots, &snapshot)
}
return snapshots, rows.Err()
} }
// GetIncompleteByHostname returns all incomplete snapshots for a specific hostname // GetIncompleteByHostname returns all incomplete snapshots for a specific hostname
func (r *SnapshotRepository) GetIncompleteByHostname( func (r *SnapshotRepository) GetIncompleteByHostname(ctx context.Context, hostname string) ([]*Snapshot, error) {
ctx context.Context, hostname string,
) ([]*Snapshot, error) {
query := ` query := `
SELECT id, hostname, vaultik_version, vaultik_git_revision, SELECT id, hostname, vaultik_version, vaultik_git_revision, started_at, completed_at, file_count, chunk_count, blob_count, total_size, blob_size, compression_ratio
started_at, completed_at, file_count, chunk_count, blob_count,
total_size, blob_size, compression_ratio
FROM snapshots FROM snapshots
WHERE completed_at IS NULL AND hostname = ? WHERE completed_at IS NULL AND hostname = ?
ORDER BY started_at DESC ORDER BY started_at DESC
@@ -601,22 +461,13 @@ func (r *SnapshotRepository) GetIncompleteByHostname(
if err != nil { if err != nil {
return nil, fmt.Errorf("querying incomplete snapshots: %w", err) return nil, fmt.Errorf("querying incomplete snapshots: %w", err)
} }
defer CloseRows(rows)
defer func() {
err := rows.Close()
if err != nil {
Fatalf("failed to close rows: %v", err)
}
}()
var snapshots []*Snapshot var snapshots []*Snapshot
for rows.Next() { for rows.Next() {
var ( var snapshot Snapshot
snapshot Snapshot var startedAtUnix int64
startedAtUnix int64 var completedAtUnix *int64
completedAtUnix *int64
)
err := rows.Scan( err := rows.Scan(
&snapshot.ID, &snapshot.ID,
@@ -661,9 +512,7 @@ func (r *SnapshotRepository) Delete(ctx context.Context, snapshotID string) erro
} }
// DeleteSnapshotFiles removes all snapshot_files entries for a snapshot // DeleteSnapshotFiles removes all snapshot_files entries for a snapshot
func (r *SnapshotRepository) DeleteSnapshotFiles( func (r *SnapshotRepository) DeleteSnapshotFiles(ctx context.Context, snapshotID string) error {
ctx context.Context, snapshotID string,
) error {
query := `DELETE FROM snapshot_files WHERE snapshot_id = ?` query := `DELETE FROM snapshot_files WHERE snapshot_id = ?`
_, err := r.db.ExecWithLog(ctx, query, snapshotID) _, err := r.db.ExecWithLog(ctx, query, snapshotID)
@@ -675,9 +524,7 @@ func (r *SnapshotRepository) DeleteSnapshotFiles(
} }
// DeleteSnapshotBlobs removes all snapshot_blobs entries for a snapshot // DeleteSnapshotBlobs removes all snapshot_blobs entries for a snapshot
func (r *SnapshotRepository) DeleteSnapshotBlobs( func (r *SnapshotRepository) DeleteSnapshotBlobs(ctx context.Context, snapshotID string) error {
ctx context.Context, snapshotID string,
) error {
query := `DELETE FROM snapshot_blobs WHERE snapshot_id = ?` query := `DELETE FROM snapshot_blobs WHERE snapshot_id = ?`
_, err := r.db.ExecWithLog(ctx, query, snapshotID) _, err := r.db.ExecWithLog(ctx, query, snapshotID)
@@ -689,9 +536,7 @@ func (r *SnapshotRepository) DeleteSnapshotBlobs(
} }
// DeleteSnapshotUploads removes all uploads entries for a snapshot // DeleteSnapshotUploads removes all uploads entries for a snapshot
func (r *SnapshotRepository) DeleteSnapshotUploads( func (r *SnapshotRepository) DeleteSnapshotUploads(ctx context.Context, snapshotID string) error {
ctx context.Context, snapshotID string,
) error {
query := `DELETE FROM uploads WHERE snapshot_id = ?` query := `DELETE FROM uploads WHERE snapshot_id = ?`
_, err := r.db.ExecWithLog(ctx, query, snapshotID) _, err := r.db.ExecWithLog(ctx, query, snapshotID)
@@ -701,77 +546,3 @@ func (r *SnapshotRepository) DeleteSnapshotUploads(
return nil return nil
} }
// extendedCompressionRatio computes the compression ratio for a snapshot
// from its stored blob_size and the given uncompressed size. Returns 1.0
// when the uncompressed size is zero.
func (r *SnapshotRepository) extendedCompressionRatio(
ctx context.Context,
tx *sql.Tx,
snapshotID string,
blobUncompressedSize int64,
) (float64, error) {
if blobUncompressedSize <= 0 {
return 1.0, nil
}
// Get current blob_size from DB to calculate ratio
var blobSize int64
queryGet := `SELECT blob_size FROM snapshots WHERE id = ?`
var err error
if tx != nil {
err = tx.QueryRowContext(ctx, queryGet, snapshotID).Scan(&blobSize)
} else {
err = r.db.conn.QueryRowContext(ctx, queryGet, snapshotID).Scan(&blobSize)
}
if err != nil {
return 0, fmt.Errorf("getting blob size: %w", err)
}
return float64(blobSize) / float64(blobUncompressedSize), nil
}
// scanSnapshotRows scans the standard snapshot column set from a rows
// iterator into Snapshot records.
func (r *SnapshotRepository) scanSnapshotRows(rows *sql.Rows) ([]*Snapshot, error) {
var snapshots []*Snapshot
for rows.Next() {
var (
snapshot Snapshot
startedAtUnix int64
completedAtUnix *int64
)
err := rows.Scan(
&snapshot.ID,
&snapshot.Hostname,
&snapshot.VaultikVersion,
&snapshot.VaultikGitRevision,
&startedAtUnix,
&completedAtUnix,
&snapshot.FileCount,
&snapshot.ChunkCount,
&snapshot.BlobCount,
&snapshot.TotalSize,
&snapshot.BlobSize,
&snapshot.CompressionRatio,
)
if err != nil {
return nil, fmt.Errorf("scanning snapshot: %w", err)
}
snapshot.StartedAt = time.Unix(startedAtUnix, 0)
if completedAtUnix != nil {
t := time.Unix(*completedAtUnix, 0)
snapshot.CompletedAt = &t
}
snapshots = append(snapshots, &snapshot)
}
return snapshots, rows.Err()
}

View File

@@ -1,4 +1,4 @@
package database_test package database
import ( import (
"context" "context"
@@ -7,8 +7,7 @@ import (
"testing" "testing"
"time" "time"
"sneak.berlin/go/vaultik/internal/database" "git.eeqj.de/sneak/vaultik/internal/types"
"sneak.berlin/go/vaultik/internal/types"
) )
const ( const (
@@ -22,19 +21,17 @@ const (
) )
func TestSnapshotRepository(t *testing.T) { func TestSnapshotRepository(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t) db, cleanup := setupTestDB(t)
defer cleanup() defer cleanup()
ctx := context.Background() ctx := context.Background()
repo := database.NewSnapshotRepository(db) repo := NewSnapshotRepository(db)
// Test Create // Test Create
snapshot := &database.Snapshot{ snapshot := &Snapshot{
ID: "2024-01-01T12:00:00Z", ID: "2024-01-01T12:00:00Z",
Hostname: testHostname, Hostname: "test-host",
VaultikVersion: testVersion, VaultikVersion: "1.0.0",
StartedAt: time.Now().Truncate(time.Second), StartedAt: time.Now().Truncate(time.Second),
CompletedAt: nil, CompletedAt: nil,
FileCount: 100, FileCount: 100,
@@ -55,118 +52,62 @@ func TestSnapshotRepository(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("failed to get snapshot: %v", err) t.Fatalf("failed to get snapshot: %v", err)
} }
if retrieved == nil { if retrieved == nil {
t.Fatal("expected snapshot, got nil") t.Fatal("expected snapshot, got nil")
} }
if retrieved.ID != snapshot.ID { if retrieved.ID != snapshot.ID {
t.Errorf("ID mismatch: got %s, want %s", retrieved.ID, snapshot.ID) t.Errorf("ID mismatch: got %s, want %s", retrieved.ID, snapshot.ID)
} }
if retrieved.Hostname != snapshot.Hostname { if retrieved.Hostname != snapshot.Hostname {
t.Errorf("hostname mismatch: got %s, want %s", t.Errorf("hostname mismatch: got %s, want %s", retrieved.Hostname, snapshot.Hostname)
retrieved.Hostname, snapshot.Hostname)
} }
if retrieved.FileCount != snapshot.FileCount { if retrieved.FileCount != snapshot.FileCount {
t.Errorf("file count mismatch: got %d, want %d", t.Errorf("file count mismatch: got %d, want %d", retrieved.FileCount, snapshot.FileCount)
retrieved.FileCount, snapshot.FileCount)
}
}
func TestSnapshotRepositoryUpdateCounts(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
repo := database.NewSnapshotRepository(db)
snapshot := &database.Snapshot{
ID: "2024-01-02T12:00:00Z",
Hostname: testHostname,
VaultikVersion: testVersion,
StartedAt: time.Now().Truncate(time.Second),
CompletedAt: nil,
FileCount: 100,
ChunkCount: 500,
BlobCount: 10,
TotalSize: oneHundredMebibytes,
BlobSize: fortyMebibytes,
CompressionRatio: compressionRatioPoint4,
}
err := repo.Create(ctx, nil, snapshot)
if err != nil {
t.Fatalf("failed to create snapshot: %v", err)
} }
// Test UpdateCounts // Test UpdateCounts
err = repo.UpdateCounts(ctx, nil, snapshot.ID.String(), err = repo.UpdateCounts(ctx, nil, snapshot.ID.String(), 200, 1000, 20, twoHundredMebibytes, sixtyMebibytes)
200, 1000, 20, twoHundredMebibytes, sixtyMebibytes)
if err != nil { if err != nil {
t.Fatalf("failed to update counts: %v", err) t.Fatalf("failed to update counts: %v", err)
} }
retrieved, err := repo.GetByID(ctx, snapshot.ID.String()) retrieved, err = repo.GetByID(ctx, snapshot.ID.String())
if err != nil { if err != nil {
t.Fatalf("failed to get updated snapshot: %v", err) t.Fatalf("failed to get updated snapshot: %v", err)
} }
if retrieved.FileCount != 200 { if retrieved.FileCount != 200 {
t.Errorf("file count not updated: got %d, want %d", retrieved.FileCount, 200) t.Errorf("file count not updated: got %d, want %d", retrieved.FileCount, 200)
} }
if retrieved.ChunkCount != 1000 { if retrieved.ChunkCount != 1000 {
t.Errorf("chunk count not updated: got %d, want %d", t.Errorf("chunk count not updated: got %d, want %d", retrieved.ChunkCount, 1000)
retrieved.ChunkCount, 1000)
} }
if retrieved.BlobCount != 20 { if retrieved.BlobCount != 20 {
t.Errorf("blob count not updated: got %d, want %d", retrieved.BlobCount, 20) t.Errorf("blob count not updated: got %d, want %d", retrieved.BlobCount, 20)
} }
if retrieved.TotalSize != twoHundredMebibytes { if retrieved.TotalSize != twoHundredMebibytes {
t.Errorf("total size not updated: got %d, want %d", t.Errorf("total size not updated: got %d, want %d", retrieved.TotalSize, twoHundredMebibytes)
retrieved.TotalSize, twoHundredMebibytes)
} }
if retrieved.BlobSize != sixtyMebibytes { if retrieved.BlobSize != sixtyMebibytes {
t.Errorf("blob size not updated: got %d, want %d", t.Errorf("blob size not updated: got %d, want %d", retrieved.BlobSize, sixtyMebibytes)
retrieved.BlobSize, sixtyMebibytes)
} }
expectedRatio := compressionRatioPoint3 // 0.3 expectedRatio := compressionRatioPoint3 // 0.3
if math.Abs(retrieved.CompressionRatio-expectedRatio) > 0.001 { if math.Abs(retrieved.CompressionRatio-expectedRatio) > 0.001 {
t.Errorf("compression ratio not updated: got %f, want %f", t.Errorf("compression ratio not updated: got %f, want %f", retrieved.CompressionRatio, expectedRatio)
retrieved.CompressionRatio, expectedRatio)
} }
}
func TestSnapshotRepositoryListRecent(t *testing.T) { // Test ListRecent
t.Parallel() // Add more snapshots
for i := 2; i <= 5; i++ {
db, cleanup := setupTestDB(t) s := &Snapshot{
defer cleanup()
ctx := context.Background()
repo := database.NewSnapshotRepository(db)
// Add snapshots
for i := 1; i <= 5; i++ {
s := &database.Snapshot{
ID: types.SnapshotID(fmt.Sprintf("2024-01-0%dT12:00:00Z", i)), ID: types.SnapshotID(fmt.Sprintf("2024-01-0%dT12:00:00Z", i)),
Hostname: testHostname, Hostname: "test-host",
VaultikVersion: testVersion, VaultikVersion: "1.0.0",
StartedAt: time.Now().Add(time.Duration(i) * time.Hour).Truncate(time.Second), StartedAt: time.Now().Add(time.Duration(i) * time.Hour).Truncate(time.Second),
CompletedAt: nil, CompletedAt: nil,
FileCount: int64(100 * i), FileCount: int64(100 * i),
ChunkCount: int64(500 * i), ChunkCount: int64(500 * i),
BlobCount: int64(10 * i), BlobCount: int64(10 * i),
} }
err := repo.Create(ctx, nil, s) err := repo.Create(ctx, nil, s)
if err != nil { if err != nil {
t.Fatalf("failed to create snapshot %d: %v", i, err) t.Fatalf("failed to create snapshot %d: %v", i, err)
@@ -178,13 +119,12 @@ func TestSnapshotRepositoryListRecent(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("failed to list recent snapshots: %v", err) t.Fatalf("failed to list recent snapshots: %v", err)
} }
if len(recent) != 3 { if len(recent) != 3 {
t.Errorf("expected 3 recent snapshots, got %d", len(recent)) t.Errorf("expected 3 recent snapshots, got %d", len(recent))
} }
// Verify order (most recent first) // Verify order (most recent first)
for i := range len(recent) - 1 { for i := 0; i < len(recent)-1; i++ {
if recent[i].StartedAt.Before(recent[i+1].StartedAt) { if recent[i].StartedAt.Before(recent[i+1].StartedAt) {
t.Error("snapshots not in descending order") t.Error("snapshots not in descending order")
} }
@@ -192,27 +132,23 @@ func TestSnapshotRepositoryListRecent(t *testing.T) {
} }
func TestSnapshotRepositoryNotFound(t *testing.T) { func TestSnapshotRepositoryNotFound(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t) db, cleanup := setupTestDB(t)
defer cleanup() defer cleanup()
ctx := context.Background() ctx := context.Background()
repo := database.NewSnapshotRepository(db) repo := NewSnapshotRepository(db)
// Test GetByID with non-existent ID // Test GetByID with non-existent ID
snapshot, err := repo.GetByID(ctx, "nonexistent") snapshot, err := repo.GetByID(ctx, "nonexistent")
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
if snapshot != nil { if snapshot != nil {
t.Error("expected nil for non-existent snapshot") t.Error("expected nil for non-existent snapshot")
} }
// Test UpdateCounts on non-existent snapshot // Test UpdateCounts on non-existent snapshot
err = repo.UpdateCounts(ctx, nil, "nonexistent", err = repo.UpdateCounts(ctx, nil, "nonexistent", 100, 200, 10, oneHundredMebibytes, fortyMebibytes)
100, 200, 10, oneHundredMebibytes, fortyMebibytes)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
@@ -220,18 +156,16 @@ func TestSnapshotRepositoryNotFound(t *testing.T) {
} }
func TestSnapshotRepositoryDuplicate(t *testing.T) { func TestSnapshotRepositoryDuplicate(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t) db, cleanup := setupTestDB(t)
defer cleanup() defer cleanup()
ctx := context.Background() ctx := context.Background()
repo := database.NewSnapshotRepository(db) repo := NewSnapshotRepository(db)
snapshot := &database.Snapshot{ snapshot := &Snapshot{
ID: "2024-01-01T12:00:00Z", ID: "2024-01-01T12:00:00Z",
Hostname: testHostname, Hostname: "test-host",
VaultikVersion: testVersion, VaultikVersion: "1.0.0",
StartedAt: time.Now().Truncate(time.Second), StartedAt: time.Now().Truncate(time.Second),
CompletedAt: nil, CompletedAt: nil,
FileCount: 100, FileCount: 100,

View File

@@ -3,10 +3,9 @@ package database
import ( import (
"context" "context"
"database/sql" "database/sql"
"errors"
"time" "time"
"sneak.berlin/go/vaultik/internal/log" "git.eeqj.de/sneak/vaultik/internal/log"
) )
// Upload represents a blob upload record // Upload represents a blob upload record
@@ -29,9 +28,7 @@ func NewUploadRepository(conn *sql.DB) *UploadRepository {
} }
// Create inserts a new upload record // Create inserts a new upload record
func (r *UploadRepository) Create( func (r *UploadRepository) Create(ctx context.Context, tx *sql.Tx, upload *Upload) error {
ctx context.Context, tx *sql.Tx, upload *Upload,
) error {
query := ` query := `
INSERT INTO uploads (blob_hash, snapshot_id, uploaded_at, size, duration_ms) INSERT INTO uploads (blob_hash, snapshot_id, uploaded_at, size, duration_ms)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
@@ -39,22 +36,16 @@ func (r *UploadRepository) Create(
var err error var err error
if tx != nil { if tx != nil {
_, err = tx.ExecContext(ctx, query, _, err = tx.ExecContext(ctx, query, upload.BlobHash, upload.SnapshotID, upload.UploadedAt, upload.Size, upload.DurationMs)
upload.BlobHash, upload.SnapshotID, upload.UploadedAt,
upload.Size, upload.DurationMs)
} else { } else {
_, err = r.conn.ExecContext(ctx, query, _, err = r.conn.ExecContext(ctx, query, upload.BlobHash, upload.SnapshotID, upload.UploadedAt, upload.Size, upload.DurationMs)
upload.BlobHash, upload.SnapshotID, upload.UploadedAt,
upload.Size, upload.DurationMs)
} }
return err return err
} }
// GetByBlobHash retrieves an upload record by blob hash // GetByBlobHash retrieves an upload record by blob hash
func (r *UploadRepository) GetByBlobHash( func (r *UploadRepository) GetByBlobHash(ctx context.Context, blobHash string) (*Upload, error) {
ctx context.Context, blobHash string,
) (*Upload, error) {
query := ` query := `
SELECT blob_hash, uploaded_at, size, duration_ms SELECT blob_hash, uploaded_at, size, duration_ms
FROM uploads FROM uploads
@@ -62,7 +53,6 @@ func (r *UploadRepository) GetByBlobHash(
` `
var upload Upload var upload Upload
err := r.conn.QueryRowContext(ctx, query, blobHash).Scan( err := r.conn.QueryRowContext(ctx, query, blobHash).Scan(
&upload.BlobHash, &upload.BlobHash,
&upload.UploadedAt, &upload.UploadedAt,
@@ -70,10 +60,9 @@ func (r *UploadRepository) GetByBlobHash(
&upload.DurationMs, &upload.DurationMs,
) )
if errors.Is(err, sql.ErrNoRows) { if err == sql.ErrNoRows {
return nil, nil //nolint:nilnil // nil,nil signals not-found; callers check nil return nil, nil
} }
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -82,9 +71,7 @@ func (r *UploadRepository) GetByBlobHash(
} }
// GetRecentUploads retrieves recent uploads ordered by upload time // GetRecentUploads retrieves recent uploads ordered by upload time
func (r *UploadRepository) GetRecentUploads( func (r *UploadRepository) GetRecentUploads(ctx context.Context, limit int) ([]*Upload, error) {
ctx context.Context, limit int,
) ([]*Upload, error) {
query := ` query := `
SELECT blob_hash, uploaded_at, size, duration_ms SELECT blob_hash, uploaded_at, size, duration_ms
FROM uploads FROM uploads
@@ -96,26 +83,18 @@ func (r *UploadRepository) GetRecentUploads(
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer func() { defer func() {
err := rows.Close() if err := rows.Close(); err != nil {
if err != nil {
log.Error("failed to close rows", "error", err) log.Error("failed to close rows", "error", err)
} }
}() }()
var uploads []*Upload var uploads []*Upload
for rows.Next() { for rows.Next() {
var upload Upload var upload Upload
if err := rows.Scan(&upload.BlobHash, &upload.UploadedAt, &upload.Size, &upload.DurationMs); err != nil {
err := rows.Scan(
&upload.BlobHash, &upload.UploadedAt, &upload.Size, &upload.DurationMs,
)
if err != nil {
return nil, err return nil, err
} }
uploads = append(uploads, &upload) uploads = append(uploads, &upload)
} }
@@ -123,9 +102,7 @@ func (r *UploadRepository) GetRecentUploads(
} }
// GetUploadStats returns aggregate statistics for uploads // GetUploadStats returns aggregate statistics for uploads
func (r *UploadRepository) GetUploadStats( func (r *UploadRepository) GetUploadStats(ctx context.Context, since time.Time) (*UploadStats, error) {
ctx context.Context, since time.Time,
) (*UploadStats, error) {
query := ` query := `
SELECT SELECT
COUNT(*) as count, COUNT(*) as count,
@@ -138,7 +115,6 @@ func (r *UploadRepository) GetUploadStats(
` `
var stats UploadStats var stats UploadStats
err := r.conn.QueryRowContext(ctx, query, since).Scan( err := r.conn.QueryRowContext(ctx, query, since).Scan(
&stats.Count, &stats.Count,
&stats.TotalSize, &stats.TotalSize,
@@ -160,17 +136,12 @@ type UploadStats struct {
} }
// GetCountBySnapshot returns the count of uploads for a specific snapshot // GetCountBySnapshot returns the count of uploads for a specific snapshot
func (r *UploadRepository) GetCountBySnapshot( func (r *UploadRepository) GetCountBySnapshot(ctx context.Context, snapshotID string) (int64, error) {
ctx context.Context, snapshotID string,
) (int64, error) {
query := `SELECT COUNT(*) FROM uploads WHERE snapshot_id = ?` query := `SELECT COUNT(*) FROM uploads WHERE snapshot_id = ?`
var count int64 var count int64
err := r.conn.QueryRowContext(ctx, query, snapshotID).Scan(&count) err := r.conn.QueryRowContext(ctx, query, snapshotID).Scan(&count)
if err != nil { if err != nil {
return 0, err return 0, err
} }
return count, nil return count, nil
} }

View File

@@ -1,5 +1,3 @@
// Package globals holds application-wide metadata (name, version,
// commit) that is populated at build time via linker flags.
package globals package globals
import ( import (
@@ -7,55 +5,27 @@ import (
) )
// Appname is the application name, populated from main(). // Appname is the application name, populated from main().
var Appname = "vaultik" //nolint:gochecknoglobals // set via -ldflags at build time var Appname string = "vaultik"
// Version is the application version, populated from main(). // Version is the application version, populated from main().
var Version = "dev" //nolint:gochecknoglobals // set via -ldflags at build time var Version string = "dev"
// Commit is the git commit hash, populated from main(). // Commit is the git commit hash, populated from main().
var Commit = "unknown" //nolint:gochecknoglobals // set via -ldflags at build time var Commit string = "unknown"
// CommitDate is the ISO-8601 date of the commit, populated from main().
var CommitDate = "unknown" //nolint:gochecknoglobals // set via -ldflags at build time
// Author identifies the upstream author of vaultik.
const Author = "Jeffrey Paul <sneak@sneak.berlin>"
// Homepage is the canonical URL for vaultik.
const Homepage = "https://sneak.berlin/go/vaultik"
// License is the SPDX identifier for the project license.
const License = "MIT"
// Globals contains application-wide configuration and metadata. // Globals contains application-wide configuration and metadata.
type Globals struct { type Globals struct {
Appname string Appname string
Version string Version string
Commit string Commit string
CommitDate string
StartTime time.Time StartTime time.Time
} }
// New creates and returns a new Globals instance initialized with the // New creates and returns a new Globals instance initialized with the package-level variables.
// package-level variables.
func New() (*Globals, error) { func New() (*Globals, error) {
return &Globals{ return &Globals{
Appname: Appname, Appname: Appname,
Version: Version, Version: Version,
Commit: Commit, Commit: Commit,
CommitDate: CommitDate,
}, nil }, nil
} }
// shortCommitLen is the number of commit-hash characters ShortCommit keeps.
const shortCommitLen = 12
// ShortCommit returns the first 12 chars of the commit hash, or the
// whole string if it's shorter (e.g. "unknown").
func (g *Globals) ShortCommit() string {
if len(g.Commit) > shortCommitLen {
return g.Commit[:shortCommitLen]
}
return g.Commit
}

View File

@@ -1,16 +1,12 @@
package globals_test package globals
import ( import (
"testing" "testing"
"sneak.berlin/go/vaultik/internal/globals"
) )
// TestGlobalsNew ensures the globals package initializes correctly // TestGlobalsNew ensures the globals package initializes correctly
func TestGlobalsNew(t *testing.T) { func TestGlobalsNew(t *testing.T) {
t.Parallel() g, err := New()
g, err := globals.New()
if err != nil { if err != nil {
t.Fatalf("Failed to create Globals: %v", err) t.Fatalf("Failed to create Globals: %v", err)
} }

View File

@@ -1,6 +1,4 @@
// Package log provides the application-wide structured logger: slog package log
// with a colorized TTY handler on terminals and JSON output otherwise.
package log //nolint:revive,nolintlint // stdlib log unused here; see #76
import ( import (
"context" "context"
@@ -14,12 +12,12 @@ import (
"golang.org/x/term" "golang.org/x/term"
) )
// Level represents the logging level. // LogLevel represents the logging level.
type Level int type LogLevel int
const ( const (
// LevelFatal represents a fatal error level that will exit the program. // LevelFatal represents a fatal error level that will exit the program.
LevelFatal Level = iota LevelFatal LogLevel = iota
// LevelError represents an error level. // LevelError represents an error level.
LevelError LevelError
// LevelWarn represents a warning level. // LevelWarn represents a warning level.
@@ -40,7 +38,6 @@ type Config struct {
Quiet bool Quiet bool
} }
//nolint:gochecknoglobals // package-level logger is the package's purpose
var logger *slog.Logger var logger *slog.Logger
// Initialize sets up the global logger based on the provided configuration. // Initialize sets up the global logger based on the provided configuration.
@@ -48,19 +45,14 @@ func Initialize(cfg Config) {
// Determine log level based on configuration // Determine log level based on configuration
var level slog.Level var level slog.Level
switch { if cfg.Cron || cfg.Quiet {
case cfg.Cron || cfg.Quiet: // In quiet/cron mode, only show errors
// In cron/quiet mode keep warnings and errors visible — the level = slog.LevelError
// whole point of --cron is to stay silent only on total } else if cfg.Debug || strings.Contains(os.Getenv("GODEBUG"), "vaultik") {
// success, so that anything cron emails to root is genuinely
// "something went wrong, look at it." A backup with stuck
// permission errors or skipped files should NOT be silent.
level = slog.LevelWarn
case cfg.Debug || strings.Contains(os.Getenv("GODEBUG"), "vaultik"):
level = slog.LevelDebug level = slog.LevelDebug
case cfg.Verbose: } else if cfg.Verbose {
level = slog.LevelInfo level = slog.LevelInfo
default: } else {
level = slog.LevelWarn level = slog.LevelWarn
} }
@@ -69,7 +61,7 @@ func Initialize(cfg Config) {
Level: level, Level: level,
} }
// Check if stdout is a TTY. // Check if stdout is a TTY
if term.IsTerminal(int(os.Stdout.Fd())) { if term.IsTerminal(int(os.Stdout.Fd())) {
// Use colorized TTY handler // Use colorized TTY handler
logger = slog.New(NewTTYHandler(os.Stdout, opts)) logger = slog.New(NewTTYHandler(os.Stdout, opts))
@@ -82,17 +74,12 @@ func Initialize(cfg Config) {
slog.SetDefault(logger) slog.SetDefault(logger)
} }
// callerSkipFrames is the number of stack frames between runtime.Caller
// and the code that invoked the package-level logging function.
const callerSkipFrames = 2
// getCaller returns the caller information as a string // getCaller returns the caller information as a string
func getCaller() string { func getCaller(skip int) string {
_, file, line, ok := runtime.Caller(callerSkipFrames) _, file, line, ok := runtime.Caller(skip)
if !ok { if !ok {
return "unknown" return "unknown"
} }
return fmt.Sprintf("%s:%d", filepath.Base(file), line) return fmt.Sprintf("%s:%d", filepath.Base(file), line)
} }
@@ -100,10 +87,9 @@ func getCaller() string {
func Fatal(msg string, args ...any) { func Fatal(msg string, args ...any) {
if logger != nil { if logger != nil {
// Add caller info to args // Add caller info to args
args = append(args, "caller", getCaller()) args = append(args, "caller", getCaller(2))
logger.Error(msg, args...) logger.Error(msg, args...)
} }
os.Exit(1) os.Exit(1)
} }
@@ -115,7 +101,7 @@ func Fatalf(format string, args ...any) {
// Error logs an error message. // Error logs an error message.
func Error(msg string, args ...any) { func Error(msg string, args ...any) {
if logger != nil { if logger != nil {
args = append(args, "caller", getCaller()) args = append(args, "caller", getCaller(2))
logger.Error(msg, args...) logger.Error(msg, args...)
} }
} }
@@ -128,7 +114,7 @@ func Errorf(format string, args ...any) {
// Warn logs a warning message. // Warn logs a warning message.
func Warn(msg string, args ...any) { func Warn(msg string, args ...any) {
if logger != nil { if logger != nil {
args = append(args, "caller", getCaller()) args = append(args, "caller", getCaller(2))
logger.Warn(msg, args...) logger.Warn(msg, args...)
} }
} }
@@ -141,7 +127,7 @@ func Warnf(format string, args ...any) {
// Notice logs a notice message (mapped to Info level). // Notice logs a notice message (mapped to Info level).
func Notice(msg string, args ...any) { func Notice(msg string, args ...any) {
if logger != nil { if logger != nil {
args = append(args, "caller", getCaller()) args = append(args, "caller", getCaller(2))
logger.Info(msg, args...) logger.Info(msg, args...)
} }
} }
@@ -154,7 +140,7 @@ func Noticef(format string, args ...any) {
// Info logs an informational message. // Info logs an informational message.
func Info(msg string, args ...any) { func Info(msg string, args ...any) {
if logger != nil { if logger != nil {
args = append(args, "caller", getCaller()) args = append(args, "caller", getCaller(2))
logger.Info(msg, args...) logger.Info(msg, args...)
} }
} }
@@ -167,7 +153,7 @@ func Infof(format string, args ...any) {
// Debug logs a debug message. // Debug logs a debug message.
func Debug(msg string, args ...any) { func Debug(msg string, args ...any) {
if logger != nil { if logger != nil {
args = append(args, "caller", getCaller()) args = append(args, "caller", getCaller(2))
logger.Debug(msg, args...) logger.Debug(msg, args...)
} }
} }
@@ -182,12 +168,11 @@ func With(args ...any) *slog.Logger {
if logger != nil { if logger != nil {
return logger.With(args...) return logger.With(args...)
} }
return slog.Default() return slog.Default()
} }
// WithContext returns a logger with the provided context. // WithContext returns a logger with the provided context.
func WithContext(_ context.Context) *slog.Logger { func WithContext(ctx context.Context) *slog.Logger {
return logger return logger
} }

View File

@@ -1,12 +1,10 @@
package log //nolint:revive,nolintlint // stdlib log unused here; see #76 package log
import ( import (
"go.uber.org/fx" "go.uber.org/fx"
) )
// Module exports logging functionality for dependency injection. // Module exports logging functionality for dependency injection.
//
//nolint:gochecknoglobals // fx module definitions are package globals
var Module = fx.Module("log", var Module = fx.Module("log",
fx.Invoke(func(cfg Config) { fx.Invoke(func(cfg Config) {
Initialize(cfg) Initialize(cfg)
@@ -14,12 +12,12 @@ var Module = fx.Module("log",
) )
// New creates a new logger configuration from provided options. // New creates a new logger configuration from provided options.
func New(opts Options) Config { func New(opts LogOptions) Config {
return Config(opts) return Config(opts)
} }
// Options are provided by the CLI. // LogOptions are provided by the CLI.
type Options struct { type LogOptions struct {
Verbose bool Verbose bool
Debug bool Debug bool
Cron bool Cron bool

View File

@@ -1,4 +1,4 @@
package log //nolint:revive,nolintlint // stdlib log unused here; see #76 package log
import ( import (
"context" "context"
@@ -33,7 +33,6 @@ func NewTTYHandler(out io.Writer, opts *slog.HandlerOptions) *TTYHandler {
if opts == nil { if opts == nil {
opts = &slog.HandlerOptions{} opts = &slog.HandlerOptions{}
} }
return &TTYHandler{ return &TTYHandler{
out: out, out: out,
opts: *opts, opts: *opts,
@@ -55,9 +54,7 @@ func (h *TTYHandler) Handle(_ context.Context, r slog.Record) error {
// Level and color // Level and color
level := r.Level.String() level := r.Level.String()
var levelColor string var levelColor string
switch r.Level { switch r.Level {
case slog.LevelDebug: case slog.LevelDebug:
levelColor = colorGray levelColor = colorGray
@@ -94,47 +91,38 @@ func (h *TTYHandler) Handle(_ context.Context, r slog.Record) error {
if a.Key == "bytes" { if a.Key == "bytes" {
value = formatBytes(a.Value.Int64()) value = formatBytes(a.Value.Int64())
} }
case slog.KindAny, slog.KindBool, slog.KindFloat64, slog.KindString,
slog.KindTime, slog.KindUint64, slog.KindGroup, slog.KindLogValuer:
// Plain string form above is already correct for these kinds.
default:
// Future kinds also use the plain string form.
} }
_, _ = fmt.Fprintf(h.out, " %s%s%s=%s%s%s", _, _ = fmt.Fprintf(h.out, " %s%s%s=%s%s%s",
colorCyan, a.Key, colorReset, colorCyan, a.Key, colorReset,
colorBlue, value, colorReset) colorBlue, value, colorReset)
return true return true
}) })
_, _ = fmt.Fprintln(h.out) _, _ = fmt.Fprintln(h.out)
return nil return nil
} }
// WithAttrs returns a new handler with the given attributes. // WithAttrs returns a new handler with the given attributes.
func (h *TTYHandler) WithAttrs(_ []slog.Attr) slog.Handler { func (h *TTYHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return h // Simplified for now return h // Simplified for now
} }
// WithGroup returns a new handler with the given group name. // WithGroup returns a new handler with the given group name.
func (h *TTYHandler) WithGroup(_ string) slog.Handler { func (h *TTYHandler) WithGroup(name string) slog.Handler {
return h // Simplified for now return h // Simplified for now
} }
// formatDuration formats a duration in a human-readable way // formatDuration formats a duration in a human-readable way
func formatDuration(d time.Duration) string { func formatDuration(d time.Duration) string {
switch { if d < time.Millisecond {
case d < time.Millisecond:
return fmt.Sprintf("%dµs", d.Microseconds()) return fmt.Sprintf("%dµs", d.Microseconds())
case d < time.Second: } else if d < time.Second {
return fmt.Sprintf("%dms", d.Milliseconds()) return fmt.Sprintf("%dms", d.Milliseconds())
case d < time.Minute: } else if d < time.Minute {
return fmt.Sprintf("%.1fs", d.Seconds()) return fmt.Sprintf("%.1fs", d.Seconds())
default:
return d.String()
} }
return d.String()
} }
// formatBytes formats bytes in a human-readable way // formatBytes formats bytes in a human-readable way
@@ -143,12 +131,10 @@ func formatBytes(b int64) string {
if b < unit { if b < unit {
return fmt.Sprintf("%d B", b) return fmt.Sprintf("%d B", b)
} }
div, exp := int64(unit), 0 div, exp := int64(unit), 0
for n := b / unit; n >= unit; n /= unit { for n := b / unit; n >= unit; n /= unit {
div *= unit div *= unit
exp++ exp++
} }
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp]) return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
} }

View File

@@ -1,5 +1,3 @@
// Package models defines shared value types describing files, chunks,
// blobs, and snapshots as they move through the backup pipeline.
package models package models
import ( import (
@@ -65,3 +63,10 @@ type Chunk struct {
Offset int64 Offset int64
Length int64 Length int64
} }
// DirtyPath represents a path marked for backup by inotify
type DirtyPath struct {
Path string
MarkedAt time.Time
EventType string // "create", "modify", "delete"
}

View File

@@ -1,21 +1,17 @@
package models_test package models
import ( import (
"testing" "testing"
"time" "time"
"sneak.berlin/go/vaultik/internal/models"
) )
// TestModelsCompilation ensures all model types can be instantiated // TestModelsCompilation ensures all model types can be instantiated
func TestModelsCompilation(t *testing.T) { func TestModelsCompilation(t *testing.T) {
t.Parallel()
// This test primarily serves as a compilation test // This test primarily serves as a compilation test
// to ensure all types are properly defined // to ensure all types are properly defined
// Test FileInfo // Test FileInfo
fi := &models.FileInfo{ fi := &FileInfo{
Path: "/test/file.txt", Path: "/test/file.txt",
MTime: time.Now(), MTime: time.Now(),
Size: 1024, Size: 1024,
@@ -25,7 +21,7 @@ func TestModelsCompilation(t *testing.T) {
} }
// Test ChunkInfo // Test ChunkInfo
ci := &models.ChunkInfo{ ci := &ChunkInfo{
Hash: "abc123", Hash: "abc123",
Size: 512, Size: 512,
Offset: 0, Offset: 0,
@@ -35,7 +31,7 @@ func TestModelsCompilation(t *testing.T) {
} }
// Test BlobInfo // Test BlobInfo
bi := &models.BlobInfo{ bi := &BlobInfo{
Hash: "blob123", Hash: "blob123",
CreatedAt: time.Now(), CreatedAt: time.Now(),
Size: 1024, Size: 1024,
@@ -46,7 +42,7 @@ func TestModelsCompilation(t *testing.T) {
} }
// Test Snapshot // Test Snapshot
s := &models.Snapshot{ s := &Snapshot{
ID: "2024-01-01T00:00:00Z", ID: "2024-01-01T00:00:00Z",
Hostname: "test-host", Hostname: "test-host",
Version: "1.0.0", Version: "1.0.0",

View File

@@ -21,13 +21,6 @@ type Lock struct {
path string path string
} }
const (
// lockDirPerm is the mode for the lock directory (owner-only).
lockDirPerm = 0o700
// pidFilePerm is the mode for the PID file (owner-only).
pidFilePerm = 0o600
)
// Acquire attempts to acquire a PID lock in the specified directory. // Acquire attempts to acquire a PID lock in the specified directory.
// If the lock file exists and the process is still running, it returns // If the lock file exists and the process is still running, it returns
// ErrAlreadyRunning with details about the existing process. // ErrAlreadyRunning with details about the existing process.
@@ -35,8 +28,7 @@ const (
// a Lock that must be released with Release(). // a Lock that must be released with Release().
func Acquire(lockDir string) (*Lock, error) { func Acquire(lockDir string) (*Lock, error) {
// Ensure lock directory exists // Ensure lock directory exists
err := os.MkdirAll(lockDir, lockDirPerm) if err := os.MkdirAll(lockDir, 0700); err != nil {
if err != nil {
return nil, fmt.Errorf("creating lock directory: %w", err) return nil, fmt.Errorf("creating lock directory: %w", err)
} }
@@ -54,9 +46,7 @@ func Acquire(lockDir string) (*Lock, error) {
// Write our PID // Write our PID
pid := os.Getpid() pid := os.Getpid()
if err := os.WriteFile(lockPath, []byte(strconv.Itoa(pid)), 0600); err != nil {
err = os.WriteFile(lockPath, []byte(strconv.Itoa(pid)), pidFilePerm)
if err != nil {
return nil, fmt.Errorf("writing PID file: %w", err) return nil, fmt.Errorf("writing PID file: %w", err)
} }
@@ -74,7 +64,7 @@ func (l *Lock) Release() error {
existingPID, err := readPIDFile(l.path) existingPID, err := readPIDFile(l.path)
if err != nil { if err != nil {
// File already gone or unreadable - that's fine // File already gone or unreadable - that's fine
return nil //nolint:nilerr // unreadable lock file means nothing to release return nil
} }
if existingPID != os.Getpid() { if existingPID != os.Getpid() {
@@ -82,19 +72,17 @@ func (l *Lock) Release() error {
return nil return nil
} }
err = os.Remove(l.path) if err := os.Remove(l.path); err != nil && !os.IsNotExist(err) {
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("removing PID file: %w", err) return fmt.Errorf("removing PID file: %w", err)
} }
l.path = "" // Prevent double-release l.path = "" // Prevent double-release
return nil return nil
} }
// readPIDFile reads and parses the PID from a lock file. // readPIDFile reads and parses the PID from a lock file.
func readPIDFile(path string) (int, error) { func readPIDFile(path string) (int, error) {
data, err := os.ReadFile(path) //nolint:gosec // G304: path is our own lock file data, err := os.ReadFile(path)
if err != nil { if err != nil {
return 0, err return 0, err
} }
@@ -116,6 +104,5 @@ func isProcessRunning(pid int) bool {
// On Unix, FindProcess always succeeds. We need to send signal 0 to check. // On Unix, FindProcess always succeeds. We need to send signal 0 to check.
err = process.Signal(syscall.Signal(0)) err = process.Signal(syscall.Signal(0))
return err == nil return err == nil
} }

View File

@@ -1,4 +1,4 @@
package pidlock_test package pidlock
import ( import (
"os" "os"
@@ -8,22 +8,18 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/pidlock"
) )
func TestAcquireAndRelease(t *testing.T) { func TestAcquireAndRelease(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir() tmpDir := t.TempDir()
// Acquire lock // Acquire lock
lock, err := pidlock.Acquire(tmpDir) lock, err := Acquire(tmpDir)
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, lock) require.NotNil(t, lock)
// Verify PID file exists with our PID // Verify PID file exists with our PID
pidPath := filepath.Join(tmpDir, "vaultik.pid") data, err := os.ReadFile(filepath.Join(tmpDir, "vaultik.pid"))
data, err := os.ReadFile(pidPath) //nolint:gosec // G304: test's own temp file
require.NoError(t, err) require.NoError(t, err)
pid, err := strconv.Atoi(string(data)) pid, err := strconv.Atoi(string(data))
require.NoError(t, err) require.NoError(t, err)
@@ -34,32 +30,26 @@ func TestAcquireAndRelease(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// Verify PID file is gone // Verify PID file is gone
_, err = os.Stat(pidPath) _, err = os.Stat(filepath.Join(tmpDir, "vaultik.pid"))
assert.True(t, os.IsNotExist(err)) assert.True(t, os.IsNotExist(err))
} }
func TestAcquireBlocksSecondInstance(t *testing.T) { func TestAcquireBlocksSecondInstance(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir() tmpDir := t.TempDir()
// Acquire first lock // Acquire first lock
lock1, err := pidlock.Acquire(tmpDir) lock1, err := Acquire(tmpDir)
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, lock1) require.NotNil(t, lock1)
defer func() { _ = lock1.Release() }() defer func() { _ = lock1.Release() }()
// Try to acquire second lock - should fail // Try to acquire second lock - should fail
lock2, err := pidlock.Acquire(tmpDir) lock2, err := Acquire(tmpDir)
require.ErrorIs(t, err, pidlock.ErrAlreadyRunning) assert.ErrorIs(t, err, ErrAlreadyRunning)
assert.Nil(t, lock2) assert.Nil(t, lock2)
} }
func TestAcquireWithStaleLock(t *testing.T) { func TestAcquireWithStaleLock(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir() tmpDir := t.TempDir()
// Write a stale PID file (PID that doesn't exist) // Write a stale PID file (PID that doesn't exist)
@@ -69,15 +59,13 @@ func TestAcquireWithStaleLock(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// Should be able to acquire lock (stale lock is cleaned up) // Should be able to acquire lock (stale lock is cleaned up)
lock, err := pidlock.Acquire(tmpDir) lock, err := Acquire(tmpDir)
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, lock) require.NotNil(t, lock)
defer func() { _ = lock.Release() }() defer func() { _ = lock.Release() }()
// Verify our PID is now in the file // Verify our PID is now in the file
data, err := os.ReadFile(pidPath) //nolint:gosec // G304: test's own temp file data, err := os.ReadFile(pidPath)
require.NoError(t, err) require.NoError(t, err)
pid, err := strconv.Atoi(string(data)) pid, err := strconv.Atoi(string(data))
require.NoError(t, err) require.NoError(t, err)
@@ -85,11 +73,9 @@ func TestAcquireWithStaleLock(t *testing.T) {
} }
func TestReleaseIsIdempotent(t *testing.T) { func TestReleaseIsIdempotent(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir() tmpDir := t.TempDir()
lock, err := pidlock.Acquire(tmpDir) lock, err := Acquire(tmpDir)
require.NoError(t, err) require.NoError(t, err)
// Release multiple times - should not error // Release multiple times - should not error
@@ -101,25 +87,18 @@ func TestReleaseIsIdempotent(t *testing.T) {
} }
func TestReleaseNilLock(t *testing.T) { func TestReleaseNilLock(t *testing.T) {
t.Parallel() var lock *Lock
var lock *pidlock.Lock
err := lock.Release() err := lock.Release()
require.NoError(t, err) assert.NoError(t, err)
} }
func TestAcquireCreatesDirectory(t *testing.T) { func TestAcquireCreatesDirectory(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir() tmpDir := t.TempDir()
nestedDir := filepath.Join(tmpDir, "nested", "dir") nestedDir := filepath.Join(tmpDir, "nested", "dir")
lock, err := pidlock.Acquire(nestedDir) lock, err := Acquire(nestedDir)
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, lock) require.NotNil(t, lock)
defer func() { _ = lock.Release() }() defer func() { _ = lock.Release() }()
// Verify directory was created // Verify directory was created

View File

@@ -1,10 +1,7 @@
// Package s3 wraps the AWS S3 SDK with a simplified client for vaultik's
// bucket-and-prefix scoped object operations.
package s3 package s3
import ( import (
"context" "context"
"errors"
"io" "io"
"sync/atomic" "sync/atomic"
@@ -13,7 +10,6 @@ import (
"github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/feature/s3/manager" "github.com/aws/aws-sdk-go-v2/feature/s3/manager"
"github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/s3"
s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/smithy-go/logging" "github.com/aws/smithy-go/logging"
) )
@@ -44,7 +40,7 @@ type Config struct {
// Used to suppress SDK warnings about checksums. // Used to suppress SDK warnings about checksums.
type nopLogger struct{} type nopLogger struct{}
func (nopLogger) Logf(_ logging.Classification, _ string, _ ...any) {} func (nopLogger) Logf(classification logging.Classification, format string, v ...interface{}) {}
// NewClient creates a new S3 client with the provided configuration. // NewClient creates a new S3 client with the provided configuration.
// It establishes a connection to the S3-compatible storage service and // It establishes a connection to the S3-compatible storage service and
@@ -94,7 +90,6 @@ func (c *Client) PutObject(ctx context.Context, key string, data io.Reader) erro
Key: aws.String(fullKey), Key: aws.String(fullKey),
Body: data, Body: data,
}) })
return err return err
} }
@@ -107,18 +102,13 @@ type ProgressCallback func(bytesUploaded int64) error
// The size parameter must be the exact size of the data to upload. // The size parameter must be the exact size of the data to upload.
// The progress callback is called periodically with the number of bytes uploaded. // The progress callback is called periodically with the number of bytes uploaded.
// Returns an error if the upload fails. // Returns an error if the upload fails.
func (c *Client) PutObjectWithProgress( func (c *Client) PutObjectWithProgress(ctx context.Context, key string, data io.Reader, size int64, progress ProgressCallback) error {
ctx context.Context, key string, data io.Reader,
size int64, progress ProgressCallback,
) error {
fullKey := c.prefix + key fullKey := c.prefix + key
// uploadPartSize is 10MB for better progress granularity.
const uploadPartSize = 10 * 1024 * 1024
// Create an uploader with the S3 client // Create an uploader with the S3 client
uploader := manager.NewUploader(c.s3Client, func(u *manager.Uploader) { uploader := manager.NewUploader(c.s3Client, func(u *manager.Uploader) {
u.PartSize = uploadPartSize // Set part size to 10MB for better progress granularity
u.PartSize = 10 * 1024 * 1024
}) })
// Create a progress reader that tracks upload progress // Create a progress reader that tracks upload progress
@@ -145,7 +135,6 @@ func (c *Client) PutObjectWithProgress(
// close the returned reader when done to avoid resource leaks. // close the returned reader when done to avoid resource leaks.
func (c *Client) GetObject(ctx context.Context, key string) (io.ReadCloser, error) { func (c *Client) GetObject(ctx context.Context, key string) (io.ReadCloser, error) {
fullKey := c.prefix + key fullKey := c.prefix + key
result, err := c.s3Client.GetObject(ctx, &s3.GetObjectInput{ result, err := c.s3Client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(c.bucket), Bucket: aws.String(c.bucket),
Key: aws.String(fullKey), Key: aws.String(fullKey),
@@ -153,7 +142,6 @@ func (c *Client) GetObject(ctx context.Context, key string) (io.ReadCloser, erro
if err != nil { if err != nil {
return nil, err return nil, err
} }
return result.Body, nil return result.Body, nil
} }
@@ -166,7 +154,6 @@ func (c *Client) DeleteObject(ctx context.Context, key string) error {
Bucket: aws.String(c.bucket), Bucket: aws.String(c.bucket),
Key: aws.String(fullKey), Key: aws.String(fullKey),
}) })
return err return err
} }
@@ -179,7 +166,6 @@ func (c *Client) ListObjects(ctx context.Context, prefix string) ([]string, erro
fullPrefix := c.prefix + prefix fullPrefix := c.prefix + prefix
var keys []string var keys []string
paginator := s3.NewListObjectsV2Paginator(c.s3Client, &s3.ListObjectsV2Input{ paginator := s3.NewListObjectsV2Paginator(c.s3Client, &s3.ListObjectsV2Input{
Bucket: aws.String(c.bucket), Bucket: aws.String(c.bucket),
Prefix: aws.String(fullPrefix), Prefix: aws.String(fullPrefix),
@@ -198,7 +184,6 @@ func (c *Client) ListObjects(ctx context.Context, prefix string) ([]string, erro
if len(key) > len(c.prefix) { if len(key) > len(c.prefix) {
key = key[len(c.prefix):] key = key[len(c.prefix):]
} }
keys = append(keys, key) keys = append(keys, key)
} }
} }
@@ -213,23 +198,15 @@ func (c *Client) ListObjects(ctx context.Context, prefix string) ([]string, erro
// Note: This method returns false for any error, not just "not found". // Note: This method returns false for any error, not just "not found".
func (c *Client) HeadObject(ctx context.Context, key string) (bool, error) { func (c *Client) HeadObject(ctx context.Context, key string) (bool, error) {
fullKey := c.prefix + key fullKey := c.prefix + key
_, err := c.s3Client.HeadObject(ctx, &s3.HeadObjectInput{ _, err := c.s3Client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: aws.String(c.bucket), Bucket: aws.String(c.bucket),
Key: aws.String(fullKey), Key: aws.String(fullKey),
}) })
if err != nil { if err != nil {
var ( // Check if it's a not found error
notFound *s3types.NotFound // TODO: Add proper error type checking
noSuchKey *s3types.NoSuchKey
)
if errors.As(err, &notFound) || errors.As(err, &noSuchKey) {
return false, nil return false, nil
} }
return false, err
}
return true, nil return true, nil
} }
@@ -248,9 +225,7 @@ type ObjectInfo struct {
// listing is complete or an error occurs. If an error occurs, it will be // listing is complete or an error occurs. If an error occurs, it will be
// sent as the last item with the Err field set. The recursive parameter // sent as the last item with the Err field set. The recursive parameter
// is currently unused but reserved for future use. // is currently unused but reserved for future use.
func (c *Client) ListObjectsStream( func (c *Client) ListObjectsStream(ctx context.Context, prefix string, recursive bool) <-chan ObjectInfo {
ctx context.Context, prefix string, _ bool,
) <-chan ObjectInfo {
ch := make(chan ObjectInfo) ch := make(chan ObjectInfo)
go func() { go func() {
@@ -267,7 +242,6 @@ func (c *Client) ListObjectsStream(
page, err := paginator.NextPage(ctx) page, err := paginator.NextPage(ctx)
if err != nil { if err != nil {
ch <- ObjectInfo{Err: err} ch <- ObjectInfo{Err: err}
return return
} }
@@ -278,7 +252,6 @@ func (c *Client) ListObjectsStream(
if len(key) > len(c.prefix) { if len(key) > len(c.prefix) {
key = key[len(c.prefix):] key = key[len(c.prefix):]
} }
ch <- ObjectInfo{ ch <- ObjectInfo{
Key: key, Key: key,
Size: *obj.Size, Size: *obj.Size,
@@ -297,7 +270,6 @@ func (c *Client) ListObjectsStream(
// Returns an error if the object doesn't exist or if the operation fails. // Returns an error if the object doesn't exist or if the operation fails.
func (c *Client) StatObject(ctx context.Context, key string) (*ObjectInfo, error) { func (c *Client) StatObject(ctx context.Context, key string) (*ObjectInfo, error) {
fullKey := c.prefix + key fullKey := c.prefix + key
result, err := c.s3Client.HeadObject(ctx, &s3.HeadObjectInput{ result, err := c.s3Client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: aws.String(c.bucket), Bucket: aws.String(c.bucket),
Key: aws.String(fullKey), Key: aws.String(fullKey),
@@ -336,7 +308,6 @@ func (c *Client) Endpoint() string {
if c.endpoint == "" { if c.endpoint == "" {
return "s3.amazonaws.com" return "s3.amazonaws.com"
} }
return c.endpoint return c.endpoint
} }
@@ -353,14 +324,11 @@ func (pr *progressReader) Read(p []byte) (int, error) {
n, err := pr.reader.Read(p) n, err := pr.reader.Read(p)
if n > 0 { if n > 0 {
atomic.AddInt64(&pr.read, int64(n)) atomic.AddInt64(&pr.read, int64(n))
if pr.callback != nil { if pr.callback != nil {
callbackErr := pr.callback(atomic.LoadInt64(&pr.read)) if callbackErr := pr.callback(atomic.LoadInt64(&pr.read)); callbackErr != nil {
if callbackErr != nil {
return n, callbackErr return n, callbackErr
} }
} }
} }
return n, err return n, err
} }

Some files were not shown because too many files have changed in this diff Show More