Compare commits
1
Commits
main
..
09dbe6f4c9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09dbe6f4c9 |
@@ -3,8 +3,6 @@
|
|||||||
*.md
|
*.md
|
||||||
LICENSE
|
LICENSE
|
||||||
vaultik
|
vaultik
|
||||||
dist
|
|
||||||
.tool
|
|
||||||
coverage.out
|
coverage.out
|
||||||
coverage.html
|
coverage.html
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
name: check
|
name: check
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main, next]
|
branches: [main]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main, next]
|
branches: [main]
|
||||||
jobs:
|
jobs:
|
||||||
check:
|
check:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
name: release
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags: ["v*"]
|
|
||||||
jobs:
|
|
||||||
release:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
# actions/checkout v4, 2024-09-16
|
|
||||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
|
||||||
with:
|
|
||||||
# goreleaser needs the tags and the full history: the version
|
|
||||||
# it stamps comes from the tag, and the changelog comes from
|
|
||||||
# the commits since the previous one. A shallow checkout
|
|
||||||
# silently produces a mislabelled release.
|
|
||||||
fetch-depth: 0
|
|
||||||
# goreleaser is not a compiler: it shells out to `go` for the
|
|
||||||
# `before:` hook and for every one of the four cross-compiles.
|
|
||||||
# Nothing else in this repo puts a Go toolchain on the runner --
|
|
||||||
# check.yml runs script/cibuild, which does all of its work inside
|
|
||||||
# the digest-pinned Dockerfile images -- so without this step the
|
|
||||||
# release either fails at the before-hook or, worse, ships binaries
|
|
||||||
# built by whatever Go the runner happens to carry.
|
|
||||||
#
|
|
||||||
# actions/setup-go would pin the action by commit sha, but the Go
|
|
||||||
# tarball it downloads at runtime is verified against no value in
|
|
||||||
# this repo, and the action exposes no checksum input.
|
|
||||||
# REPO_POLICIES.md requires every external reference to be pinned
|
|
||||||
# by hash with no exceptions, and this is the compiler that
|
|
||||||
# produces the published binaries -- the input where a substituted
|
|
||||||
# artifact matters most. So Go is installed the way goreleaser is:
|
|
||||||
# script/install-go downloads the exact archive for go.mod's `go`
|
|
||||||
# directive and refuses it unless its sha256 matches the value
|
|
||||||
# committed in the script, then puts .tool/go/bin on PATH for the
|
|
||||||
# steps below.
|
|
||||||
- name: Install Go
|
|
||||||
run: script/install-go
|
|
||||||
- name: Install goreleaser
|
|
||||||
run: script/install-goreleaser
|
|
||||||
- name: Release
|
|
||||||
run: script/release
|
|
||||||
env:
|
|
||||||
# RELEASE_TOKEN is a repository Actions secret: a Gitea access
|
|
||||||
# token with write access to this repository's releases (scope
|
|
||||||
# write:repository), owned by an account that can publish here.
|
|
||||||
# It is deliberately not the runner's automatic token, which is
|
|
||||||
# not guaranteed to carry that scope.
|
|
||||||
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
|
||||||
# Build with the toolchain install-go just verified, never a
|
|
||||||
# different one auto-downloaded from a `toolchain` directive:
|
|
||||||
# the point of the hash pin is that this exact compiler makes
|
|
||||||
# the release.
|
|
||||||
GOTOOLCHAIN: local
|
|
||||||
@@ -1,12 +1,6 @@
|
|||||||
# Binary
|
# Binary
|
||||||
/vaultik
|
/vaultik
|
||||||
|
|
||||||
# goreleaser output
|
|
||||||
/dist/
|
|
||||||
|
|
||||||
# Locally installed pinned tools (script/install-goreleaser)
|
|
||||||
/.tool/
|
|
||||||
|
|
||||||
# Test artifacts
|
# Test artifacts
|
||||||
*.out
|
*.out
|
||||||
*.test
|
*.test
|
||||||
|
|||||||
+1
-14
@@ -2,13 +2,6 @@ version: 2
|
|||||||
|
|
||||||
project_name: vaultik
|
project_name: vaultik
|
||||||
|
|
||||||
# This repo lives on Gitea, not GitHub. Without this block goreleaser
|
|
||||||
# talks to the GitHub API by default and a `goreleaser release` either
|
|
||||||
# fails outright or publishes somewhere nobody is looking.
|
|
||||||
gitea_urls:
|
|
||||||
api: https://git.eeqj.de/api/v1
|
|
||||||
download: https://git.eeqj.de
|
|
||||||
|
|
||||||
before:
|
before:
|
||||||
hooks:
|
hooks:
|
||||||
- go mod tidy
|
- go mod tidy
|
||||||
@@ -44,14 +37,8 @@ checksum:
|
|||||||
name_template: "checksums.txt"
|
name_template: "checksums.txt"
|
||||||
algorithm: sha256
|
algorithm: sha256
|
||||||
|
|
||||||
# A snapshot is not a release and must not name itself like one. The
|
|
||||||
# previous `{{ incpatch .Version }}-next` derived a plausible-looking
|
|
||||||
# release number from the last tag -- and with no tags in the repo at
|
|
||||||
# all, from goreleaser's fabricated v0.0.0. This produces the same
|
|
||||||
# string script/version produces for an untagged build, so a snapshot
|
|
||||||
# binary and a `make vaultik` binary of the same clean commit agree.
|
|
||||||
snapshot:
|
snapshot:
|
||||||
version_template: "dev-{{ slice .FullCommit 0 12 }}"
|
version_template: "{{ incpatch .Version }}-next"
|
||||||
|
|
||||||
changelog:
|
changelog:
|
||||||
sort: asc
|
sort: asc
|
||||||
|
|||||||
@@ -83,8 +83,8 @@ Version: 2025-06-08
|
|||||||
possible to mock or stub these side-effects in tests.
|
possible to mock or stub these side-effects in tests.
|
||||||
|
|
||||||
9. Always use structured logging. Log any relevant state/context with the
|
9. Always use structured logging. Log any relevant state/context with the
|
||||||
messages (but do not log secrets). If the log stream is not a terminal,
|
messages (but do not log secrets). If stdout is not a terminal, output
|
||||||
output the structured logs in jsonl format.
|
the structured logs in jsonl format.
|
||||||
|
|
||||||
10. Avoid using bare strings or numbers in code, especially if they appear
|
10. Avoid using bare strings or numbers in code, especially if they appear
|
||||||
anywhere more than once. Always define a constant (usually at the top
|
anywhere more than once. Always define a constant (usually at the top
|
||||||
@@ -104,12 +104,7 @@ Version: 2025-06-08
|
|||||||
|
|
||||||
13. Pre-1.0: NEVER write database migrations. There are no live databases
|
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
|
anywhere — every user's local index can be rebuilt from a fresh full
|
||||||
backup. To change the schema, edit `internal/database/schema/001.sql`
|
backup. When the schema changes, just change `schema.sql` (and any code
|
||||||
(and any code that touches the affected tables) directly; do not add new
|
that touches the affected tables). The local index is disposable until
|
||||||
numbered schema files. Those numbered files and the `schema_migrations`
|
1.0 ships and is tagged.
|
||||||
table they populate only bootstrap a fresh database — they are not an
|
|
||||||
upgrade path. The local index is disposable until 1.0 ships and is
|
|
||||||
tagged; once 1.0 is tagged that clause expires and the question of
|
|
||||||
upgrading existing indexes returns. See [`docs/DATAMODEL.md`](docs/DATAMODEL.md)
|
|
||||||
for the full explanation.
|
|
||||||
|
|
||||||
|
|||||||
+20
-29
@@ -63,7 +63,7 @@ A content-addressed unit of data. Files are split into variable-size chunks usin
|
|||||||
- `ChunkHash`: SHA256 hash of chunk content (primary key)
|
- `ChunkHash`: SHA256 hash of chunk content (primary key)
|
||||||
- `Size`: Chunk size in bytes
|
- `Size`: Chunk size in bytes
|
||||||
|
|
||||||
Chunk sizes vary between `avgChunkSize/4` and `avgChunkSize*4` (2.5MB-40MB for the 10MB default average).
|
Chunk sizes vary between `avgChunkSize/4` and `avgChunkSize*4` (typically 16KB-256KB for 64KB average).
|
||||||
|
|
||||||
#### FileChunk (`database.FileChunk`)
|
#### FileChunk (`database.FileChunk`)
|
||||||
Maps files to their constituent chunks:
|
Maps files to their constituent chunks:
|
||||||
@@ -74,16 +74,16 @@ Maps files to their constituent chunks:
|
|||||||
#### Blob (`database.Blob`)
|
#### Blob (`database.Blob`)
|
||||||
The final storage unit uploaded to S3. Contains many compressed and encrypted chunks:
|
The final storage unit uploaded to S3. Contains many compressed and encrypted chunks:
|
||||||
- `ID`: UUID assigned at creation
|
- `ID`: UUID assigned at creation
|
||||||
- `Hash`: `hex(SHA256(SHA256(uncompressed blob contents)))`, computed before compression and encryption (see [docs/REPOSTRUCTURE.md](docs/REPOSTRUCTURE.md#blobs-directory-blobs))
|
- `Hash`: SHA256 of final compressed+encrypted content
|
||||||
- `UncompressedSize`: Total raw chunk data before compression
|
- `UncompressedSize`: Total raw chunk data before compression
|
||||||
- `CompressedSize`: Size after zstd compression and age encryption
|
- `CompressedSize`: Size after zstd compression and age encryption
|
||||||
- `CreatedTS`, `FinishedTS`, `UploadedTS`: Lifecycle timestamps
|
- `CreatedTS`, `FinishedTS`, `UploadedTS`: Lifecycle timestamps
|
||||||
|
|
||||||
Blob creation process:
|
Blob creation process:
|
||||||
1. Chunks are accumulated (up to MaxBlobSize, typically 10GB)
|
1. Chunks are accumulated (up to MaxBlobSize, typically 10GB)
|
||||||
2. As each chunk is added, its uncompressed bytes are fed to a running SHA-256
|
2. Compressed with zstd
|
||||||
3. Concurrently, the same bytes are compressed with zstd, then encrypted with age (recipients configured in config), and streamed to storage
|
3. Encrypted with age (recipients configured in config)
|
||||||
4. On finalize, the blob's name is the double SHA-256 of the uncompressed contents — `hex(SHA256(SHA256(...)))` — not a hash of the compressed, encrypted bytes
|
4. SHA256 hash computed → becomes filename in S3
|
||||||
5. Uploaded to `blobs/{hash[0:2]}/{hash[2:4]}/{hash}`
|
5. Uploaded to `blobs/{hash[0:2]}/{hash[2:4]}/{hash}`
|
||||||
|
|
||||||
#### BlobChunk (`database.BlobChunk`)
|
#### BlobChunk (`database.BlobChunk`)
|
||||||
@@ -120,7 +120,7 @@ The CLI uses fx for dependency injection. Here's the instantiation order:
|
|||||||
```go
|
```go
|
||||||
// cli/app.go: NewApp()
|
// cli/app.go: NewApp()
|
||||||
fx.New(
|
fx.New(
|
||||||
fx.Supply(config.Path(opts.ConfigPath)), // 1. Config path
|
fx.Supply(config.ConfigPath(opts.ConfigPath)), // 1. Config path
|
||||||
fx.Supply(opts.LogOptions), // 2. Log options
|
fx.Supply(opts.LogOptions), // 2. Log options
|
||||||
fx.Provide(globals.New), // 3. Globals
|
fx.Provide(globals.New), // 3. Globals
|
||||||
fx.Provide(log.New), // 4. Logger config
|
fx.Provide(log.New), // 4. Logger config
|
||||||
@@ -193,7 +193,7 @@ scanner := v.ScannerFactory(snapshot.ScannerParams{
|
|||||||
- **Created by**: `chunker.NewChunker(avgChunkSize)`
|
- **Created by**: `chunker.NewChunker(avgChunkSize)`
|
||||||
- **When**: Inside `snapshot.NewScanner()`
|
- **When**: Inside `snapshot.NewScanner()`
|
||||||
- **Configuration**:
|
- **Configuration**:
|
||||||
- `avgChunkSize`: From config (default 10MB)
|
- `avgChunkSize`: From config (typically 64KB)
|
||||||
- `minChunkSize`: avgChunkSize / 4
|
- `minChunkSize`: avgChunkSize / 4
|
||||||
- `maxChunkSize`: avgChunkSize * 4
|
- `maxChunkSize`: avgChunkSize * 4
|
||||||
|
|
||||||
@@ -284,10 +284,9 @@ Manages snapshot lifecycle and metadata export.
|
|||||||
|
|
||||||
Key methods:
|
Key methods:
|
||||||
- `CreateSnapshot(ctx, hostname, version, commit)` → Create snapshot record
|
- `CreateSnapshot(ctx, hostname, version, commit)` → Create snapshot record
|
||||||
- `PopulateSnapshotBlobs(ctx, snapshotID)` → Record every blob the snapshot references
|
- `CompleteSnapshot(ctx, snapshotID)` → Mark snapshot complete
|
||||||
- `ExportSnapshotMetadata(ctx, dbPath, snapshotID)` → Export to S3
|
- `ExportSnapshotMetadata(ctx, dbPath, snapshotID)` → Export to S3
|
||||||
- `MarkSnapshotComplete(ctx, snapshotID)` → Record completion, only after a successful export
|
- `CleanupIncompleteSnapshots(ctx, hostname)` → Remove failed snapshots
|
||||||
- `CompleteSnapshot(ctx, snapshotID)` → Convenience: populate blobs, then mark complete (no export between)
|
|
||||||
|
|
||||||
### `internal/database`
|
### `internal/database`
|
||||||
SQLite database for local index. Single-writer mode for thread safety.
|
SQLite database for local index. Single-writer mode for thread safety.
|
||||||
@@ -308,7 +307,7 @@ Repository interfaces:
|
|||||||
```
|
```
|
||||||
CreateSnapshot(opts)
|
CreateSnapshot(opts)
|
||||||
│
|
│
|
||||||
├─► PruneDatabase() // Critical: avoid dedup errors
|
├─► CleanupIncompleteSnapshots() // Critical: avoid dedup errors
|
||||||
│
|
│
|
||||||
├─► SnapshotManager.CreateSnapshot() // Create DB record
|
├─► SnapshotManager.CreateSnapshot() // Create DB record
|
||||||
│
|
│
|
||||||
@@ -337,18 +336,16 @@ CreateSnapshot(opts)
|
|||||||
│
|
│
|
||||||
├─► SnapshotManager.UpdateSnapshotStatsExtended()
|
├─► SnapshotManager.UpdateSnapshotStatsExtended()
|
||||||
│
|
│
|
||||||
├─► SnapshotManager.PopulateSnapshotBlobs() // record referenced blobs
|
├─► SnapshotManager.CompleteSnapshot()
|
||||||
│
|
│
|
||||||
├─► 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 (VACUUM)
|
||||||
│ ├─► Compress binary SQLite with zstd
|
├─► Compress binary SQLite with zstd
|
||||||
│ ├─► Encrypt with age
|
├─► Encrypt with age
|
||||||
│ ├─► Upload db.zst.age to storage
|
├─► Upload db.zst.age to storage
|
||||||
│ └─► Upload manifest.json.zst to storage
|
└─► Upload manifest.json.zst to storage
|
||||||
│
|
|
||||||
└─► SnapshotManager.MarkSnapshotComplete() // only after the export succeeds
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Deduplication Strategy
|
## Deduplication Strategy
|
||||||
@@ -369,17 +366,11 @@ bucket/
|
|||||||
│ └── {full-hash} # Compressed+encrypted blob
|
│ └── {full-hash} # Compressed+encrypted blob
|
||||||
│
|
│
|
||||||
└── metadata/
|
└── metadata/
|
||||||
└── {remote-key}/
|
└── {snapshot-id}/
|
||||||
├── db.zst.age # Encrypted binary SQLite database
|
├── db.zst.age # Encrypted binary SQLite database
|
||||||
└── manifest.json.zst # Blob list (for pruning/verification)
|
└── manifest.json.zst # Blob list (for pruning/verification)
|
||||||
```
|
```
|
||||||
|
|
||||||
The `{remote-key}` directory name is a one-way double SHA-256 hash of the human
|
|
||||||
snapshot ID, so the human ID (hostname, snapshot name, timestamp) is never
|
|
||||||
written to the store as a directory name. See
|
|
||||||
[docs/REPOSTRUCTURE.md](docs/REPOSTRUCTURE.md#remote-key-derivation) for the
|
|
||||||
derivation and a worked example.
|
|
||||||
|
|
||||||
## Thread Safety
|
## Thread Safety
|
||||||
|
|
||||||
- `Packer`: Thread-safe via mutex. Multiple goroutines can call `AddChunk()`.
|
- `Packer`: Thread-safe via mutex. Multiple goroutines can call `AddChunk()`.
|
||||||
|
|||||||
+56
-65
@@ -1,28 +1,13 @@
|
|||||||
# This file has no lint stage, deliberately.
|
# Lint stage
|
||||||
#
|
#
|
||||||
# Linting lives in Dockerfile.lint, built by script/lint, and
|
# This FROM line is the single source of truth for the linter version:
|
||||||
# script/cibuild builds both. A lint stage here would have to either
|
# script/lint parses the image reference out of it and runs that exact
|
||||||
# shell out to `make lint` -- which is now `docker build`, so
|
# image, so a local `make lint` and CI use the same linter. Bump the
|
||||||
# docker-in-docker inside a BuildKit step with no daemon -- or call
|
# linter here (tag AND digest) and nowhere else.
|
||||||
# golangci-lint directly, which would mean a second, independently
|
|
||||||
# bumpable digest pin for the linter alongside the one in
|
|
||||||
# Dockerfile.lint. Two pins for one tool is the drift that
|
|
||||||
# https://git.eeqj.de/sneak/vaultik/issues/78 was filed over. See
|
|
||||||
# https://git.eeqj.de/sneak/vaultik/issues/113 for the ruling.
|
|
||||||
#
|
#
|
||||||
# Consequence, stated rather than left to be discovered: script/docker
|
# golangci/golangci-lint:v2.12.2-alpine, 2026-08-07
|
||||||
# builds this file only and therefore does not lint. `make fmt-check`
|
FROM golangci/golangci-lint:v2.12.2-alpine@sha256:91b27804074a0bacea298707f016911e60cf0cdbc6c7bf5ccacb5f0606d18d60 AS lint
|
||||||
# and `make test` still run here, so what a green build of this file
|
|
||||||
# means is "formatted, tested, and it compiles" -- the lint verdict
|
|
||||||
# comes from script/lint or script/cibuild.
|
|
||||||
|
|
||||||
# Build stage
|
|
||||||
# golang:1.26.1-alpine, 2026-03-17
|
|
||||||
FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder
|
|
||||||
|
|
||||||
# Build tooling: make, plus a C toolchain because `go test -race` needs cgo.
|
|
||||||
# The sqlite driver is pure Go (modernc.org/sqlite), so no sqlite library or
|
|
||||||
# CLI is required.
|
|
||||||
RUN apk add --no-cache make build-base
|
RUN apk add --no-cache make build-base
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
@@ -34,67 +19,73 @@ RUN go mod download
|
|||||||
# Copy source code
|
# Copy source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Run the format check and the tests.
|
# Run formatting check and linter.
|
||||||
#
|
#
|
||||||
# CHECK_EPOCH must stay immediately above these RUNs. These layers are
|
# CHECK_EPOCH must stay immediately above these RUNs. These layers are
|
||||||
# keyed on its value, so they are cache-eligible only for a value
|
# keyed on its value, so they are cache-eligible only for a value
|
||||||
# already built against this same tree. script/cibuild and script/docker
|
# already built against this same tree. script/cibuild passes a fresh
|
||||||
# each pass a fresh value on every invocation, which is what makes their
|
# value on every invocation, which is what makes its green mean the
|
||||||
# green mean the checks really executed.
|
# checks really executed.
|
||||||
#
|
#
|
||||||
# The value is expanded into each check command rather than left to a
|
# The guarantee is conditional on that fresh value, not absolute. A
|
||||||
# bare declaration, so the cache miss does not depend on BuildKit's
|
# build that omits --build-arg -- a bare `docker build .` -- gets an
|
||||||
# unreferenced-ARG handling staying as it is. It also puts the epoch in
|
# empty CHECK_EPOCH, and an empty string is a constant: the first such
|
||||||
# the build log, where a reader can see the layer was keyed fresh.
|
# build runs the checks, and every one after it on an unchanged tree
|
||||||
|
# replays these layers from cache, never executing a check and still
|
||||||
|
# exiting 0, a green nothing earned. Gate through script/cibuild.
|
||||||
|
# Making the missing-arg case fail loudly instead is tracked in #91.
|
||||||
#
|
#
|
||||||
# The guard is what makes a build that omits --build-arg fail instead of
|
# CHECK_EPOCH is deliberately not referenced by the commands below: a
|
||||||
# lie. An unset ARG is an empty string, and an empty string is a
|
# declared-but-unreferenced ARG does enter BuildKit's cache key, which
|
||||||
# perfectly stable cache key: without the guard the first such build
|
# is measured on this host rather than assumed (PR #89). Upstream
|
||||||
# runs the checks and every one after it on an unchanged tree replays
|
# sneak/prompts #26 prefers expanding the value into the command so
|
||||||
# these layers from cache, executes nothing, and still exits 0. Failed
|
# that the miss is contractual rather than dependent on that behavior
|
||||||
# steps are never cached, so the guard fails on EVERY invocation rather
|
# staying as it is; adopting that here is tracked in #91. Do not delete
|
||||||
# than once -- a bare `docker build .` is a loud error, not a quiet
|
# this ARG as dead code -- the gate depends on it.
|
||||||
# green. Do not give CHECK_EPOCH a default value; a default would
|
|
||||||
# satisfy the guard with a constant and restore the hole.
|
|
||||||
#
|
#
|
||||||
|
# ARG scope is per-stage, so the builder stage declares its own.
|
||||||
# Everything above this line (apk, go.mod, `go mod download`) is
|
# Everything above this line (apk, go.mod, `go mod download`) is
|
||||||
# deliberately outside the busted range and keeps caching.
|
# deliberately outside the busted range and keeps caching.
|
||||||
ARG CHECK_EPOCH
|
ARG CHECK_EPOCH
|
||||||
RUN [ -n "$CHECK_EPOCH" ] || exit 1
|
RUN make fmt-check
|
||||||
RUN echo "check epoch: ${CHECK_EPOCH}" && make fmt-check
|
RUN make lint
|
||||||
RUN echo "check epoch: ${CHECK_EPOCH}" && make test
|
|
||||||
|
# Build stage
|
||||||
|
# golang:1.26.1-alpine, 2026-03-17
|
||||||
|
FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder
|
||||||
|
|
||||||
|
# Depend on lint stage passing
|
||||||
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
|
|
||||||
# Version, commit and build date are computed on the host by
|
|
||||||
# script/docker and script/cibuild (where .git exists) and passed in as
|
|
||||||
# build args. The build context excludes .git (see .dockerignore), so
|
|
||||||
# the build cannot derive them itself: it used to try, with `git
|
|
||||||
# rev-parse` inside this stage, and always got "unknown". VERSION comes
|
|
||||||
# from script/version, the source of truth shared with the Makefile, so
|
|
||||||
# it carries the same tag / dev-<sha> / -dirty rules and a Docker image
|
|
||||||
# reports the same string a local build of the same tree would.
|
|
||||||
#
|
|
||||||
# The defaults are the fallback for a bare `docker build .` that passes
|
|
||||||
# none of them: an unset arg would otherwise stamp an empty string and
|
|
||||||
# produce an image that cannot report its own version, commit or date.
|
|
||||||
# They match what an out-of-git build reports elsewhere.
|
|
||||||
#
|
|
||||||
# These ARGs sit here, after the checks, rather than at the top of the
|
|
||||||
# stage: every commit changes their values, and a value change
|
|
||||||
# invalidates all layers below the ARG. Declared up top they would bust
|
|
||||||
# `go mod download`; here they only rekey this build layer, which the
|
|
||||||
# COPY of the sources above already rebuilds on any change anyway.
|
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
ARG COMMIT=unknown
|
|
||||||
ARG COMMIT_DATE=unknown
|
# Install build dependencies for CGO (mattn/go-sqlite3) and sqlite3 CLI (tests)
|
||||||
|
RUN apk add --no-cache make build-base sqlite
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Copy go mod files first for better layer caching
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Run tests. See the CHECK_EPOCH comment in the lint stage, including
|
||||||
|
# the conditions the guarantee depends on; ARG scope is per-stage, so
|
||||||
|
# this stage needs its own declaration, and it must stay immediately
|
||||||
|
# above the check RUN.
|
||||||
|
ARG CHECK_EPOCH
|
||||||
|
RUN make test
|
||||||
|
|
||||||
# Build (pure Go, no CGO required since we use modernc.org/sqlite)
|
# Build (pure Go, no CGO required since we use modernc.org/sqlite)
|
||||||
RUN CGO_ENABLED=0 go build -ldflags "-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=${COMMIT_DATE}'" -o /vaultik ./cmd/vaultik
|
RUN CGO_ENABLED=0 go build -ldflags "-X 'sneak.berlin/go/vaultik/internal/globals.Version=${VERSION}' -X 'sneak.berlin/go/vaultik/internal/globals.Commit=$(git rev-parse HEAD 2>/dev/null || echo unknown)' -X 'sneak.berlin/go/vaultik/internal/globals.CommitDate=$(git show -s --format=%cs 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
|
||||||
FROM alpine:3.21@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
|
FROM alpine:3.21@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
|
||||||
|
|
||||||
RUN apk add --no-cache ca-certificates
|
RUN apk add --no-cache ca-certificates sqlite
|
||||||
|
|
||||||
# Copy binary from builder
|
# Copy binary from builder
|
||||||
COPY --from=builder /vaultik /usr/local/bin/vaultik
|
COPY --from=builder /vaultik /usr/local/bin/vaultik
|
||||||
|
|||||||
-104
@@ -1,104 +0,0 @@
|
|||||||
# Lint image.
|
|
||||||
#
|
|
||||||
# Every lint run in this repo happens inside this image, invoked through
|
|
||||||
# script/lint, and linting is a BUILD STEP rather than a container
|
|
||||||
# command: a successful build of this file IS a clean lint. That shape
|
|
||||||
# also works where the docker daemon is remote and bind mounts are
|
|
||||||
# impossible, which `docker run` against a mounted worktree does not.
|
|
||||||
#
|
|
||||||
# This FROM line is the single source of truth for the linter version in
|
|
||||||
# this repo. Nothing else pins golangci-lint: the product Dockerfile has
|
|
||||||
# no lint stage, deliberately, so there is no second digest to bump and
|
|
||||||
# no pair of pins that can drift apart. Bump the tag AND the digest here
|
|
||||||
# and nowhere else.
|
|
||||||
#
|
|
||||||
# Note for readers coming from REPO_POLICIES.md: that document still
|
|
||||||
# describes the older pattern, a lint stage inside the product
|
|
||||||
# Dockerfile wired up with `COPY --from=lint /src/go.sum /dev/null`.
|
|
||||||
# That pattern is superseded here by the owner's ruling recorded in
|
|
||||||
# https://git.eeqj.de/sneak/vaultik/issues/113 -- lint runs in its own
|
|
||||||
# image, per run, with its own cache and its own lock, which is what
|
|
||||||
# makes concurrent runs on one host safe. The policy text is org-wide
|
|
||||||
# and is being amended separately; this file is what this repo does.
|
|
||||||
#
|
|
||||||
# golangci/golangci-lint:v2.12.2, 2026-08-10
|
|
||||||
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240
|
|
||||||
|
|
||||||
WORKDIR /src
|
|
||||||
|
|
||||||
# Copy the dependency manifests first so the module download layer stays
|
|
||||||
# cached until they change. Everything above the ARG below is cacheable
|
|
||||||
# on purpose; a cold module download on every lint would make the inner
|
|
||||||
# loop unusable and buys nothing, because it is not what the gate is
|
|
||||||
# asserting.
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Force the check layers to execute on every invocation.
|
|
||||||
#
|
|
||||||
# CHECK_EPOCH must stay immediately above the RUNs below. Those layers
|
|
||||||
# are keyed on its value, so they are cache-eligible only for a value
|
|
||||||
# already built against this same tree; script/lint and script/cibuild
|
|
||||||
# each pass a fresh value on every invocation, which is what makes their
|
|
||||||
# green mean the linter really ran. Without it, `docker build -f
|
|
||||||
# Dockerfile.lint .` on an unchanged tree exits 0 in well under a second
|
|
||||||
# having linted nothing.
|
|
||||||
#
|
|
||||||
# The value is expanded into each check command itself rather than left
|
|
||||||
# to a bare declaration, so the cache miss does not depend on BuildKit's
|
|
||||||
# unreferenced-ARG handling staying as it is. It also puts the epoch in
|
|
||||||
# the build log, where a reader can see the layer was keyed fresh.
|
|
||||||
#
|
|
||||||
# The guard is what makes a build that omits --build-arg fail instead of
|
|
||||||
# lie. An unset ARG is an empty string, and an empty string is a
|
|
||||||
# perfectly stable cache key: without the guard the first such build
|
|
||||||
# lints and every one after it on an unchanged tree replays this layer,
|
|
||||||
# executes nothing, and still exits 0. Failed steps are never cached, so
|
|
||||||
# the guard fails on EVERY invocation rather than once. Do not give
|
|
||||||
# CHECK_EPOCH a default value; a default would satisfy the guard with a
|
|
||||||
# constant and restore the hole.
|
|
||||||
ARG CHECK_EPOCH
|
|
||||||
RUN [ -n "$CHECK_EPOCH" ] || exit 1
|
|
||||||
|
|
||||||
# Validate .golangci.yml before linting with it.
|
|
||||||
#
|
|
||||||
# This is not belt-and-braces; it closes a hole that `golangci-lint run`
|
|
||||||
# leaves wide open. `run` rejects YAML it cannot PARSE, but it silently
|
|
||||||
# IGNORES an unknown top-level KEY. Renaming `linters:` to `linterz:` --
|
|
||||||
# one character -- discards `default: all`, the whole disable list and
|
|
||||||
# every threshold, leaves only golangci-lint's small default linter set
|
|
||||||
# running, and exits 0 reporting `0 issues.` on a tree the real config
|
|
||||||
# fails. Demonstrated on this repo at this pin, recorded on
|
|
||||||
# https://git.eeqj.de/sneak/vaultik/pulls/114: with a planted
|
|
||||||
# over-length line, `script/lint` exits 1 naming the `revive` finding
|
|
||||||
# with `linters:` and exits 0 with `linterz:`. A set-but-ineffective
|
|
||||||
# config quietly falling back to defaults is precisely the false-green
|
|
||||||
# class this gate exists to eliminate, so it must not sit in the gate's
|
|
||||||
# own configuration.
|
|
||||||
#
|
|
||||||
# `config verify` catches it, and it does so OFFLINE at this pinned
|
|
||||||
# version -- verified, not assumed. Under `docker run --network none`
|
|
||||||
# against the pinned digest it exits 0 on this repo's config and exits 3
|
|
||||||
# on the `linterz:` variant with `additional properties 'linterz' not
|
|
||||||
# allowed`. An earlier revision of this file asserted the opposite, that
|
|
||||||
# the schema is fetched over live HTTPS from an unpinned URL, and used
|
|
||||||
# that to justify omitting this line. That claim was false at v2.12.2;
|
|
||||||
# the schema is embedded. If a future bump reintroduces a network fetch
|
|
||||||
# the failure is loud and this comment is where to record it.
|
|
||||||
#
|
|
||||||
# It is keyed on CHECK_EPOCH, like the lint run below, so it executes on
|
|
||||||
# every invocation. Content-addressing alone would arguably be enough --
|
|
||||||
# .golangci.yml arrives through `COPY . .`, so a cache hit here implies
|
|
||||||
# a byte-identical config was validated when the layer really ran. That
|
|
||||||
# argument is exactly the one that would also excuse caching the lint
|
|
||||||
# layer, and this repo has ruled it insufficient: a cached check layer
|
|
||||||
# checks nothing, and the cost of being wrong is silent. Forcing it costs
|
|
||||||
# milliseconds and puts the epoch in the log, where a reader can see that
|
|
||||||
# this validation ran rather than being replayed.
|
|
||||||
RUN echo "check epoch: ${CHECK_EPOCH}" && \
|
|
||||||
golangci-lint config verify --config .golangci.yml
|
|
||||||
|
|
||||||
RUN echo "check epoch: ${CHECK_EPOCH}" && \
|
|
||||||
golangci-lint run --config .golangci.yml ./...
|
|
||||||
@@ -1,20 +1,7 @@
|
|||||||
.PHONY: all bootstrap setup check test lint lint-fix fmt fmt-check build clean deps test-coverage local install release release-snapshot docker hooks
|
.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
|
||||||
|
|
||||||
# Version number, derived from git by script/version -- the tag when
|
# Version number
|
||||||
# HEAD is on one, otherwise dev-<sha>. This used to be a hardcoded
|
VERSION := 1.0.0-rc.1
|
||||||
# constant, which meant every local build claimed to be a release that
|
|
||||||
# had never been tagged.
|
|
||||||
VERSION := $(shell script/version)
|
|
||||||
|
|
||||||
# $(shell) discards exit status, so a script/version that is missing,
|
|
||||||
# non-executable or broken would otherwise leave VERSION empty and every
|
|
||||||
# binary built here would print "vaultik " with no version at all. A
|
|
||||||
# build that cannot determine what it is must not produce an artifact.
|
|
||||||
ifeq ($(strip $(VERSION)),)
|
|
||||||
$(error script/version produced no version string; a build that cannot \
|
|
||||||
determine its version will not be made. Check that script/version exists \
|
|
||||||
and is executable)
|
|
||||||
endif
|
|
||||||
|
|
||||||
# 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")
|
||||||
@@ -40,13 +27,7 @@ setup:
|
|||||||
check:
|
check:
|
||||||
@script/check
|
@script/check
|
||||||
|
|
||||||
# Run tests only. This runs the ENTIRE suite -- there is no separate
|
# Run tests only.
|
||||||
# integration target and no build-tagged subset held back. In
|
|
||||||
# particular internal/vaultik/integration_test.go, which does full
|
|
||||||
# chunk -> pack -> encrypt -> upload -> restore round-trips, runs here.
|
|
||||||
# A `test-integration` target used to exist and was removed: no file in
|
|
||||||
# the repo carried a build tag, so `-tags=integration` selected nothing
|
|
||||||
# extra and the target was an exact duplicate of this one.
|
|
||||||
test:
|
test:
|
||||||
@script/test
|
@script/test
|
||||||
|
|
||||||
@@ -66,18 +47,7 @@ lint:
|
|||||||
lint-fix:
|
lint-fix:
|
||||||
@script/lint-fix
|
@script/lint-fix
|
||||||
|
|
||||||
# Build binary. `build` is the name the org convention reaches for and
|
# Build binary.
|
||||||
# the one a caller checks the exit code of; `vaultik` is the file rule
|
|
||||||
# that does the work, so an unchanged tree still short-circuits.
|
|
||||||
#
|
|
||||||
# This alias is not decorative. `build` was listed in .PHONY with no
|
|
||||||
# rule, and a phony target with no prerequisites and no recipe is
|
|
||||||
# already satisfied: `make build` printed "Nothing to be done" and
|
|
||||||
# exited 0 without producing a binary (issue #110). Every name in
|
|
||||||
# .PHONY needs a rule for that reason; TestPhonyTargetsAllHaveRules in
|
|
||||||
# cmd/vaultik keeps it that way.
|
|
||||||
build: vaultik
|
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -87,21 +57,22 @@ clean:
|
|||||||
go clean
|
go clean
|
||||||
|
|
||||||
# Install dependencies. The linter is deliberately not installed here:
|
# Install dependencies. The linter is deliberately not installed here:
|
||||||
# script/lint lints by building Dockerfile.lint, whose FROM line is the
|
# script/lint runs the digest-pinned golangci-lint image declared by the
|
||||||
# single source of truth for the linter version. A second, separately
|
# Dockerfile's lint stage, which is the single source of truth for the
|
||||||
# pinned copy on PATH could drift from it and make a local `make lint`
|
# linter version. A second, separately pinned copy on PATH could drift
|
||||||
# disagree with CI.
|
# from it and make a local `make lint` disagree with CI.
|
||||||
deps:
|
deps:
|
||||||
go mod download
|
go mod download
|
||||||
|
|
||||||
# Run tests with coverage. -count=1 for the same reason script/test
|
# Run tests with coverage.
|
||||||
# uses it: without it an unchanged package is served from Go's test
|
|
||||||
# result cache, and a coverage profile assembled from cached results
|
|
||||||
# describes a run that did not happen.
|
|
||||||
test-coverage:
|
test-coverage:
|
||||||
go test -v -count=1 -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.
|
||||||
|
test-integration:
|
||||||
|
go test -v -tags=integration ./...
|
||||||
|
|
||||||
local:
|
local:
|
||||||
VAULTIK_CONFIG=$(HOME)/etc/vaultik/config.yml ./vaultik snapshot --debug list 2>&1
|
VAULTIK_CONFIG=$(HOME)/etc/vaultik/config.yml ./vaultik snapshot --debug list 2>&1
|
||||||
VAULTIK_CONFIG=$(HOME)/etc/vaultik/config.yml ./vaultik snapshot --debug create 2>&1
|
VAULTIK_CONFIG=$(HOME)/etc/vaultik/config.yml ./vaultik snapshot --debug create 2>&1
|
||||||
@@ -111,11 +82,11 @@ install: vaultik
|
|||||||
|
|
||||||
# Build and publish release artifacts (linux/darwin × amd64/arm64) via goreleaser.
|
# Build and publish release artifacts (linux/darwin × amd64/arm64) via goreleaser.
|
||||||
release:
|
release:
|
||||||
@script/release
|
goreleaser release --clean
|
||||||
|
|
||||||
# Dry-run a release build without publishing or tagging.
|
# Dry-run a release build without publishing or tagging.
|
||||||
release-snapshot:
|
release-snapshot:
|
||||||
@script/release-snapshot
|
goreleaser release --clean --snapshot
|
||||||
|
|
||||||
# Build Docker image.
|
# Build Docker image.
|
||||||
docker:
|
docker:
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ vaultik snapshot list
|
|||||||
|
|
||||||
Features:
|
Features:
|
||||||
|
|
||||||
* modern encryption ([age](https://age-encryption.org/), X25519 + ChaCha20-Poly1305)
|
* modern encryption ([age](https://age-encryption.org/), X25519 + XChaCha20-Poly1305)
|
||||||
* content-defined chunking with deduplication (FastCDC)
|
* content-defined chunking with deduplication (FastCDC)
|
||||||
* incremental backups (only changed files are re-chunked)
|
* incremental backups (only changed files are re-chunked)
|
||||||
* multithreaded zstd compression at configurable levels
|
* multithreaded zstd compression at configurable levels
|
||||||
@@ -71,80 +71,19 @@ Requirements that no existing tool meets:
|
|||||||
## daily use
|
## daily use
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# verify a snapshot (shallow: checks all blobs are present with the listed size)
|
# verify a snapshot (shallow: checks all blobs exist)
|
||||||
vaultik snapshot verify <snapshot-id>
|
vaultik snapshot verify <snapshot-id>
|
||||||
|
|
||||||
# put the private key file in the environment (reading it from the file
|
# deep verify (downloads and cryptographically verifies every blob)
|
||||||
# keeps the key out of your shell history); the whole age-keygen file,
|
VAULTIK_AGE_SECRET_KEY='AGE-SECRET-KEY-...' vaultik snapshot verify --deep <snapshot-id>
|
||||||
# with one or more identities, is accepted
|
|
||||||
export VAULTIK_AGE_SECRET_KEY="$(cat vaultik_backup_private_key.txt)"
|
|
||||||
|
|
||||||
# deep verify (downloads every blob, decrypts it, and re-hashes it to
|
# restore (requires the private key)
|
||||||
# detect corruption — this checks integrity, not who wrote the blob)
|
VAULTIK_AGE_SECRET_KEY='AGE-SECRET-KEY-...' vaultik snapshot restore <snapshot-id> /tmp/restored
|
||||||
vaultik snapshot verify --deep <snapshot-id>
|
|
||||||
|
|
||||||
# restore (requires the private key). Restore into a new directory you own,
|
|
||||||
# writable only by you — not a shared location like /tmp
|
|
||||||
vaultik snapshot restore <snapshot-id> ~/vaultik-restore
|
|
||||||
|
|
||||||
# daily cron job: back up, keep a 4-week rolling window of snapshots
|
# daily cron job: back up, keep a 4-week rolling window of snapshots
|
||||||
# 0 3 * * * vaultik snapshot create --cron --prune --keep-newer-than 4w
|
# 0 3 * * * vaultik snapshot create --cron --prune --keep-newer-than 4w
|
||||||
```
|
```
|
||||||
|
|
||||||
## restoring on another machine
|
|
||||||
|
|
||||||
Restoring on a host that never ran the backup — a replacement machine
|
|
||||||
after the original is gone — is the case vaultik is built for. That host
|
|
||||||
needs only three things: the `vaultik` binary, the age **private** key,
|
|
||||||
and the storage credentials for the destination. It does **not** need the
|
|
||||||
local index, the original config file, or the original hostname.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# install
|
|
||||||
go install sneak.berlin/go/vaultik/cmd/vaultik@latest
|
|
||||||
|
|
||||||
# create a config and point it at the ORIGINAL backup destination
|
|
||||||
vaultik config init
|
|
||||||
vaultik config set storage_url "s3://bucket/prefix?endpoint=https://s3.example.com"
|
|
||||||
vaultik config set s3.access_key_id "..."
|
|
||||||
vaultik config set s3.secret_access_key "..."
|
|
||||||
|
|
||||||
# see what is on the destination store
|
|
||||||
vaultik snapshot list
|
|
||||||
```
|
|
||||||
|
|
||||||
`snapshot list` reads the destination store without the private key. A
|
|
||||||
snapshot that is not in this host's (empty) local index is shown as
|
|
||||||
remote-only: its row is identified by `<remote only:...>` rather than by
|
|
||||||
a `hostname_name_timestamp` name, because the name lives only in the
|
|
||||||
local index and the encrypted database and cannot be recovered from the
|
|
||||||
store. Its timestamp and compressed size are real. (See the `snapshot
|
|
||||||
list` description under [command details](#command-details) for the full
|
|
||||||
explanation.)
|
|
||||||
|
|
||||||
Use that remote key — the hex printed inside `<remote only:...>`, or the
|
|
||||||
full `remote_key` from `snapshot list --json` — to restore and verify:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# put the private key file in the environment (reading it from the file
|
|
||||||
# keeps the key out of your shell history)
|
|
||||||
export VAULTIK_AGE_SECRET_KEY="$(cat vaultik_backup_private_key.txt)"
|
|
||||||
|
|
||||||
# restore everything to a new directory you own (writable only by you, not a
|
|
||||||
# shared location like /tmp), then check every restored file's chunk hashes
|
|
||||||
vaultik snapshot restore --verify <remote-key> ~/vaultik-restore
|
|
||||||
|
|
||||||
# optionally, deep-verify the snapshot against the store (downloads every
|
|
||||||
# blob, decrypts it, and re-hashes it to detect corruption — this checks
|
|
||||||
# integrity, not who wrote the blob)
|
|
||||||
vaultik snapshot verify --deep <remote-key>
|
|
||||||
```
|
|
||||||
|
|
||||||
`age_recipients` (the public key) is not needed to restore — only the
|
|
||||||
private key in `VAULTIK_AGE_SECRET_KEY`. Both the abbreviated key printed
|
|
||||||
in the table and the full 64-character key from `--json` are accepted; a
|
|
||||||
leading part of the key is enough as long as it is unambiguous.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## cli
|
## cli
|
||||||
@@ -157,10 +96,10 @@ vaultik [--config <path>] config edit
|
|||||||
vaultik [--config <path>] config get <key>
|
vaultik [--config <path>] config get <key>
|
||||||
vaultik [--config <path>] config set <key> <value>
|
vaultik [--config <path>] config set <key> <value>
|
||||||
vaultik [--config <path>] snapshot create [snapshot-names...] [--cron] [--prune] [--keep-newer-than <duration>]
|
vaultik [--config <path>] snapshot create [snapshot-names...] [--cron] [--prune] [--keep-newer-than <duration>]
|
||||||
vaultik [--config <path>] snapshot list [--json] # alias: ls
|
vaultik [--config <path>] snapshot list [--json]
|
||||||
vaultik [--config <path>] snapshot verify <snapshot-id> [--deep] [--json]
|
vaultik [--config <path>] snapshot verify <snapshot-id> [--deep] [--json]
|
||||||
vaultik [--config <path>] snapshot purge [--keep-latest | --older-than <duration>] [--snapshot <name>...] [--force]
|
vaultik [--config <path>] snapshot purge [--keep-latest | --older-than <duration>] [--snapshot <name>...] [--force]
|
||||||
vaultik [--config <path>] snapshot remove <snapshot-id> [--dry-run] [--force] [--local-only] [--json] # alias: rm
|
vaultik [--config <path>] snapshot remove <snapshot-id> [--dry-run] [--force] [--local-only] [--json]
|
||||||
vaultik [--config <path>] snapshot restore <snapshot-id> <target-dir> [paths...] [--verify]
|
vaultik [--config <path>] snapshot restore <snapshot-id> <target-dir> [paths...] [--verify]
|
||||||
vaultik [--config <path>] prune [--force] [--json]
|
vaultik [--config <path>] prune [--force] [--json]
|
||||||
vaultik [--config <path>] info
|
vaultik [--config <path>] info
|
||||||
@@ -174,64 +113,16 @@ vaultik version
|
|||||||
### global flags
|
### global flags
|
||||||
|
|
||||||
* `--config <path>`: Path to config file (default: `$VAULTIK_CONFIG`, then platform config dir, then `/etc/vaultik/config.yml`)
|
* `--config <path>`: Path to config file (default: `$VAULTIK_CONFIG`, then platform config dir, then `/etc/vaultik/config.yml`)
|
||||||
* `--verbose`, `-v`: Enable verbose output (on stderr — see below)
|
* `--verbose`, `-v`: Enable verbose output
|
||||||
* `--debug`: Enable debug output (on stderr — see below)
|
* `--debug`: Enable debug output
|
||||||
* `--quiet`, `-q`: Suppress non-error output (also suppresses startup banner)
|
* `--quiet`, `-q`: Suppress non-error output (also suppresses startup banner)
|
||||||
* `--skip-errors`: Skip files that cannot be read when creating a snapshot, or that cannot be restored when restoring, instead of aborting. Packing and storage errors (which would leave a chunk recorded but not stored) still abort the run.
|
* `--skip-errors`: Continue past per-file errors instead of aborting (applies to `snapshot create` and `restore`)
|
||||||
|
|
||||||
### locking
|
|
||||||
|
|
||||||
Commands that write persistent state — `snapshot create`, `snapshot
|
|
||||||
purge`, `snapshot remove`, `prune`, and `remote nuke` — take a
|
|
||||||
process-wide lock at `$XDG_DATA_HOME/vaultik/vaultik.pid`
|
|
||||||
(`~/.local/share/vaultik/vaultik.pid` on Linux) for the whole run. Only
|
|
||||||
one of them runs at a time: a second one exits immediately with an
|
|
||||||
"already running" error rather than waiting, so two writers can never
|
|
||||||
corrupt the local index or the destination store.
|
|
||||||
|
|
||||||
Read-only commands — `info`, `snapshot list`, `snapshot verify`, and
|
|
||||||
`remote info` — do not take the lock and are never blocked, so they run
|
|
||||||
even while a backup is in progress. `snapshot restore` does not take the
|
|
||||||
lock either: it writes only to the target directory you name, not the
|
|
||||||
local index or the destination store. `config`, `database delete`,
|
|
||||||
`completion`, and `version` do not take the lock.
|
|
||||||
|
|
||||||
### stdout and stderr
|
|
||||||
|
|
||||||
Log output — everything from `--verbose` and `--debug`, and every
|
|
||||||
warning and error the logger emits — goes to **stderr**. stdout carries
|
|
||||||
the output you asked for: tables, and the documents produced by `--json`.
|
|
||||||
|
|
||||||
This means `vaultik snapshot list --verbose > out.txt` captures the
|
|
||||||
listing and leaves the diagnostics on your terminal. To capture both,
|
|
||||||
redirect stderr as well (`> out.txt 2> log.txt`, or `> out.txt 2>&1` to
|
|
||||||
interleave them).
|
|
||||||
|
|
||||||
The split is what makes `--json` usable from a script. Warnings and
|
|
||||||
errors are never suppressed — not by `--quiet`, not by `--cron` — so a
|
|
||||||
logger on stdout would eventually land a log line inside a JSON
|
|
||||||
document and break the parse. A config file with group- or
|
|
||||||
world-readable permissions is enough to trigger it.
|
|
||||||
|
|
||||||
Format follows the stream: when stderr is a terminal the records are
|
|
||||||
colorized one-liners, and when it is redirected or piped they are
|
|
||||||
JSON, one object per line.
|
|
||||||
|
|
||||||
Under `--json`, stdout holds the document and nothing else. The startup
|
|
||||||
banner is suppressed, as `--quiet` and `--cron` suppress it, and the
|
|
||||||
progress narration a command would otherwise print — such as the stale
|
|
||||||
local records `prune` reconciles away — is suppressed too, so it cannot
|
|
||||||
land ahead of the document. Every `--json` command therefore pipes on
|
|
||||||
its own, with no additional flag: `vaultik snapshot list --json | jq .`
|
|
||||||
and `vaultik prune --json | jq .` both work as written.
|
|
||||||
|
|
||||||
### environment variables
|
### environment variables
|
||||||
|
|
||||||
* `VAULTIK_AGE_SECRET_KEY`: Age private key for decryption (required for `snapshot restore` and `snapshot verify --deep`). May hold the whole `age-keygen` file — comments and every identity in it are accepted. Set it from the file, e.g. `export VAULTIK_AGE_SECRET_KEY="$(cat vaultik_backup_private_key.txt)"`, so the key is not typed into your shell history.
|
* `VAULTIK_AGE_SECRET_KEY`: Age private key for decryption (required for `snapshot restore` and `snapshot verify --deep`)
|
||||||
* `VAULTIK_CONFIG`: Path to config file (overridden by `--config`)
|
* `VAULTIK_CONFIG`: Path to config file (overridden by `--config`)
|
||||||
* `VAULTIK_INDEX_PATH`: Override local SQLite index path
|
* `VAULTIK_INDEX_PATH`: Override local SQLite index path
|
||||||
* `VAULTIK_CPUPROFILE`: Write a CPU profile to this path for the duration of the run (development/debugging)
|
|
||||||
* `VAULTIK_MEMPROFILE`: Write a heap profile to this path when the run exits (development/debugging)
|
|
||||||
|
|
||||||
### shell completion
|
### shell completion
|
||||||
|
|
||||||
@@ -317,26 +208,20 @@ local index alone, and still exits zero.
|
|||||||
(whether the snapshot is in the local index), `remote_key` (the full
|
(whether the snapshot is in the local index), `remote_key` (the full
|
||||||
64-character storage key), and `remote_present` (whether it was seen
|
64-character storage key), and `remote_present` (whether it was seen
|
||||||
on the destination store, or `null` if the destination could not be
|
on the destination store, or `null` if the destination could not be
|
||||||
listed). Warnings about an unlistable destination, unreadable
|
listed). The warning about an unlistable destination goes to stderr
|
||||||
manifests, and a truncated listing all go to stderr through the
|
so stdout stays a single parseable document.
|
||||||
logger, so stdout stays a single parseable document.
|
|
||||||
|
|
||||||
**`snapshot verify`**: Verify snapshot integrity.
|
**`snapshot verify`**: Verify snapshot integrity.
|
||||||
* Default (shallow): checks that every blob the manifest lists is present in
|
* Default (shallow): checks that all blobs referenced in the manifest exist in storage
|
||||||
storage with the size the manifest records, and that the encrypted database is
|
|
||||||
present. It does not read blob contents.
|
|
||||||
* `--deep`: Downloads and decrypts each blob, verifies chunk hashes against the
|
* `--deep`: Downloads and decrypts each blob, verifies chunk hashes against the
|
||||||
encrypted metadata database
|
encrypted metadata database
|
||||||
* Accepts the same identifiers as `snapshot restore`: a snapshot ID, or a
|
|
||||||
remote-only snapshot's remote key (or an unambiguous leading part of it)
|
|
||||||
* `--json`: Output results as JSON
|
* `--json`: Output results as JSON
|
||||||
|
|
||||||
**`snapshot purge`**: Remove old snapshots based on criteria. Retention is
|
**`snapshot purge`**: Remove old snapshots based on criteria. Retention is
|
||||||
per-snapshot-name (`--keep-latest` keeps the latest of each name, not the
|
per-snapshot-name (`--keep-latest` keeps the latest of each name, not the
|
||||||
latest globally).
|
latest globally).
|
||||||
* `--keep-latest`: Keep only the most recent snapshot of each name
|
* `--keep-latest`: Keep only the most recent snapshot of each name
|
||||||
* `--older-than <duration>`: Remove snapshots older than duration (e.g. `30d`,
|
* `--older-than <duration>`: Remove snapshots older than duration (e.g. `30d`, `6m`, `1y`)
|
||||||
`4w`, `6mo`, `1y`; `m` is minutes, `mo` is months)
|
|
||||||
* `--snapshot <name>`: Restrict to specific snapshot names (repeat for multiple)
|
* `--snapshot <name>`: Restrict to specific snapshot names (repeat for multiple)
|
||||||
* `--force`: Skip confirmation prompt
|
* `--force`: Skip confirmation prompt
|
||||||
|
|
||||||
@@ -359,10 +244,6 @@ on the destination in one go, use `vaultik remote nuke --force`.
|
|||||||
|
|
||||||
**`snapshot restore`**: Restore files from a backup snapshot.
|
**`snapshot restore`**: Restore files from a backup snapshot.
|
||||||
* Requires `VAULTIK_AGE_SECRET_KEY` environment variable
|
* Requires `VAULTIK_AGE_SECRET_KEY` environment variable
|
||||||
* Accepts a snapshot ID, or — for a snapshot only on the destination
|
|
||||||
store — its remote key (or an unambiguous leading part of it) as shown
|
|
||||||
by `snapshot list`. See
|
|
||||||
[restoring on another machine](#restoring-on-another-machine).
|
|
||||||
* Optional path arguments to restore specific files/directories (default: all)
|
* Optional path arguments to restore specific files/directories (default: all)
|
||||||
* Preserves file permissions, timestamps, ownership (ownership requires root),
|
* Preserves file permissions, timestamps, ownership (ownership requires root),
|
||||||
symlinks, and empty directories
|
symlinks, and empty directories
|
||||||
@@ -426,10 +307,6 @@ both are set.
|
|||||||
|
|
||||||
## architecture
|
## architecture
|
||||||
|
|
||||||
For an implementation-level view of the internals — the data model, the
|
|
||||||
`fx` dependency-injection wiring, and the scanner — see
|
|
||||||
[`ARCHITECTURE.md`](ARCHITECTURE.md).
|
|
||||||
|
|
||||||
### remote storage layout
|
### remote storage layout
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -437,7 +314,7 @@ For an implementation-level view of the internals — the data model, the
|
|||||||
├── blobs/
|
├── blobs/
|
||||||
│ └── <aa>/<bb>/<full_blob_hash>
|
│ └── <aa>/<bb>/<full_blob_hash>
|
||||||
└── metadata/
|
└── metadata/
|
||||||
└── <remote-key>/
|
└── <snapshot_id>/
|
||||||
├── db.zst.age # Encrypted binary SQLite database
|
├── db.zst.age # Encrypted binary SQLite database
|
||||||
└── manifest.json.zst # Unencrypted blob list (for pruning)
|
└── manifest.json.zst # Unencrypted blob list (for pruning)
|
||||||
```
|
```
|
||||||
@@ -448,23 +325,8 @@ For an implementation-level view of the internals — the data model, the
|
|||||||
* `manifest.json.zst` is an unencrypted compressed JSON blob list, enabling
|
* `manifest.json.zst` is an unencrypted compressed JSON blob list, enabling
|
||||||
pruning without the private key
|
pruning without the private key
|
||||||
|
|
||||||
Snapshot IDs follow the human-readable format
|
Snapshot IDs follow the format `<hostname>_<snapshot-name>_<RFC3339-timestamp>`
|
||||||
`<hostname>_<snapshot-name>_<RFC3339-timestamp>` (e.g.
|
(e.g. `server1_home_2025-06-01T12:00:00Z`).
|
||||||
`server1_home_2025-06-01T12:00:00Z`), but this ID is never written to the
|
|
||||||
destination store in plaintext. Each snapshot's metadata directory is named
|
|
||||||
with its `<remote-key>`, a one-way double SHA-256 hash of the ID, so a plain
|
|
||||||
listing of the store shows no hostname or snapshot name. The hash uses no
|
|
||||||
secret, though, so an observer who guesses a candidate hostname and snapshot
|
|
||||||
name can hash it and confirm the snapshot is present; the remote key keeps
|
|
||||||
names out of a listing but does not hide them from a guess. The backup time is
|
|
||||||
not hidden either: manifest.json.zst carries a plaintext timestamp, and object
|
|
||||||
modification times are visible at the storage layer regardless. For example,
|
|
||||||
`server1_home_2025-06-01T12:00:00Z` is stored under
|
|
||||||
`metadata/17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa/`.
|
|
||||||
See [docs/REPOSTRUCTURE.md](docs/REPOSTRUCTURE.md#remote-key-derivation) for the
|
|
||||||
derivation, and [Security Considerations](docs/REPOSTRUCTURE.md#security-considerations)
|
|
||||||
(including [Accepted Risks](docs/REPOSTRUCTURE.md#accepted-risks)) for what the
|
|
||||||
format does and does not protect.
|
|
||||||
|
|
||||||
### data flow
|
### data flow
|
||||||
|
|
||||||
@@ -481,7 +343,7 @@ format does and does not protect.
|
|||||||
|
|
||||||
**restore:**
|
**restore:**
|
||||||
|
|
||||||
1. Download and decrypt `metadata/<remote-key>/db.zst.age`
|
1. Download and decrypt `metadata/<snapshot_id>/db.zst.age`
|
||||||
2. Open the binary SQLite database
|
2. Open the binary SQLite database
|
||||||
3. Query files (optionally filtered by paths)
|
3. Query files (optionally filtered by paths)
|
||||||
4. Download and decrypt required blobs
|
4. Download and decrypt required blobs
|
||||||
@@ -505,37 +367,32 @@ format does and does not protect.
|
|||||||
|
|
||||||
### encryption
|
### encryption
|
||||||
|
|
||||||
* Asymmetric encryption using age (X25519 + ChaCha20-Poly1305)
|
* Asymmetric encryption using age (X25519 + XChaCha20-Poly1305)
|
||||||
* Only the public key is needed on the source host
|
* Only the public key is needed on the source host
|
||||||
* Each blob and each metadata database is encrypted independently
|
* Each blob and each metadata database is encrypted independently
|
||||||
* Multiple recipients supported (encrypt to multiple keys)
|
* Multiple recipients supported (encrypt to multiple keys)
|
||||||
|
|
||||||
### compression
|
### compression
|
||||||
|
|
||||||
* zstd compression at configurable level (1-19, default 3). The level is
|
* zstd compression at configurable level (1-19, default 3)
|
||||||
accepted as 1-19 but maps onto zstd's four internal speed presets:
|
|
||||||
1-2 fastest, 3-5 default, 6-9 better, 10-19 best. Levels within the
|
|
||||||
same band compress identically.
|
|
||||||
* Applied before encryption at the blob level
|
* Applied before encryption at the blob level
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## configuration reference
|
## configuration reference
|
||||||
|
|
||||||
Run `vaultik config init` to generate a fully commented config file; a
|
Run `vaultik config init` to generate a fully commented config file.
|
||||||
complete annotated example also lives in
|
Key fields:
|
||||||
[`config.example.yml`](config.example.yml). Key fields:
|
|
||||||
|
|
||||||
| Field | Default | Description |
|
| Field | Default | Description |
|
||||||
|-------|---------|-------------|
|
|-------|---------|-------------|
|
||||||
| `age_recipients` | (required) | Age public keys for encryption |
|
| `age_recipients` | (required) | Age public keys for encryption |
|
||||||
| `age_secret_key` | (unset) | Age private key for decryption (`snapshot restore`, `snapshot verify --deep`). Setting it in the config file places the private key on the backed-up host, defeating the public-key-only design (see "why" above). Prefer the `VAULTIK_AGE_SECRET_KEY` environment variable, supplied only on the machine you restore from. |
|
|
||||||
| `snapshots` | (required) | Named snapshot definitions with paths and excludes |
|
| `snapshots` | (required) | Named snapshot definitions with paths and excludes |
|
||||||
| `storage_url` | | Storage backend URL (`s3://`, `file://`, `rclone://`) |
|
| `storage_url` | | Storage backend URL (`s3://`, `file://`, `rclone://`) |
|
||||||
| `s3.*` | | Legacy S3 configuration (endpoint, bucket, credentials) |
|
| `s3.*` | | Legacy S3 configuration (endpoint, bucket, credentials) |
|
||||||
| `exclude` | | Global exclude patterns (applied to all snapshots) |
|
| `exclude` | | Global exclude patterns (applied to all snapshots) |
|
||||||
| `chunk_size` | `10MB` | Average chunk size for content-defined chunking |
|
| `chunk_size` | `10MB` | Average chunk size for content-defined chunking |
|
||||||
| `blob_size_limit` | `10GB` | Maximum blob size before splitting. Must be at least four times `chunk_size` (the largest chunk the chunker can emit), otherwise a single-chunk blob could exceed the limit |
|
| `blob_size_limit` | `10GB` | Maximum blob size before splitting |
|
||||||
| `compression_level` | `3` | zstd compression level (1-19) |
|
| `compression_level` | `3` | zstd compression level (1-19) |
|
||||||
| `hostname` | system hostname | Hostname used in snapshot IDs |
|
| `hostname` | system hostname | Hostname used in snapshot IDs |
|
||||||
| `index_path` | platform data dir | Local SQLite index path |
|
| `index_path` | platform data dir | Local SQLite index path |
|
||||||
@@ -559,13 +416,9 @@ complete annotated example also lives in
|
|||||||
sequentially. Restore speed is bound by single-stream throughput.
|
sequentially. Restore speed is bound by single-stream throughput.
|
||||||
* **Device nodes, named pipes, and sockets are silently skipped.** Only
|
* **Device nodes, named pipes, and sockets are silently skipped.** Only
|
||||||
regular files, directories, and symlinks are backed up.
|
regular files, directories, and symlinks are backed up.
|
||||||
* **No upgrade path between versions.** There is no supported way to carry
|
* **No database migrations.** If the local SQLite schema changes between
|
||||||
an existing local index across a schema change; if the local SQLite
|
versions, delete the local database (`vaultik database delete`) and run
|
||||||
schema changes between versions, delete the local database (`vaultik
|
a full backup. Remote storage is unaffected.
|
||||||
database delete`) and run a full backup. Remote storage is unaffected.
|
|
||||||
(The binary does embed numbered schema files and a `schema_migrations`
|
|
||||||
table to bootstrap a fresh database — see [`docs/DATAMODEL.md`](docs/DATAMODEL.md)
|
|
||||||
— but that is not an upgrade path.)
|
|
||||||
* **Files that change during backup may be inconsistent.** There is no
|
* **Files that change during backup may be inconsistent.** There is no
|
||||||
filesystem snapshot or freeze. If a file is modified between the scan
|
filesystem snapshot or freeze. If a file is modified between the scan
|
||||||
and chunk phases, the backed-up copy may reflect a partial write.
|
and chunk phases, the backed-up copy may reflect a partial write.
|
||||||
@@ -631,12 +484,14 @@ priority.
|
|||||||
|
|
||||||
### infrastructure
|
### infrastructure
|
||||||
|
|
||||||
* **Cross-version schema upgrades.** There is no upgrade path between
|
* **Cross-machine restore documentation.** The "restore from
|
||||||
released versions — pre-1.0 schema changes are handled by `vaultik
|
another host" workflow works but isn't documented as a
|
||||||
database delete` plus a full re-scan (see
|
first-class operation in this README. Worth a dedicated section
|
||||||
[`docs/DATAMODEL.md`](docs/DATAMODEL.md)). Post-1.0 we'll need a
|
once it's settled.
|
||||||
migration story to keep existing index databases usable across
|
* **Schema migrations.** Currently nonexistent — pre-1.0 schema
|
||||||
upgrades.
|
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://
|
* **Storage backend coverage tests.** S3, file://, and rclone://
|
||||||
all share the Storer interface but the rclone path is the least
|
all share the Storer interface but the rclone path is the least
|
||||||
exercised in CI.
|
exercised in CI.
|
||||||
@@ -645,30 +500,9 @@ priority.
|
|||||||
|
|
||||||
## output style
|
## output style
|
||||||
|
|
||||||
Every command's user-facing output is governed by `internal/ui`, in one
|
All user-facing output goes through helpers in `internal/ui` and conforms
|
||||||
of two ways. Color is enabled when stdout is a TTY and the `NO_COLOR`
|
to a uniform style. Color is enabled when stdout is a TTY and the
|
||||||
environment variable is unset (https://no-color.org/).
|
`NO_COLOR` environment variable is unset (https://no-color.org/).
|
||||||
|
|
||||||
* **Status, progress, warnings, and errors** go through the `internal/ui`
|
|
||||||
message methods below: marker-prefixed, colored on a TTY, and — except
|
|
||||||
warnings and errors — silenced by `--quiet`. This is the operational
|
|
||||||
narration of the long-running commands (`snapshot create`, `prune`,
|
|
||||||
`snapshot restore`, and the like) and the confirmations of
|
|
||||||
`config init`, `config set`, and `database delete`.
|
|
||||||
* **The data a command exists to produce** is written plain, with no
|
|
||||||
marker and no color, because a marker would corrupt a table or a
|
|
||||||
parsed document. This covers the `version`, `info`, and `remote info`
|
|
||||||
reports, the `snapshot list` table, `config get` values, and every
|
|
||||||
`--json` document. `--quiet` silences the human reports and tables
|
|
||||||
(`version`, `info`, `remote info`, `snapshot list`) but never the
|
|
||||||
machine-consumed `config get` value or the `--json` documents, which a
|
|
||||||
script depends on. The `database delete` confirmation prompt is also
|
|
||||||
written this way and always shown: it is an interactive exchange the
|
|
||||||
operator must see.
|
|
||||||
|
|
||||||
`internal/ui` writes to stdout; it is the output the user asked for.
|
|
||||||
Structured log records are a different thing and go through
|
|
||||||
`internal/log`, which writes to stderr (see "stdout and stderr" above).
|
|
||||||
|
|
||||||
Message classes:
|
Message classes:
|
||||||
|
|
||||||
@@ -729,12 +563,6 @@ regardless of color setting (emoji are not color).
|
|||||||
## requirements
|
## requirements
|
||||||
|
|
||||||
* Go 1.26 or later
|
* Go 1.26 or later
|
||||||
* Docker, with a reachable daemon, to lint, check, or commit:
|
|
||||||
`script/lint` lints by building `Dockerfile.lint`, which runs the
|
|
||||||
digest-pinned `golangci-lint` image as a build step, and `make check`
|
|
||||||
and the pre-commit hook both run it. A `golangci-lint` installed on
|
|
||||||
`PATH` is not a substitute and is never used on a host, whatever its
|
|
||||||
version.
|
|
||||||
* S3-compatible object storage (or local filesystem, or rclone remote)
|
* S3-compatible object storage (or local filesystem, or rclone remote)
|
||||||
|
|
||||||
## development workflow
|
## development workflow
|
||||||
@@ -765,194 +593,53 @@ standard: normalized scripts in `script/` are the entrypoints for the
|
|||||||
development workflow, and the Makefile targets are thin shims that call
|
development workflow, and the Makefile targets are thin shims that call
|
||||||
them. We provide:
|
them. We provide:
|
||||||
|
|
||||||
* `script/bootstrap` — install all development dependencies (go, Go
|
* `script/bootstrap` — install all development dependencies (go, sqlite3,
|
||||||
module download). It deliberately does not install `golangci-lint`;
|
Go module download). It deliberately does not install `golangci-lint`;
|
||||||
see `script/lint` below.
|
see `script/lint` below.
|
||||||
* `script/setup` — make a fresh clone ready for development: runs
|
* `script/setup` — make a fresh clone ready for development: runs
|
||||||
`script/bootstrap`, then `script/install-precommit`
|
`script/bootstrap`, then `script/install-precommit`
|
||||||
* `script/projectname` — print the project name (used for the Docker
|
* `script/projectname` — print the project name (used for the Docker
|
||||||
image tag)
|
image tag)
|
||||||
* `script/version` — print the version string to bake into the binary.
|
* `script/test` — run the test suite (verbose rerun on failure)
|
||||||
The `Makefile`'s `LDFLAGS` call this; it is the single source of truth
|
* `script/lint` — run `golangci-lint run ./...` at the exact version CI
|
||||||
for the version. See [releasing](#releasing) for the rules.
|
uses, by running the digest-pinned `golangci-lint` image declared by
|
||||||
* `script/install-goreleaser` — install the pinned `goreleaser` into
|
the `Dockerfile` lint stage (requires Docker; it fails loudly rather
|
||||||
`.tool/bin` from a sha256-verified release archive. Idempotent, and
|
than falling back to a differently versioned `golangci-lint` on
|
||||||
called by `script/bootstrap`; the release workflow calls it directly
|
`PATH`). That `FROM` line is the single source of truth for the linter
|
||||||
because it needs `goreleaser` but not the Docker daemon
|
version — bump it there and nowhere else.
|
||||||
`script/bootstrap` insists on.
|
|
||||||
* `script/install-go` — install the Go toolchain named by `go.mod`'s
|
|
||||||
`go` directive into `.tool/go` from a sha256-verified `go.dev`
|
|
||||||
archive, and put it on `PATH`. Idempotent. Called only by the release
|
|
||||||
workflow, which needs a host Go for `goreleaser` to shell out to;
|
|
||||||
nothing else on the release runner does. `actions/setup-go` is not
|
|
||||||
used because it verifies the downloaded toolchain against no value in
|
|
||||||
this repo. Bumping Go edits `go.mod`, the checksum in this script, and
|
|
||||||
the `Dockerfile` `golang` digest together.
|
|
||||||
* `script/release` — cross-compile and publish the release artifacts
|
|
||||||
with the pinned `goreleaser`. Refuses a `goreleaser` on `PATH` whose
|
|
||||||
version is not the pinned one, on the same reasoning as `script/lint`.
|
|
||||||
* `script/release-snapshot` — the same build with no publishing and no
|
|
||||||
tagging, into `./dist`
|
|
||||||
* `script/test` — run the test suite (verbose rerun on failure). This
|
|
||||||
runs *everything*: there is no separate integration target and no
|
|
||||||
build-tagged subset held back, so the full round-trip tests in
|
|
||||||
`internal/vaultik/integration_test.go` run on every invocation. It
|
|
||||||
passes `-count=1`, which disables Go's test result cache. That is
|
|
||||||
deliberate and it is not free: on this repo's suite it costs about 11
|
|
||||||
seconds on every repeat run (measured, back to back: 0.4s cached
|
|
||||||
versus 11.6s with `-count=1`). That is the price of the run meaning
|
|
||||||
anything, because without it an unchanged package prints
|
|
||||||
`ok <pkg> (cached)`, which is indistinguishable from a package that
|
|
||||||
really ran, so the whole suite can report a full set of `ok` lines in
|
|
||||||
under half a second having executed nothing. The `-timeout` is a hang
|
|
||||||
backstop rather than a performance budget — it applies per test binary
|
|
||||||
to test execution only, not to compilation — and is set well above the
|
|
||||||
slowest package's measured runtime. Its 120s value deliberately
|
|
||||||
diverges from the 30s `REPO_POLICIES.md` mandates; the reasoning is in
|
|
||||||
the comment in the script, and issue #101 proposes amending the policy
|
|
||||||
text.
|
|
||||||
* `script/lint` — lint by building `Dockerfile.lint`, which runs
|
|
||||||
`golangci-lint run --config .golangci.yml ./...` as a build step
|
|
||||||
inside the digest-pinned `golangci-lint` image, so a successful build
|
|
||||||
*is* a clean lint. Nothing lints on the host, at any version, ever;
|
|
||||||
the script requires Docker and fails loudly rather than falling back
|
|
||||||
to a `golangci-lint` on `PATH`. That `FROM` line is the single source
|
|
||||||
of truth for the linter version — bump it there and nowhere else.
|
|
||||||
|
|
||||||
It takes no arguments, because a build step has no command line to
|
|
||||||
pass flags to, and it passes a fresh `--build-arg CHECK_EPOCH` on
|
|
||||||
every invocation so the lint layer cannot be replayed from cache (see
|
|
||||||
`script/cibuild` below for what that mechanism defends against). To
|
|
||||||
watch the linter execute, run it as
|
|
||||||
`BUILDKIT_PROGRESS=plain script/lint` and check that the lint layer
|
|
||||||
says `RUN … golangci-lint` rather than `CACHED`.
|
|
||||||
|
|
||||||
One container per run means one lint cache and one `golangci-lint`
|
|
||||||
lock per run, both private to it and discarded with it, so concurrent
|
|
||||||
runs on one host cannot contaminate or block each other.
|
|
||||||
* `script/lint-fix` — apply the linter's autofixes (rewrites files),
|
* `script/lint-fix` — apply the linter's autofixes (rewrites files),
|
||||||
using the same pinned image, parsed out of `Dockerfile.lint`. It
|
using the same pinned linter
|
||||||
cannot be a build step, because fixes have to land in the worktree, so
|
|
||||||
it bind-mounts the tree into a `docker run` and therefore needs a
|
|
||||||
*local* daemon. It is a developer convenience and never a gate: no
|
|
||||||
gate reads its exit status. Run `make lint` afterwards to find out
|
|
||||||
whether the tree is clean.
|
|
||||||
* `script/fmt` — format all code (writes)
|
* `script/fmt` — format all code (writes)
|
||||||
* `script/fmt-check` — check formatting (read-only)
|
* `script/fmt-check` — check formatting (read-only)
|
||||||
* `script/check` — run `script/test`, `script/lint`, and
|
* `script/check` — run `script/test`, `script/lint`, and
|
||||||
`script/fmt-check`. This is authoritative *because* `script/lint`
|
`script/fmt-check`. This is authoritative *because* `script/lint` uses
|
||||||
builds `Dockerfile.lint`: a local `make check` and CI cannot disagree
|
the pinned linter: a local `make check` and CI cannot disagree about
|
||||||
about lint findings.
|
lint findings.
|
||||||
* `script/docker` — build the Docker image tagged via
|
* `script/docker` — build the Docker image tagged via
|
||||||
`script/projectname`. Passes a fresh `--build-arg CHECK_EPOCH` for the
|
`script/projectname`
|
||||||
same reason `script/cibuild` does, so a local image build cannot be
|
* `script/cibuild` — CI entrypoint: `docker build` (the `Dockerfile`
|
||||||
green on checks it replayed from cache. It builds the *product* image
|
runs `make fmt-check` and `make lint` in its lint stage and `make
|
||||||
only, and the product `Dockerfile` has no lint stage, so it does not
|
test` in its builder stage). This is the full CI-equivalent gate — it
|
||||||
lint: a green here means formatted, tested, and it compiles.
|
runs the checks in the same containers CI does, from a clean copy of
|
||||||
* `script/cibuild` — CI entrypoint, and the full gate. Two builds, in
|
the tree, so it also catches anything that depends on host state. It
|
||||||
order: `Dockerfile.lint` (the linter, as a build step) and then
|
passes a fresh `--build-arg CHECK_EPOCH`, which the `Dockerfile`
|
||||||
`Dockerfile` (`make fmt-check` and `make test` in its builder stage,
|
declares immediately above the check `RUN`s in both stages. Those
|
||||||
then the product image). Either failing fails the script. It runs the
|
layers are keyed on that value, so a new value re-runs them even on a
|
||||||
checks in the same containers CI does, from a clean copy of the tree,
|
byte-identical tree, and a green from this script means the checks
|
||||||
so it also catches anything that depends on host state.
|
executed. Dependency and module layers sit above the `ARG` and still
|
||||||
`.gitea/workflows/check.yml` runs it on every push to `main` and
|
cache, so a build is not cold.
|
||||||
`next` and on every pull request against either.
|
|
||||||
|
|
||||||
It passes a fresh `--build-arg CHECK_EPOCH` to each build, unique per
|
That guarantee is conditional on the fresh value, so **run the gate
|
||||||
invocation, which both files declare immediately above their check
|
through `script/cibuild`, not by invoking `docker build` yourself**. A
|
||||||
`RUN`s and expand into each check command. Those layers are keyed on
|
bare `docker build .` supplies no `CHECK_EPOCH`; the empty default is
|
||||||
that value, so a new value re-runs them even on a byte-identical tree,
|
a constant, so the second and every later build on an unchanged tree
|
||||||
and a green from this script means the checks executed. Dependency and
|
serves all three check layers from cache, executes nothing, and still
|
||||||
module layers sit above the `ARG` and still cache, so a build is not
|
exits 0. Issue #91 tracks making that case fail loudly instead.
|
||||||
cold.
|
|
||||||
|
|
||||||
A build that supplies no `CHECK_EPOCH` — a bare `docker build .` or
|
|
||||||
`docker build -f Dockerfile.lint .` — fails rather than lying. An
|
|
||||||
unset `ARG` is an empty string and an empty string is a stable cache
|
|
||||||
key, so without a guard such a build would serve every check layer
|
|
||||||
from cache, execute nothing, and still exit 0. Each file therefore
|
|
||||||
asserts the value is non-empty before running anything, and because
|
|
||||||
failed steps are never cached that assertion fires on every
|
|
||||||
invocation rather than once. Use `script/lint`, `script/docker` or
|
|
||||||
`script/cibuild`, which pass the arg; a bare `docker build` is a loud
|
|
||||||
error.
|
|
||||||
* `script/precommit` — pre-commit gate: `go mod tidy` + `go fmt` (must
|
* `script/precommit` — pre-commit gate: `go mod tidy` + `go fmt` (must
|
||||||
not change files), then `script/check`
|
not change files), then `script/check`
|
||||||
* `script/install-precommit` — install the git pre-commit hook that
|
* `script/install-precommit` — install the git pre-commit hook that
|
||||||
runs `script/precommit`
|
runs `script/precommit`
|
||||||
|
|
||||||
## releasing
|
|
||||||
|
|
||||||
### version numbers
|
|
||||||
|
|
||||||
The version a binary reports comes from git, not from a constant in a
|
|
||||||
file. `script/version` decides it, and everything that stamps a binary
|
|
||||||
agrees with it:
|
|
||||||
|
|
||||||
* `HEAD` is exactly on a tag → that tag with a leading `v` stripped, so
|
|
||||||
the tag `v1.0.0` produces `vaultik 1.0.0`, matching the archive name
|
|
||||||
`vaultik_1.0.0_linux_amd64.tar.gz`. `goreleaser` strips the prefix the
|
|
||||||
same way.
|
|
||||||
* anything else → `dev-<12 chars of the commit sha>`.
|
|
||||||
* either, with uncommitted changes to tracked files → a `-dirty`
|
|
||||||
suffix, because a modified checkout of a tag is not that tag.
|
|
||||||
|
|
||||||
A build that is not a release never names itself like one. `vaultik
|
|
||||||
version` says so in as many words on a development build, and
|
|
||||||
`goreleaser --snapshot` stamps the same `dev-<sha>` string rather than
|
|
||||||
inventing the next patch number. If `script/version` cannot be run at
|
|
||||||
all, `make` stops with an error instead of building an unversioned
|
|
||||||
binary, and a binary that somehow carries an empty version string still
|
|
||||||
reports itself as a development build.
|
|
||||||
|
|
||||||
### cutting a release
|
|
||||||
|
|
||||||
Releases are cut by CI from a tag, not from a workstation:
|
|
||||||
|
|
||||||
```
|
|
||||||
git tag -a v1.2.3 -m 'v1.2.3'
|
|
||||||
git push origin v1.2.3
|
|
||||||
```
|
|
||||||
|
|
||||||
`.gitea/workflows/release.yml` triggers on `v*` tags, installs a Go
|
|
||||||
toolchain and the pinned `goreleaser`, and runs `script/release`, which
|
|
||||||
builds
|
|
||||||
`linux,darwin × amd64,arm64` archives plus `checksums.txt` and publishes
|
|
||||||
them to this repository's Gitea releases as a draft. `.goreleaser.yaml`
|
|
||||||
has a `gitea_urls:` block pointing at `https://git.eeqj.de/api/v1`;
|
|
||||||
without it `goreleaser` would talk to the GitHub API.
|
|
||||||
|
|
||||||
The workflow needs one repository Actions secret:
|
|
||||||
|
|
||||||
| Secret | What it is |
|
|
||||||
| --------------- | ------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `RELEASE_TOKEN` | A Gitea access token with `write:repository` scope, owned by an account that can publish releases here. |
|
|
||||||
|
|
||||||
It is passed to `goreleaser` as `GITEA_TOKEN`. The runner's automatic
|
|
||||||
token is deliberately not used: it is not guaranteed to carry release
|
|
||||||
write access.
|
|
||||||
|
|
||||||
The Go toolchain that compiles the released binaries comes from an
|
|
||||||
`actions/setup-go` step pinned by commit sha, reading its version from
|
|
||||||
`go.mod` (currently `1.26.1`, the same version the `Dockerfile` builder
|
|
||||||
stage pins by digest). `goreleaser` shells out to `go` for every
|
|
||||||
cross-compile, so without that step the release would either fail
|
|
||||||
outright or ship binaries built by whatever unpinned toolchain the
|
|
||||||
runner happened to carry — the one unpinned thing in an otherwise
|
|
||||||
hash-pinned release path.
|
|
||||||
|
|
||||||
To rehearse the whole build without publishing or tagging anything:
|
|
||||||
|
|
||||||
```
|
|
||||||
make release-snapshot
|
|
||||||
```
|
|
||||||
|
|
||||||
Artifacts land in `./dist`, which is gitignored.
|
|
||||||
|
|
||||||
Release artifacts are not signed, carry no SBOM, and are not built
|
|
||||||
reproducibly; the archives contain the binary, `LICENSE`, and
|
|
||||||
`README.md` only (no shell completions or man page).
|
|
||||||
|
|
||||||
## license
|
## license
|
||||||
|
|
||||||
[MIT](https://opensource.org/license/mit/)
|
[MIT](https://opensource.org/license/mit/)
|
||||||
|
|||||||
@@ -14,555 +14,11 @@ pre-1.0
|
|||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
Define the remaining scope for the first tagged release under the 1.0.0
|
Triage the stale remote branches (issue #71): for each, merge the work
|
||||||
milestone, then cut that tag. The mechanism to cut it now exists and is
|
or delete the branch.
|
||||||
exercised; what is left is the scope decision, which is the owner's.
|
|
||||||
This step deliberately names one version number: it previously said
|
|
||||||
"cut v0.1.0" while the `Makefile` baked in `1.0.0-rc.1` and the issue
|
|
||||||
milestone said 1.0.0, and three different answers to "what is the next
|
|
||||||
release" is exactly the contradiction
|
|
||||||
[issue #65](https://git.eeqj.de/sneak/vaultik/issues/65) was filed over.
|
|
||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 2026-09-22: Routed the last direct-to-stdout command output through
|
|
||||||
`internal/ui`
|
|
||||||
([issue #149](https://git.eeqj.de/sneak/vaultik/issues/149)). The
|
|
||||||
`version`, `info`, `remote info`, `config`, and `database delete`
|
|
||||||
commands wrote plain text straight to stdout, so they were unstyled and
|
|
||||||
ignored `--quiet`. Output now falls in two buckets, both governed by
|
|
||||||
`internal/ui`: status lines and confirmations go through its message
|
|
||||||
methods (styled, and `--quiet` silences them), while the data a command
|
|
||||||
exists to produce — the reports, the `snapshot list` table, `config get`
|
|
||||||
values, and the `--json` documents — is written plain. `--quiet`
|
|
||||||
silences the human reports and tables but never the `config get` value
|
|
||||||
or the `--json` documents, which a script depends on, and the
|
|
||||||
`database delete` confirmation prompt is always shown. The README
|
|
||||||
output-style section now states this rule.
|
|
||||||
|
|
||||||
- 2026-09-22: Validated blob hashes, offsets and lengths read back from
|
|
||||||
the destination before using them
|
|
||||||
([issue #155](https://git.eeqj.de/sneak/vaultik/issues/155)). A blob
|
|
||||||
hash taken from the downloaded database or the store listing was
|
|
||||||
trusted unchecked, so a hostile remote could drive a decrypted blob to
|
|
||||||
be written outside the cache directory (a hash like `aa/../../etc`) or
|
|
||||||
crash a command with a short or negative value. `blobDiskCache.path`
|
|
||||||
now refuses any key containing a path separator, and `ReadAt` rejects a
|
|
||||||
negative offset or length and bounds with `length > size-offset` so a
|
|
||||||
sum cannot overflow past the check. A new `isBlobHash` helper (a plain
|
|
||||||
function, not a method — the packer stores `temp-placeholder-{uuid}` as
|
|
||||||
a hash) gates `FetchBlob`, shallow and deep verify; the `blobs/` and
|
|
||||||
`metadata/` listings skip a non-conforming name with a warning; and
|
|
||||||
short-hash prefixes in log and error text go through a `shortHash`
|
|
||||||
helper that cannot panic. `verify`'s chunk reader also rejects a
|
|
||||||
negative `blob_chunks` length and streams the chunk instead of
|
|
||||||
allocating a database-supplied size. `restore.go` and
|
|
||||||
`internal/database` were left untouched to avoid colliding with the
|
|
||||||
in-flight [issue #156](https://git.eeqj.de/sneak/vaultik/issues/156)
|
|
||||||
work; the cache-path and `FetchBlob` guards already stop the unsafe
|
|
||||||
write and fetch, so restore's own `buildBlobIndexes` early check is
|
|
||||||
deferred as fail-fast defense in depth.
|
|
||||||
|
|
||||||
- 2026-09-21: Stopped an interrupted blob upload from making a later
|
|
||||||
backup deduplicate against data that was never stored
|
|
||||||
([issue #148](https://git.eeqj.de/sneak/vaultik/issues/148)). The
|
|
||||||
packer commits a blob's `chunks`, `blob_chunks`, and `blobs` rows
|
|
||||||
before the upload is attempted, so a failed upload left chunk rows
|
|
||||||
behind and the next run skipped re-uploading them, producing a
|
|
||||||
snapshot that reported success but could not be restored. A run now
|
|
||||||
deduplicates only against chunks held by a blob whose `uploaded_ts` is
|
|
||||||
set, and at startup drops any un-uploaded blob rows (and the chunks
|
|
||||||
they orphan) so the affected data is re-chunked and re-uploaded. Blobs
|
|
||||||
recorded with no remote backend are marked uploaded so this invariant
|
|
||||||
holds uniformly.
|
|
||||||
|
|
||||||
- 2026-09-22: Made restore refuse any snapshot path that would write
|
|
||||||
outside the target directory
|
|
||||||
([issue #154](https://git.eeqj.de/sneak/vaultik/issues/154)).
|
|
||||||
`restoreFile` and `verifyRestoredFiles` joined the stored path onto the
|
|
||||||
target with no containment check, so a `..` segment or an absolute path
|
|
||||||
escaped the target and a restored symlink could redirect a later child
|
|
||||||
write anywhere on disk. Every stored path is now rejected unless
|
|
||||||
`filepath.IsLocal` accepts it with the leading separator removed, and
|
|
||||||
each existing ancestor directory below the target is `Lstat`ed to refuse
|
|
||||||
descending through a symlink; honest symlinks pointing outside the tree
|
|
||||||
are still written verbatim. age decryption proves a snapshot is
|
|
||||||
readable, not honest, and restore usually runs as root.
|
|
||||||
|
|
||||||
- 2026-09-21: Stopped `--json` from silencing stderr diagnostics
|
|
||||||
([issue #112](https://git.eeqj.de/sneak/vaultik/issues/112)). `--json`
|
|
||||||
used to be folded into `Quiet`, which pinned the log level to `WARN`,
|
|
||||||
so `prune --json` gave a machine consumer no record of the local index
|
|
||||||
rows it deleted even under `--verbose`. `--json` now quiets only the
|
|
||||||
stdout UI (the JSON document must stay clean, per
|
|
||||||
[issue #108](https://git.eeqj.de/sneak/vaultik/issues/108)); the stderr
|
|
||||||
log level follows `--verbose`/`--debug` again. The coupling was
|
|
||||||
removed the same way for `snapshot verify`, `snapshot remove`, and
|
|
||||||
`remote info`, which carried it for the same outdated reason.
|
|
||||||
|
|
||||||
- 2026-09-21: Stopped `prune` from reporting a failed row count as 0
|
|
||||||
([issue #96](https://git.eeqj.de/sneak/vaultik/issues/96)). The seven
|
|
||||||
`getTableCount` reads in `PruneDatabase` discarded their error, so a
|
|
||||||
query that could not run became a plausible `0` and the before/after
|
|
||||||
delta computed from it looked like real work. Each read now logs at
|
|
||||||
warn on failure and renders as `unknown`, never `0`, so an empty table
|
|
||||||
is distinguishable from one that could not be queried. The counts have
|
|
||||||
no `--json` representation — under `--json` the summary is suppressed
|
|
||||||
entirely — so nothing there can show a false `0`.
|
|
||||||
|
|
||||||
- 2026-09-21: Made the s3 storage backend report a missing object as
|
|
||||||
`storage.ErrNotFound`, like the `file` and `rclone` backends and as the
|
|
||||||
`Storer` interface documents. `S3Storer.Get` and `Stat` returned the raw
|
|
||||||
AWS SDK error, so `errors.Is(err, storage.ErrNotFound)` was false on s3
|
|
||||||
and callers branched differently per backend. Added a small `s3.IsNotFound`
|
|
||||||
helper (reused by `HeadObject`) and a test that a missing key maps to
|
|
||||||
`ErrNotFound`
|
|
||||||
([issue #129](https://git.eeqj.de/sneak/vaultik/issues/129)).
|
|
||||||
- 2026-09-21: Fixed `verify --deep` reporting healthy snapshots as
|
|
||||||
corrupt. Its final blob-integrity check hashed the encrypted
|
|
||||||
downloaded bytes with a single SHA256 and compared that to the blob
|
|
||||||
ID, which is the double SHA256 of the plaintext, so the two could
|
|
||||||
never match. It now hashes the decompressed plaintext and compares the
|
|
||||||
double SHA256. Added a test that backs up a real snapshot, deep-verifies
|
|
||||||
it, then flips a byte in one stored blob and confirms deep verification
|
|
||||||
then fails
|
|
||||||
([issue #131](https://git.eeqj.de/sneak/vaultik/issues/131)).
|
|
||||||
|
|
||||||
- 2026-09-21: Made `snapshot create` VACUUM the per-snapshot metadata
|
|
||||||
database through the `modernc.org/sqlite` driver instead of shelling
|
|
||||||
out to the external `sqlite` command-line binary (issue #120). A
|
|
||||||
backup no longer needs that binary on `PATH`, so `make check` passes
|
|
||||||
on a stock `go install` host; `script/bootstrap` and the `Dockerfile`
|
|
||||||
(both the test-build and the shipped runtime stage) no longer install
|
|
||||||
it, and a new test asserts the uploaded database keeps no pages from
|
|
||||||
deleted rows. Dropped the now-false note on the 2026-08-07 entry below
|
|
||||||
that said bootstrap installs it.
|
|
||||||
- 2026-09-21: Made `.gitea/workflows/check.yml` run on pushes to `main`
|
|
||||||
and `next` and on pull requests against either, so unit PRs (whose
|
|
||||||
base is `next`) and `next` itself get a CI run instead of relying on a
|
|
||||||
local `make check`
|
|
||||||
([issue #122](https://git.eeqj.de/sneak/vaultik/issues/122)).
|
|
||||||
|
|
||||||
- 2026-09-21: Hash-verified the Go toolchain in the release workflow
|
|
||||||
([issue #105](https://git.eeqj.de/sneak/vaultik/issues/105)). New
|
|
||||||
`script/install-go` downloads the exact `go.dev` archive for `go.mod`'s
|
|
||||||
`go` directive and refuses it unless its sha256 matches a value
|
|
||||||
committed in the script; `.gitea/workflows/release.yml` calls it
|
|
||||||
instead of `actions/setup-go`, which verified the downloaded toolchain
|
|
||||||
against nothing in the repo. `GOTOOLCHAIN: local` on the release step
|
|
||||||
keeps that exact compiler from auto-switching. Bumping Go now touches
|
|
||||||
`go.mod`, the checksum, and the `Dockerfile` `golang` digest together.
|
|
||||||
|
|
||||||
- 2026-09-21: Collapsed the two duration parsers into one and fixed the
|
|
||||||
`--older-than` months example
|
|
||||||
([issue #123](https://git.eeqj.de/sneak/vaultik/issues/123)). Two
|
|
||||||
functions named `parseDuration` existed with different grammars;
|
|
||||||
`snapshot purge --older-than` and `--keep-newer-than` both already went
|
|
||||||
through the one in `internal/vaultik`, while the richer copy in
|
|
||||||
`internal/cli/duration.go` was reachable only from its own test. Kept
|
|
||||||
the live-path parser and deleted the unused one, so no flag's accepted
|
|
||||||
grammar changes. The trap the issue was filed over: `README.md`
|
|
||||||
documented `6m` as the months example for `--older-than`, but `m` is
|
|
||||||
minutes, so the documented command deleted every snapshot older than
|
|
||||||
six minutes on a destructive flag. Corrected the doc to `6mo` and put
|
|
||||||
both flags' help text on one example list that states `m` is minutes
|
|
||||||
and `mo` is months. The surviving parser now rejects negatives, which
|
|
||||||
it previously accepted (`-5h`) or silently made positive (`-5d`).
|
|
||||||
Table-driven tests cover every unit, `6m` as six minutes, `6mo` as 180
|
|
||||||
days, and rejection of a bare number, an unknown unit, and a negative.
|
|
||||||
|
|
||||||
- 2026-08-10: Moved every lint run into its own container, as a build
|
|
||||||
step ([issue #113](https://git.eeqj.de/sneak/vaultik/issues/113)).
|
|
||||||
New root `Dockerfile.lint`, built by `script/lint`, runs
|
|
||||||
`golangci-lint run --config .golangci.yml ./...` as a `RUN`
|
|
||||||
instruction in the digest-pinned `golangci/golangci-lint` image: a
|
|
||||||
successful build of that file *is* a clean lint, and it works even
|
|
||||||
where the daemon is remote and bind mounts are impossible. That
|
|
||||||
`FROM` line is now the only pin of the linter version in the repo.
|
|
||||||
|
|
||||||
This supersedes the per-worktree cache isolation landed for
|
|
||||||
[issue #99](https://git.eeqj.de/sneak/vaultik/issues/99). Isolation
|
|
||||||
fixed cross-worktree contamination but not lock contention — two
|
|
||||||
concurrent runs with entirely separate cache directories still
|
|
||||||
collided. A container per run has its own cache and its own lock, so
|
|
||||||
the whole class is gone, and with it the per-worktree cache
|
|
||||||
machinery, the lock-retry loop, and `script/lint-audit`, which
|
|
||||||
existed to catch replayed findings from a cache that no longer
|
|
||||||
exists. The host lint path went too: no escape hatch, no
|
|
||||||
`VAULTIK_LINT_IN_CONTAINER`, no version detection. Nothing lints on
|
|
||||||
the host at any version.
|
|
||||||
|
|
||||||
A cached build lints nothing, so the same `CHECK_EPOCH` mechanism the
|
|
||||||
product `Dockerfile` already used is what makes the green mean
|
|
||||||
something: `ARG CHECK_EPOCH` with no default below the module layers,
|
|
||||||
a `RUN [ -n "$CHECK_EPOCH" ] || exit 1` guard, the value expanded
|
|
||||||
into each check command, and a fresh `$(date +%s%N)$$` per invocation
|
|
||||||
computed as a bare assignment. `cmd/vaultik/lintdocker_test.go`
|
|
||||||
parses both Dockerfiles and both scripts and fails if any part of
|
|
||||||
that is dropped, because every way of losing it is silent. No test
|
|
||||||
asserts that no script runs the host linter: `script/lint` is the one
|
|
||||||
lint entry point and runs `golangci-lint` only inside the container,
|
|
||||||
and keeping it that way is a review matter, not something a test
|
|
||||||
proves.
|
|
||||||
|
|
||||||
The product `Dockerfile` lost its lint stage rather than gaining a
|
|
||||||
second linter pin: `make lint` is now `docker build`, so the stage
|
|
||||||
would have been docker-in-docker with no daemon, and calling
|
|
||||||
`golangci-lint` directly there would have restored the two-pins drift
|
|
||||||
of [issue #78](https://git.eeqj.de/sneak/vaultik/issues/78).
|
|
||||||
`make fmt-check` moved beside `make test` in the builder stage, and
|
|
||||||
`script/cibuild` now builds `Dockerfile.lint` and then `Dockerfile`,
|
|
||||||
each with its own fresh epoch. Consequence, stated rather than left
|
|
||||||
to be found: `script/docker` builds the product image only and no
|
|
||||||
longer lints; the gates are `script/check` and `script/cibuild`.
|
|
||||||
|
|
||||||
`golangci-lint config verify` runs as its own epoch-keyed layer,
|
|
||||||
above the lint. `golangci-lint run` rejects a config it cannot parse
|
|
||||||
but silently ignores an unknown top-level *key*: renaming `linters:`
|
|
||||||
to `linterz:` discarded `default: all` and every threshold and still
|
|
||||||
exited 0 on a tree the real config fails. `config verify` catches
|
|
||||||
that, and it does so with the network off at this pin — checked under
|
|
||||||
`docker run --network none`, not assumed. An earlier revision omitted
|
|
||||||
it on the claim that it fetches its schema over live HTTPS; that
|
|
||||||
claim was false at v2.12.2.
|
|
||||||
|
|
||||||
`script/lint-fix` is kept, reimplemented as a
|
|
||||||
bind-mounted `docker run` against the image parsed out of
|
|
||||||
`Dockerfile.lint` — it cannot be a build step, because fixes have to
|
|
||||||
land in the worktree — and marked in its header as a developer
|
|
||||||
convenience that no gate reads.
|
|
||||||
|
|
||||||
- 2026-08-09: Finished the `--json` stdout contract and gave `make build`
|
|
||||||
a rule ([issue #108](https://git.eeqj.de/sneak/vaultik/issues/108),
|
|
||||||
[issue #110](https://git.eeqj.de/sneak/vaultik/issues/110)). Two
|
|
||||||
unrelated defects of the same shape — a command reporting something it
|
|
||||||
did not do — landed together because both are small.
|
|
||||||
|
|
||||||
`CleanupLocalSnapshots` wrote three prose lines to stdout with no
|
|
||||||
`--json` awareness, covering every branch of the function, so no input
|
|
||||||
avoided them and `vaultik prune --json | jq` failed even after
|
|
||||||
[issue #106](https://git.eeqj.de/sneak/vaultik/issues/106) removed the
|
|
||||||
banner. `-q` never helped either: `printlnStdout` and `stdoutf` write
|
|
||||||
straight to `Vaultik.Stdout` and never consult `Vaultik.UI`, which is
|
|
||||||
what `SetQuiet` affects. The issue offered three fixes and asked for a
|
|
||||||
decision. Taken: thread `*PruneOptions` into the function and gate each
|
|
||||||
write on `!opts.JSON`, matching `PruneBlobs` (its sibling phase, which
|
|
||||||
already takes the same struct), `RemoveSnapshot` and `remote info`, so
|
|
||||||
the package has one pattern rather than two. Rejected: moving the lines
|
|
||||||
to `log.Info`, because the logger's default level is `slog.LevelWarn`,
|
|
||||||
so that would not relocate them to stderr — it would delete them from a
|
|
||||||
plain `vaultik prune`, and the removal of rows from the local index is
|
|
||||||
not something to narrate only under `--verbose`. Also rejected: putting
|
|
||||||
the stale-record count into `PruneBlobsResult`, whose every field is
|
|
||||||
blob-scoped and which is produced by the later phase; a prune document
|
|
||||||
covering both phases is a reasonable thing to want, but it is a schema
|
|
||||||
design question and not a stream-hygiene fix. The narration is
|
|
||||||
duplicated as `log.Info` records, which `PruneBlobs` already does
|
|
||||||
alongside its own prints, so the events survive on stderr for anyone
|
|
||||||
running `--verbose`.
|
|
||||||
|
|
||||||
`make build` printed "Nothing to be done for 'build'" and exited 0
|
|
||||||
without producing a binary: `build` was listed in `.PHONY` with no
|
|
||||||
`build:` rule anywhere, and declaring a name phony is exactly what
|
|
||||||
converts make's "No rule to make target" error into a silent success.
|
|
||||||
Fixed with `build: vaultik`, keeping `vaultik:` as the file rule. The
|
|
||||||
audit the issue asked for covers all 19 `.PHONY` names; `build` was the
|
|
||||||
only one without a rule, and `vaultik` is correctly absent from
|
|
||||||
`.PHONY`, being a real file target.
|
|
||||||
|
|
||||||
Tests, each verified to fail with the fix reverted rather than assumed
|
|
||||||
to: `CleanupLocalSnapshots` leaves stdout untouched under `--json` in
|
|
||||||
all three branches (stale records, none, empty index) and still emits
|
|
||||||
every line without it, so the guard cannot be satisfied by deleting the
|
|
||||||
output; `prune --json` run end to end through `Entry`, cobra and fx
|
|
||||||
over the process's real stdout descriptor against a `file://` store,
|
|
||||||
asserting exactly one JSON document, in both the stale and non-stale
|
|
||||||
branches; and a parse of the `Makefile` asserting every `.PHONY` name
|
|
||||||
has a rule and that `build` reaches the rule that produces the binary,
|
|
||||||
which keeps the audit true for names added later. That last one is a
|
|
||||||
parse rather than an invocation of `make`, since `make test` is what
|
|
||||||
runs it and shelling back into `make build` would nest a build inside
|
|
||||||
the test run. The property a parse cannot establish — that the recipe
|
|
||||||
still fails when the build fails — was verified by hand against a
|
|
||||||
deliberately broken tree: `make build` exits 2 and produces nothing.
|
|
||||||
`cmd/vaultik` gains its first test file, so `make test` now reports 16
|
|
||||||
packages `ok` where it reported 15.
|
|
||||||
|
|
||||||
- 2026-08-09: Stopped the startup banner from contaminating `--json`
|
|
||||||
documents ([issue #106](https://git.eeqj.de/sneak/vaultik/issues/106)).
|
|
||||||
`Entry` writes the banner to stdout before cobra parses anything, and
|
|
||||||
the flag scan that suppresses it knew `--quiet`, `-q` and `--cron` but
|
|
||||||
not `--json`, so every `--json` document arrived behind two lines of
|
|
||||||
prose and a blank line, and `vaultik snapshot list --json | jq` failed.
|
|
||||||
With the logger already on stderr from
|
|
||||||
[issue #82](https://git.eeqj.de/sneak/vaultik/issues/82), this was the
|
|
||||||
last writer that could put something on stdout that the caller did not
|
|
||||||
ask for. The design question the issue raised — extend the raw-argv
|
|
||||||
scan, or move the banner after parsing — is answered in favour of the
|
|
||||||
scan: the banner is printed first deliberately, so that it still
|
|
||||||
appears when cobra rejects the arguments and on `--help`, and after
|
|
||||||
parsing there is no single place that covers those paths. The stated
|
|
||||||
cost of the scan, that `--json` is a subcommand flag matched anywhere
|
|
||||||
in the vector, is a cost `--cron` already carries — it exists only on
|
|
||||||
`snapshot create` — so this adds an instance of an accepted
|
|
||||||
imprecision rather than a new kind, and the two error directions are
|
|
||||||
not symmetric: a false positive loses a decorative banner, a false
|
|
||||||
negative corrupts a document. Regression tests at the CLI layer, where
|
|
||||||
`internal/vaultik`'s existing guard cannot reach: one runs `Entry`
|
|
||||||
itself over the process's real stdout descriptor, through cobra and fx
|
|
||||||
to the document, made hermetic by `file://` storage; a second covers
|
|
||||||
the argument vectors of all five `--json` commands; a third asserts the
|
|
||||||
banner is still printed without a suppressing flag, so the first
|
|
||||||
cannot be satisfied by deleting the banner. Also corrected `AGENTS.md`
|
|
||||||
policy 9, which still keyed the structured-log format on stdout's
|
|
||||||
TTY-ness after #82 moved that decision to stderr — a rules file that
|
|
||||||
misdescribes the code misleads exactly the readers who trust it most.
|
|
||||||
Two smaller findings from the same review: `bytesAttrKey`'s
|
|
||||||
human-readable byte formatting silently stopped applying under an open
|
|
||||||
group, because the key reaching the comparison is group-qualified
|
|
||||||
(`transfer.bytes`), now matched on its final segment and tested both
|
|
||||||
ways; and `listEnv.stderr` in `snapshot_list_test.go`, assigned but
|
|
||||||
never read since those tests began capturing the process's stderr, is
|
|
||||||
removed. `Vaultik.Stderr` is kept — nothing writes to it today, which
|
|
||||||
its comment now says outright.
|
|
||||||
|
|
||||||
- 2026-08-09: Moved the logger to stderr and fixed `TTYHandler`'s
|
|
||||||
discarded attributes
|
|
||||||
([issue #82](https://git.eeqj.de/sneak/vaultik/issues/82),
|
|
||||||
[issue #97](https://git.eeqj.de/sneak/vaultik/issues/97)). Two defects
|
|
||||||
in `internal/log`, fixed together because both live in the handler
|
|
||||||
construction path. The first: both handlers were built over
|
|
||||||
`os.Stdout`, and `WARN`/`ERROR` are never suppressed, so a config file
|
|
||||||
with group- or world-readable permissions was enough to put a log
|
|
||||||
record inside a `--json` document and break `jq`. Diagnostics now go
|
|
||||||
to stderr, and the TTY/JSON format choice follows stderr rather than
|
|
||||||
stdout — testing the wrong stream would colorize records on a
|
|
||||||
redirected stderr whenever stdout happened to be a terminal. This is
|
|
||||||
user-visible: `--verbose` and `--debug` output moves to stderr too,
|
|
||||||
which is documented in `README.md` under "stdout and stderr". It also
|
|
||||||
let the local workaround in `internal/vaultik/snapshot_list.go` go:
|
|
||||||
`warnWhileListing` had been hand-rolling structured-log formatting to
|
|
||||||
reach a non-stdout writer, and the `jsonOutput` parameter threaded
|
|
||||||
through the remote-listing helpers existed only to choose between the
|
|
||||||
two writers. The collect-then-emit machinery around `listingWarning`
|
|
||||||
stays, but on its remaining merit — warnings emitted in key order
|
|
||||||
after `group.Wait()` are deterministic run to run, where emitting from
|
|
||||||
the fetch workers would order them by network timing. The second
|
|
||||||
defect: `TTYHandler.WithAttrs` and `WithGroup` discarded their
|
|
||||||
arguments and returned the receiver while their doc comments claimed
|
|
||||||
otherwise, so `log.With` attributes vanished on a terminal and
|
|
||||||
appeared correctly in CI — failing precisely when someone is debugging
|
|
||||||
interactively. Both now return a new handler (the receiver is never
|
|
||||||
written to, since `slog` permits concurrent derivation), attributes
|
|
||||||
persist across records, and grouping is implemented as dotted key
|
|
||||||
prefixes, which is the only honest rendering for a format with nowhere
|
|
||||||
to nest. New tests cover both, including one that feeds the same
|
|
||||||
derivation chain to the TTY and JSON handlers and compares the
|
|
||||||
attribute sets, so the two paths cannot drift apart again. Found and
|
|
||||||
filed while verifying: the startup banner is written to stdout and
|
|
||||||
`--json` does not suppress it
|
|
||||||
([issue #106](https://git.eeqj.de/sneak/vaultik/issues/106)), which is
|
|
||||||
a separate writer on a separate path and the remaining source of
|
|
||||||
stdout contamination.
|
|
||||||
|
|
||||||
- 2026-08-09: Made the tagged-release path actually work on Gitea
|
|
||||||
([issue #65](https://git.eeqj.de/sneak/vaultik/issues/65)). Three
|
|
||||||
independent blockers, one of which was the whole
|
|
||||||
release: `.goreleaser.yaml` had no `gitea_urls:` block, so goreleaser
|
|
||||||
defaulted to the GitHub API and a `goreleaser release` from this repo
|
|
||||||
would have failed or published where nobody is looking. It now points
|
|
||||||
at `https://git.eeqj.de/api/v1`. The version is the second: it was a
|
|
||||||
hardcoded `VERSION := 1.0.0-rc.1` in the `Makefile`, so every local
|
|
||||||
build claimed to be a release candidate that had never been tagged and
|
|
||||||
did not exist, while `git tag -l` was empty and `internal/globals`
|
|
||||||
defaulted to `dev`. Version now comes from git via the new
|
|
||||||
`script/version` — the exact tag with a leading `v` stripped (so a
|
|
||||||
`make` build and a goreleaser build of one commit report the same
|
|
||||||
string, and it matches the archive names), otherwise `dev-<12-char
|
|
||||||
sha>`, with `-dirty` appended in either case when tracked files are
|
|
||||||
modified. Untracked files are deliberately not counted, matching
|
|
||||||
`git describe --dirty`. The same honesty was owed by the snapshot
|
|
||||||
path: `snapshot.version_template` was `{{ incpatch .Version }}-next`,
|
|
||||||
which manufactures a release number from the last tag and, with no
|
|
||||||
tags at all, from goreleaser's fabricated `v0.0.0`; it now emits the
|
|
||||||
same `dev-<sha>`. The one non-obvious consequence is that
|
|
||||||
`internal/cli/version.go` gated its "this is a development build"
|
|
||||||
notice on the version being exactly `dev`, so the moment untagged
|
|
||||||
builds began carrying a commit sha that notice would have gone silent
|
|
||||||
and an unreleased binary would have read as a release — the gate is
|
|
||||||
now `globals.IsDevVersion`, which is a predicate over a string rather
|
|
||||||
than a comparison against a global precisely so it can be tested, and
|
|
||||||
it is tested at the boundary (`1.0.0-dev` is a release, `dev-<sha>`
|
|
||||||
is not). Release automation is the third blocker: a tag-triggered
|
|
||||||
`.gitea/workflows/release.yml` runs the build in CI rather than from
|
|
||||||
a laptop, with `fetch-depth: 0` because a shallow checkout has no
|
|
||||||
tags and would silently mislabel the release, and with the
|
|
||||||
`RELEASE_TOKEN` repository secret passed as `GITEA_TOKEN` (documented
|
|
||||||
in `README.md`; the runner's automatic token is not used because it
|
|
||||||
is not guaranteed to carry release write scope). `script/release`
|
|
||||||
unsets any `GITHUB_TOKEN`/`GITLAB_TOKEN` it finds, since goreleaser
|
|
||||||
chooses its forge from whichever token variable is set and refuses to
|
|
||||||
run when it sees more than one — a runner-provided token must not get
|
|
||||||
to decide where these artifacts are published. `make release` and
|
|
||||||
`make release-snapshot`, the last two Makefile targets that were not
|
|
||||||
shims, now call `script/release` and `script/release-snapshot`, which
|
|
||||||
resolve goreleaser exactly the way `script/lint` resolves the linter:
|
|
||||||
a `PATH` binary is used only at the pinned version, never as a silent
|
|
||||||
fallback. `script/bootstrap` installs it, from a sha256-verified
|
|
||||||
GitHub release archive per `REPO_POLICIES.md`, via a separate
|
|
||||||
`script/install-goreleaser` — separate because `script/bootstrap`
|
|
||||||
hard-fails without a usable Docker daemon by design, and the release
|
|
||||||
runner needs goreleaser without needing Docker. Verified by running
|
|
||||||
the thing rather than reading it: `make release-snapshot` produced
|
|
||||||
four archives and `checksums.txt`, and the linux/amd64 binary from
|
|
||||||
`dist/` reports `dev-<sha>` with the development-build notice. Tag
|
|
||||||
handling was exercised in a throwaway repository rather than by
|
|
||||||
tagging this one; no tag was created here, since that is the owner's
|
|
||||||
call. Signing, SBOM, reproducible builds, completions and a man page
|
|
||||||
are out of scope by the issue.
|
|
||||||
|
|
||||||
- 2026-08-09: Isolated the lint cache per worktree and context-gated the
|
|
||||||
native lint path (issues #99, #80). One defect seen twice:
|
|
||||||
`script/lint` decided whether it could skip the pinned image by asking
|
|
||||||
what version was on `PATH` rather than where it was running, and cache
|
|
||||||
isolation is part of that same question. The cache was one directory
|
|
||||||
per repo, shared by every worktree on the host, so two checkouts with
|
|
||||||
identical Go file contents collided and golangci-lint replayed the
|
|
||||||
stored analysis — paths and all. The loud direction of that failure
|
|
||||||
(a clean tree failed by a dirty sibling) is the harmless one; the
|
|
||||||
silent direction, a dirty tree **passed** by a clean sibling, is a
|
|
||||||
sixth way for a gate here to report a green it did not earn. The cache
|
|
||||||
is now keyed on a digest of the worktree path, and every run is
|
|
||||||
audited by the new `script/lint-audit`, which rejects output citing any
|
|
||||||
file that is not in the tree being linted — a backstop that runs on
|
|
||||||
clean output too, because that is the case nobody investigates. Caches
|
|
||||||
record the worktree they belong to and are collected when it
|
|
||||||
disappears, so throwaway worktrees do not accumulate them; the whole
|
|
||||||
tree lives under `XDG_CACHE_HOME` and is disposable. The
|
|
||||||
`parallel golangci-lint is running` refusal is now a bounded retry
|
|
||||||
rather than a verdict: it is not a lint result, and exiting non-zero
|
|
||||||
on it is indistinguishable to a caller from real findings (#88 showed
|
|
||||||
a private cache does not remove that contention). The native path now
|
|
||||||
requires `VAULTIK_LINT_IN_CONTAINER=1`, set only by the `Dockerfile`
|
|
||||||
lint stage, in addition to matching the pin, so a developer's locally
|
|
||||||
installed 2.12.2 no longer bypasses the digest pin; `/.dockerenv` was
|
|
||||||
rejected as the signal because `dockerd` creates it for `docker run`
|
|
||||||
and it is not reliably present during a BuildKit `docker build`, which
|
|
||||||
is the case the exception exists for. Version detection uses
|
|
||||||
`golangci-lint version --short` with the old banner scrape kept only
|
|
||||||
as a fallback. `script/bootstrap` no longer prints `bootstrap
|
|
||||||
complete` on a machine that cannot run the gate: a missing docker, or
|
|
||||||
one whose daemon is unreachable, is a hard failure naming exactly what
|
|
||||||
breaks. Verification was by reproduction rather than inspection — two
|
|
||||||
concurrent lints from two worktrees of differing cleanliness, a real
|
|
||||||
run made to report an outside path, a matching linter shimmed onto
|
|
||||||
`PATH`, and a `PATH` with docker removed — and is recorded on the pull
|
|
||||||
request.
|
|
||||||
- 2026-08-09: Closed the fifth false-green mechanism (issues #93, #69).
|
|
||||||
`script/test` omitted `-count=1`, so Go's test result cache could
|
|
||||||
satisfy the gate outright: a second back-to-back `make test` printed
|
|
||||||
the full set of 14 `ok` lines, every one marked `(cached)`, having
|
|
||||||
executed no test at all. Since `ok <pkg> (cached)` is an `ok` line,
|
|
||||||
the "14 `ok` lines means the suite ran" signal this repo leans on was
|
|
||||||
forgeable, one level below the Docker layer cache that #85 addressed.
|
|
||||||
Fixed with `-count=1` unconditionally rather than only in the
|
|
||||||
container, because the pre-commit hook runs the same script and a
|
|
||||||
gate honest only in CI is dishonest where people rely on it most;
|
|
||||||
`test-coverage` got the same flag, and `script/check` inherits it by
|
|
||||||
calling `script/test`. In the same area, `make test-integration` was
|
|
||||||
deleted rather than made real: no file in the repo carried a build
|
|
||||||
tag, so `-tags=integration` selected nothing and the target was an
|
|
||||||
exact duplicate of `make test`. Tagging a subset was rejected because
|
|
||||||
the entire suite runs in well under a minute, and a scheme whose
|
|
||||||
failure mode is "some tests silently stopped running" is a poor trade
|
|
||||||
for those seconds in a repo with this particular history. The
|
|
||||||
`-timeout` was raised from 30s after measuring rather than after
|
|
||||||
assuming: the standing claim that cold-cache compilation is charged
|
|
||||||
against `-timeout` is **false**, disproved by a containerised run
|
|
||||||
that spent 46s compiling and still reported per-package durations
|
|
||||||
within noise of a warm host run. `-timeout` reaches the test binary
|
|
||||||
as `-test.timeout` and its clock starts inside `testing.M.Run`, after
|
|
||||||
the build. The real exposure was margin, not compilation. The 120s
|
|
||||||
landed on is a **deliberate, documented divergence** from
|
|
||||||
`REPO_POLICIES.md:192`, which mandates 30s, and from that file's
|
|
||||||
canonical recipe at `:212-214`; the divergence is recorded in
|
|
||||||
`script/test`'s comment because `REPO_POLICIES.md` is org-canonical
|
|
||||||
and not editable here, and issue #101 proposes amending the policy
|
|
||||||
text upstream. Numbers and the full verification are recorded once,
|
|
||||||
on the pull request, and are deliberately not restated here.
|
|
||||||
|
|
||||||
- 2026-08-09: Triaged all fifteen stale remote branches (issue #71) and
|
|
||||||
deleted fourteen of them; the full per-branch disposition with
|
|
||||||
evidence is recorded on that issue. Method mattered more than the
|
|
||||||
outcome here: a three-dot `git diff main...branch` diffs from the
|
|
||||||
merge base, so it replays everything that landed on `main` after the
|
|
||||||
branch diverged and makes any old branch look like it holds unlanded
|
|
||||||
work. That artifact is what made `golangci-v2.12.2` appear to carry
|
|
||||||
126 files of unpushed changes when its tree was byte-identical to
|
|
||||||
`main`'s. Every containment claim here therefore rests on two-dot tip
|
|
||||||
diffs, tree-hash equality, `git cherry`, and `git branch -r --merged`.
|
|
||||||
Nine branches were plain ancestors of `main` with zero `git cherry`
|
|
||||||
`+` commits. `golangci-v2.12.2` had landed squashed as `cc58583`,
|
|
||||||
whose tree hash equals the branch tip's exactly; note the hash
|
|
||||||
recorded in the issue had gone stale because `main` advanced, so the
|
|
||||||
check had to be redone rather than repeated.
|
|
||||||
`fix/sync-snapshot-cleanup` was redundant, its one line already on
|
|
||||||
`main` in `syncWithRemote`. `feature/restore-progress-bar` was
|
|
||||||
superseded by `printRestoreProgress` and the disk-backed blob cache,
|
|
||||||
and had become actively regressive — it would have deleted
|
|
||||||
`internal/blobgen/compress_test.go`, the #28 regression test that
|
|
||||||
landed separately. The two branches this issue was filed for both
|
|
||||||
turned out to be closed questions that `main` had already moved past
|
|
||||||
by a recorded decision, so neither was landed and no regression test
|
|
||||||
was owed: `ctime` no longer exists anywhere in the codebase after
|
|
||||||
`1c72a37` removed the column, the `File.CTime` field and every use
|
|
||||||
(#54/#55), and change detection compares size, mtime, mode, uid and
|
|
||||||
gid only, exactly as `ARCHITECTURE.md` documents — so the
|
|
||||||
silently-skipped-file data-loss risk that made this a 1.0 item does
|
|
||||||
not exist. The SQL allow-list branch would have reverted `bfd7334`,
|
|
||||||
which replaced that very allow-list with regex sanitisation on review
|
|
||||||
feedback, and would have broken `getTableCount("snapshots")` because
|
|
||||||
its allow-list omits that table. `feature/daemon-mode` is untouched
|
|
||||||
and deferred to #94 pending an owner decision, so it is the one
|
|
||||||
branch besides `main` still on the remote. The stale `TODO.md` entry
|
|
||||||
named in the issue needed no fix: `e496aa3` had already removed it.
|
|
||||||
No product code changed.
|
|
||||||
- 2026-08-09: Adopted the remaining upstream `CHECK_EPOCH` hardening
|
|
||||||
(issue #91), closing the gap #85 knowingly left open. Four changes,
|
|
||||||
all four decided as adopt upstream in `sneak/prompts` #26. (1) Each
|
|
||||||
check stage now asserts `[ -n "$CHECK_EPOCH" ] || exit 1` before
|
|
||||||
running anything, so a build that supplies no `--build-arg` fails
|
|
||||||
instead of lying. This is the item that mattered: an unset `ARG` is
|
|
||||||
an empty string and an empty string is a stable cache key, so the
|
|
||||||
second and every later bare `docker build .` on an unchanged tree
|
|
||||||
replayed all three check layers and still exited 0 — and `docker
|
|
||||||
build .` is the command `REPO_POLICIES.md` names verbatim as a thing
|
|
||||||
that must be green, so the documented command was precisely the one
|
|
||||||
that lied. Failed steps are never cached, which is what makes the
|
|
||||||
guard fire on every invocation rather than once. (2) The epoch is now
|
|
||||||
expanded into each check command rather than left as a bare
|
|
||||||
declaration, so the cache miss no longer depends on BuildKit's
|
|
||||||
unreferenced-`ARG` handling staying as it is, and the value appears
|
|
||||||
in the build log. (3) `script/cibuild` uses
|
|
||||||
`epoch="$(date +%s%N)$$"`, unique per invocation rather than per
|
|
||||||
second; `%N` alone is insufficient because busybox drops it silently
|
|
||||||
and exits 0, and `$$` is what makes the guarantee hold regardless.
|
|
||||||
The bare-assignment form is kept deliberately — inlined in an
|
|
||||||
argument, a failing substitution does not abort under `set -eu` and
|
|
||||||
would yield an empty constant epoch, restoring the exact false green
|
|
||||||
being fixed. (4) `script/docker` passes the same fresh arg, so the
|
|
||||||
two entrypoints cannot disagree about whether the tree is green;
|
|
||||||
local builds are almost always warm, which made it the likelier
|
|
||||||
fooling in practice. The `ARG` placement from #85 is unchanged, below
|
|
||||||
`apk add`, `COPY go.mod go.sum` and `go mod download`, so dependency
|
|
||||||
layers still cache and the build is not cold. Verified by negative
|
|
||||||
control rather than inspection — a bare `docker build .` run twice
|
|
||||||
back to back, plus back-to-back pairs of both scripts and a host-side
|
|
||||||
`make check`; the measurements are recorded once, in the PR
|
|
||||||
verification comment, rather than restated here. `.golangci.yml`, the
|
|
||||||
lint-stage `FROM` line and its digest, `script/lint`,
|
|
||||||
`REPO_POLICIES.md` and `.gitea/workflows/check.yml` are all
|
|
||||||
untouched.
|
|
||||||
- 2026-08-09: Stopped `script/cibuild` from reporting a green it did
|
- 2026-08-09: Stopped `script/cibuild` from reporting a green it did
|
||||||
not earn (issue #85). A bare `docker build .` let Docker serve the
|
not earn (issue #85). A bare `docker build .` let Docker serve the
|
||||||
check layers from the layer cache whenever the tree had not changed:
|
check layers from the layer cache whenever the tree had not changed:
|
||||||
@@ -638,11 +94,8 @@ release" is exactly the contradiction
|
|||||||
exactly the pinned one (which is how the lint stage runs it inside the
|
exactly the pinned one (which is how the lint stage runs it inside the
|
||||||
container); anything else goes through Docker, and a missing or
|
container); anything else goes through Docker, and a missing or
|
||||||
unreachable Docker daemon is a hard error rather than a silent
|
unreachable Docker daemon is a hard error rather than a silent
|
||||||
fallback. Only the **lint** leg of `make check` became equivalent to
|
fallback. `make check` is therefore now as trustworthy as
|
||||||
`script/cibuild`; its tests and `gofmt` still run on the host against
|
`script/cibuild`.
|
||||||
the host toolchain, as `README.md` states. An earlier version of this
|
|
||||||
entry claimed `make check` was "as trustworthy as `script/cibuild`"
|
|
||||||
outright, which overstated it; corrected under issue #80.
|
|
||||||
- 2026-08-09: Finished the lint remediation under the canonical
|
- 2026-08-09: Finished the lint remediation under the canonical
|
||||||
`.golangci.yml` (issue #61, which also unblocks issue #59). The
|
`.golangci.yml` (issue #61, which also unblocks issue #59). The
|
||||||
remaining findings were fixed behavior-preservingly: `wsl_v5`
|
remaining findings were fixed behavior-preservingly: `wsl_v5`
|
||||||
@@ -670,7 +123,7 @@ release" is exactly the contradiction
|
|||||||
was green was wrong.
|
was green was wrong.
|
||||||
- 2026-08-07: Added the standard `.golangci.yml` and `.editorconfig`
|
- 2026-08-07: Added the standard `.golangci.yml` and `.editorconfig`
|
||||||
(issue #59); lint findings under the new config are tracked in issue
|
(issue #59); lint findings under the new config are tracked in issue
|
||||||
#61.
|
#61. `script/bootstrap` now installs sqlite3 (needed by tests).
|
||||||
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
|
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
|
||||||
Makefile shims, README Entrypoints section
|
Makefile shims, README Entrypoints section
|
||||||
- 2026-07-02: Consolidated CLI verbs, retired overlapping commands; bound
|
- 2026-07-02: Consolidated CLI verbs, retired overlapping commands; bound
|
||||||
@@ -693,4 +146,4 @@ release" is exactly the contradiction
|
|||||||
|
|
||||||
# Future Steps
|
# Future Steps
|
||||||
|
|
||||||
None queued; the release-scoping item is now the Next Step.
|
- Define remaining scope for a first tagged release and cut v0.1.0.
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
package main_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// This file guards the version stamping of the product image (issue
|
|
||||||
// #75). The failure it protects against is silent: the image still
|
|
||||||
// builds and runs, but `vaultik version` inside it reports "commit:
|
|
||||||
// unknown", so an operator cannot tell which source produced a given
|
|
||||||
// backup. .dockerignore excludes .git, so the build cannot derive the
|
|
||||||
// commit itself; the values must be computed on the host and passed in.
|
|
||||||
//
|
|
||||||
// These are parses of the committed files, for the same reason the lint
|
|
||||||
// guards next door are: shelling out to docker would nest a build
|
|
||||||
// inside `make test`. That `vaultik version` in the built image really
|
|
||||||
// prints the host's version is verified by hand and recorded on the
|
|
||||||
// pull request.
|
|
||||||
|
|
||||||
// dockerScript is script/docker, relative to the repository root.
|
|
||||||
const dockerScript = "script/docker"
|
|
||||||
|
|
||||||
// versionArgs are the ldflag targets the build stamps and, matching
|
|
||||||
// them, the build args the host must supply. The names line up so the
|
|
||||||
// same list checks both files.
|
|
||||||
func versionArgs() []string {
|
|
||||||
return []string{"VERSION", "COMMIT", "COMMIT_DATE"}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestProductDockerfileTakesVersionAsBuildArgs fails unless the build
|
|
||||||
// declares each version arg and stamps it into the binary by ldflag
|
|
||||||
// reference, rather than computing it in the container.
|
|
||||||
func TestProductDockerfileTakesVersionAsBuildArgs(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
found := instructions(t, productDockerfile)
|
|
||||||
|
|
||||||
for _, arg := range versionArgs() {
|
|
||||||
require.GreaterOrEqual(t, indexOf(found, "ARG "+arg), 0,
|
|
||||||
"%s must declare `ARG %s` so the host can pass it in",
|
|
||||||
productDockerfile, arg)
|
|
||||||
|
|
||||||
assertLdflagReferences(t, found, arg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestProductDockerfileDoesNotDeriveVersionItself is the anti-regression
|
|
||||||
// for the original defect: the container ran `git rev-parse`, but .git
|
|
||||||
// is not in the build context, so it always resolved to "unknown". No
|
|
||||||
// git command may reach into a build that cannot see the history.
|
|
||||||
func TestProductDockerfileDoesNotDeriveVersionItself(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
text := instructionText(readRepoFile(t, productDockerfile))
|
|
||||||
|
|
||||||
assert.NotContains(t, text, "git ",
|
|
||||||
"%s must not run git: .git is excluded from the build context, so"+
|
|
||||||
" any value it derives is wrong. Pass version, commit and date"+
|
|
||||||
" in as build args instead.", productDockerfile)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDockerScriptComputesVersionOnTheHost fails unless script/docker
|
|
||||||
// derives each value where .git exists and passes it as a build arg,
|
|
||||||
// with VERSION coming from script/version so a Docker build reports the
|
|
||||||
// same string a local build of the same tree would.
|
|
||||||
func TestDockerScriptComputesVersionOnTheHost(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
script := readRepoFile(t, dockerScript)
|
|
||||||
|
|
||||||
for _, arg := range versionArgs() {
|
|
||||||
assert.Contains(t, script, "--build-arg "+arg+"=",
|
|
||||||
"%s must pass --build-arg %s to the build", dockerScript, arg)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Contains(t, script, "/version",
|
|
||||||
"%s must take VERSION from script/version, the source of truth"+
|
|
||||||
" shared with the Makefile", dockerScript)
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertLdflagReferences fails unless some build instruction stamps the
|
|
||||||
// named variable from the ARG (a ${arg} reference), not from a value
|
|
||||||
// computed inside the container.
|
|
||||||
func assertLdflagReferences(t *testing.T, found []string, arg string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
for _, instruction := range found {
|
|
||||||
if strings.HasPrefix(instruction, "RUN ") &&
|
|
||||||
strings.Contains(instruction, "go build") &&
|
|
||||||
strings.Contains(instruction, "${"+arg+"}") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Fail(t, "version arg is declared but never stamped",
|
|
||||||
"the go build in %s must reference ${%s} in its ldflags, or the"+
|
|
||||||
" arg is passed and discarded", productDockerfile, arg)
|
|
||||||
}
|
|
||||||
@@ -1,367 +0,0 @@
|
|||||||
package main_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// This file guards the shape of the lint gate. Every property asserted
|
|
||||||
// here is one whose loss is SILENT: the build still exits 0, the gate
|
|
||||||
// still looks green, and nothing was linted or tested.
|
|
||||||
//
|
|
||||||
// The gate is a build step. script/lint builds Dockerfile.lint, which
|
|
||||||
// runs golangci-lint as a RUN instruction, so a successful build is a
|
|
||||||
// clean lint. BuildKit will happily replay that RUN from cache on an
|
|
||||||
// unchanged tree in well under a second, which is why the check layers
|
|
||||||
// are keyed on a CHECK_EPOCH build arg that the calling script
|
|
||||||
// regenerates per invocation, and why an empty value is a hard error
|
|
||||||
// rather than a stable cache key.
|
|
||||||
//
|
|
||||||
// These are parses rather than invocations. Shelling out to docker from
|
|
||||||
// the test suite would nest a build inside `make test`, which itself
|
|
||||||
// runs inside a build in CI. The one property a parse cannot establish
|
|
||||||
// -- that a real finding actually fails the build -- is verified by
|
|
||||||
// hand against a deliberately broken tree, recorded on the pull
|
|
||||||
// request.
|
|
||||||
//
|
|
||||||
// One property is deliberately NOT tested here: that no script runs the
|
|
||||||
// linter on the host. script/lint is the only lint entry point, and it
|
|
||||||
// runs golangci-lint only inside the container; keeping it that way is a
|
|
||||||
// review matter, not something a test in this file establishes.
|
|
||||||
|
|
||||||
// The files under guard, relative to the repository root.
|
|
||||||
const (
|
|
||||||
lintDockerfile = "Dockerfile.lint"
|
|
||||||
productDockerfile = "Dockerfile"
|
|
||||||
lintScript = "script/lint"
|
|
||||||
cibuildScript = "script/cibuild"
|
|
||||||
)
|
|
||||||
|
|
||||||
// linterBinary is the linter's command name, used to locate the
|
|
||||||
// config-verify and lint steps in Dockerfile.lint.
|
|
||||||
const linterBinary = "golangci-lint"
|
|
||||||
|
|
||||||
// checkEpochARG is the declaration, with no default value. A default
|
|
||||||
// would satisfy the non-empty guard with a constant, and a constant is
|
|
||||||
// a stable cache key: the checks would be replayed from cache forever
|
|
||||||
// after the first build.
|
|
||||||
const checkEpochARG = "ARG CHECK_EPOCH"
|
|
||||||
|
|
||||||
// checkEpochGuard is what turns a build that omits --build-arg into a
|
|
||||||
// loud failure instead of a quiet green. Failed steps are never cached,
|
|
||||||
// so it fires on every such invocation rather than once.
|
|
||||||
const checkEpochGuard = `RUN [ -n "$CHECK_EPOCH" ] || exit 1`
|
|
||||||
|
|
||||||
// freshEpoch is the epoch computation the calling scripts must use, as
|
|
||||||
// a bare assignment on its own line. Inline in an argument, a failing
|
|
||||||
// `date` would not abort under `set -eu`; CHECK_EPOCH would become the
|
|
||||||
// empty string, and the guard above would be the only thing standing
|
|
||||||
// between that and a permanently cached green. `$$` is required because
|
|
||||||
// `date +%s` is second-granular and busybox silently drops `%N`, so
|
|
||||||
// without the pid two concurrent runs in one second can collide.
|
|
||||||
const freshEpoch = `epoch="$(date +%s%N)$$"`
|
|
||||||
|
|
||||||
// TestLintDockerfilePinsTheLinterByDigest fails if the lint image stops
|
|
||||||
// being pinned. An unpinned tag makes the gate's verdict depend on
|
|
||||||
// whatever the registry currently serves under that name.
|
|
||||||
func TestLintDockerfilePinsTheLinterByDigest(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
from := ""
|
|
||||||
|
|
||||||
for _, instruction := range instructions(t, lintDockerfile) {
|
|
||||||
if strings.HasPrefix(instruction, "FROM ") {
|
|
||||||
from = instruction
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NotEmpty(t, from, "%s declares no FROM", lintDockerfile)
|
|
||||||
assert.Contains(t, from, "golangci/golangci-lint",
|
|
||||||
"the lint image must be the golangci-lint image")
|
|
||||||
assert.Contains(t, from, "@sha256:",
|
|
||||||
"the lint image must be pinned by digest, not by tag alone")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLintDockerfileCannotBeCachedGreen pins the whole cache-busting
|
|
||||||
// mechanism in the file that lints: the declaration with no default,
|
|
||||||
// the non-empty guard, and the value expanded into the lint command
|
|
||||||
// itself rather than merely declared.
|
|
||||||
func TestLintDockerfileCannotBeCachedGreen(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
found := instructions(t, lintDockerfile)
|
|
||||||
|
|
||||||
argAt := indexOf(found, checkEpochARG)
|
|
||||||
require.GreaterOrEqual(t, argAt, 0,
|
|
||||||
"%s must declare `%s` with no default value",
|
|
||||||
lintDockerfile, checkEpochARG)
|
|
||||||
|
|
||||||
assert.GreaterOrEqual(t, indexOf(found, checkEpochGuard), argAt,
|
|
||||||
"%s must guard against an empty CHECK_EPOCH with `%s`",
|
|
||||||
lintDockerfile, checkEpochGuard)
|
|
||||||
|
|
||||||
assertEpochExpandedInto(t, found[argAt:], "golangci-lint run")
|
|
||||||
|
|
||||||
// Dependency layers must stay above the ARG, or every lint run
|
|
||||||
// re-downloads the module cache and the inner loop becomes
|
|
||||||
// unusable.
|
|
||||||
download := indexOf(found, "RUN go mod download")
|
|
||||||
require.GreaterOrEqual(t, download, 0,
|
|
||||||
"%s must download modules in their own layer", lintDockerfile)
|
|
||||||
assert.Less(t, download, argAt,
|
|
||||||
"`%s` must come after `go mod download` so dependency layers"+
|
|
||||||
" still cache", checkEpochARG)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLintDockerfileVerifiesTheLinterConfig guards the validation of
|
|
||||||
// .golangci.yml itself. `golangci-lint run` rejects a config it cannot
|
|
||||||
// parse but silently IGNORES an unknown top-level key, so renaming
|
|
||||||
// `linters:` to `linterz:` discards `default: all` and every threshold
|
|
||||||
// and still exits 0 reporting no issues. `config verify` is what turns
|
|
||||||
// that into a failure, and it has to run BEFORE the lint, or the lint
|
|
||||||
// spends a minute reporting a verdict from a config already known to be
|
|
||||||
// wrong.
|
|
||||||
func TestLintDockerfileVerifiesTheLinterConfig(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
found := instructions(t, lintDockerfile)
|
|
||||||
verify := linterBinary + " config verify"
|
|
||||||
|
|
||||||
verifyAt := indexContaining(found, verify)
|
|
||||||
require.GreaterOrEqual(t, verifyAt, 0,
|
|
||||||
"%s must run `%s --config .golangci.yml`: without it a typo'd"+
|
|
||||||
" top-level key in .golangci.yml is silently ignored and the"+
|
|
||||||
" gate passes with only the default linter set", lintDockerfile,
|
|
||||||
verify)
|
|
||||||
|
|
||||||
runAt := indexContaining(found, linterBinary+" run")
|
|
||||||
require.GreaterOrEqual(t, runAt, 0, "%s must lint", lintDockerfile)
|
|
||||||
assert.Less(t, verifyAt, runAt,
|
|
||||||
"%s must verify the config before linting with it", lintDockerfile)
|
|
||||||
|
|
||||||
// Keyed on the epoch like every other check layer, so it executes
|
|
||||||
// per invocation rather than being replayed. A cached validation
|
|
||||||
// validates nothing.
|
|
||||||
assertEpochExpandedInto(t, found, verify)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestProductDockerfileCannotBeCachedGreen holds the same line for the
|
|
||||||
// checks that remain in the product image build.
|
|
||||||
func TestProductDockerfileCannotBeCachedGreen(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
found := instructions(t, productDockerfile)
|
|
||||||
|
|
||||||
argAt := indexOf(found, checkEpochARG)
|
|
||||||
require.GreaterOrEqual(t, argAt, 0,
|
|
||||||
"%s must declare `%s` with no default value",
|
|
||||||
productDockerfile, checkEpochARG)
|
|
||||||
|
|
||||||
assert.GreaterOrEqual(t, indexOf(found, checkEpochGuard), argAt,
|
|
||||||
"%s must guard against an empty CHECK_EPOCH", productDockerfile)
|
|
||||||
|
|
||||||
assertEpochExpandedInto(t, found[argAt:], "make fmt-check")
|
|
||||||
assertEpochExpandedInto(t, found[argAt:], "make test")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestProductDockerfileDoesNotLint records the split deliberately: the
|
|
||||||
// linter lives in Dockerfile.lint and nowhere else, so there is exactly
|
|
||||||
// one digest pinning it. A lint stage reintroduced here would either be
|
|
||||||
// docker-in-docker (`make lint` is now `docker build`) or a second,
|
|
||||||
// independently bumpable pin.
|
|
||||||
func TestProductDockerfileDoesNotLint(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
contents := readRepoFile(t, productDockerfile)
|
|
||||||
|
|
||||||
for _, forbidden := range []string{"golangci", "make lint"} {
|
|
||||||
assert.NotContains(t, instructionText(contents), forbidden,
|
|
||||||
"%s must not lint: the linter is pinned once, in %s",
|
|
||||||
productDockerfile, lintDockerfile)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLintScriptBuildsTheLintDockerfileWithAFreshEpoch is the other
|
|
||||||
// half of the mechanism. The Dockerfile's guard only rejects an EMPTY
|
|
||||||
// epoch; a constant non-empty one would satisfy it and still be served
|
|
||||||
// from cache forever.
|
|
||||||
func TestLintScriptBuildsTheLintDockerfileWithAFreshEpoch(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
script := readRepoFile(t, lintScript)
|
|
||||||
|
|
||||||
assertBareEpochAssignment(t, script, lintScript)
|
|
||||||
assert.Contains(t, script, `--build-arg CHECK_EPOCH="$epoch"`,
|
|
||||||
"%s must pass the fresh epoch to the build", lintScript)
|
|
||||||
assert.Contains(t, script, lintDockerfile,
|
|
||||||
"%s must build %s", lintScript, lintDockerfile)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCibuildBuildsBothDockerfilesWithFreshEpochs guards the CI gate:
|
|
||||||
// dropping either build silently removes a whole class of check from
|
|
||||||
// CI while leaving it green.
|
|
||||||
func TestCibuildBuildsBothDockerfilesWithFreshEpochs(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
script := readRepoFile(t, cibuildScript)
|
|
||||||
|
|
||||||
assertBareEpochAssignment(t, script, cibuildScript)
|
|
||||||
assert.Equal(t, 2, strings.Count(script, freshEpoch),
|
|
||||||
"%s must compute a fresh epoch for each of its two builds",
|
|
||||||
cibuildScript)
|
|
||||||
assert.Equal(t, 2,
|
|
||||||
strings.Count(script, `--build-arg CHECK_EPOCH="$epoch"`),
|
|
||||||
"%s must pass a fresh epoch to both builds", cibuildScript)
|
|
||||||
assert.Contains(t, script, "-f Dockerfile.lint",
|
|
||||||
"%s must build %s", cibuildScript, lintDockerfile)
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertEpochExpandedInto fails unless some instruction runs the named
|
|
||||||
// command with the epoch expanded into it. Expansion, not mere
|
|
||||||
// declaration: an ARG that no instruction references is not guaranteed
|
|
||||||
// to key the layer, and the expansion also puts the value in the build
|
|
||||||
// log where a reader can see the layer was keyed fresh.
|
|
||||||
func assertEpochExpandedInto(t *testing.T, found []string, command string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
for _, instruction := range found {
|
|
||||||
if !strings.HasPrefix(instruction, "RUN ") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(instruction, command) &&
|
|
||||||
strings.Contains(instruction, "${CHECK_EPOCH}") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Fail(t, "no epoch-keyed layer runs the command",
|
|
||||||
"`%s` must run in a layer that expands ${CHECK_EPOCH}, or it"+
|
|
||||||
" will be replayed from cache without executing", command)
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertBareEpochAssignment fails unless the script computes the epoch
|
|
||||||
// as a bare assignment on its own line.
|
|
||||||
func assertBareEpochAssignment(t *testing.T, script, name string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
for line := range strings.SplitSeq(script, "\n") {
|
|
||||||
if strings.TrimSpace(line) == freshEpoch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Fail(t, "no bare epoch assignment",
|
|
||||||
"%s must compute `%s` as a bare assignment on its own line, so"+
|
|
||||||
" `set -e` catches a failing date instead of quietly"+
|
|
||||||
" building with an empty epoch", name, freshEpoch)
|
|
||||||
}
|
|
||||||
|
|
||||||
// instructions returns the Dockerfile's instructions, one per element,
|
|
||||||
// with comments and blank lines dropped and continuation lines joined,
|
|
||||||
// so a multi-line RUN is one string.
|
|
||||||
func instructions(t *testing.T, name string) []string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
return strings.Split(instructionText(readRepoFile(t, name)), "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// instructionText is instructions' parse, before splitting: it is also
|
|
||||||
// what a "must not contain" assertion should look at, so that a word
|
|
||||||
// appearing only in a comment is not mistaken for behaviour.
|
|
||||||
func instructionText(contents string) string {
|
|
||||||
var (
|
|
||||||
out []string
|
|
||||||
continued string
|
|
||||||
isContinued bool
|
|
||||||
)
|
|
||||||
|
|
||||||
for line := range strings.SplitSeq(contents, "\n") {
|
|
||||||
trimmed := strings.TrimSpace(line)
|
|
||||||
if !isContinued && (trimmed == "" || strings.HasPrefix(trimmed, "#")) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
isContinued = strings.HasSuffix(trimmed, `\`)
|
|
||||||
continued += strings.TrimSuffix(trimmed, `\`)
|
|
||||||
|
|
||||||
if isContinued {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
out = append(out, strings.Join(strings.Fields(continued), " "))
|
|
||||||
continued = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.Join(out, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// indexOf returns the position of the first instruction equal to, or
|
|
||||||
// beginning with, want; -1 if there is none. An `ARG NAME=default`
|
|
||||||
// counts as beginning with `ARG NAME`, so a declared arg is found
|
|
||||||
// whether or not it carries a default.
|
|
||||||
func indexOf(found []string, want string) int {
|
|
||||||
for i, instruction := range found {
|
|
||||||
if instruction == want ||
|
|
||||||
strings.HasPrefix(instruction, want+" ") ||
|
|
||||||
strings.HasPrefix(instruction, want+"=") {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
// indexContaining returns the position of the first instruction
|
|
||||||
// containing want; -1 if there is none.
|
|
||||||
func indexContaining(found []string, want string) int {
|
|
||||||
for i, instruction := range found {
|
|
||||||
if strings.Contains(instruction, want) {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
// readRepoFile reads a file by its path relative to the repository
|
|
||||||
// root.
|
|
||||||
func readRepoFile(t *testing.T, name string) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
//nolint:gosec // G304: the path is a constant relative to this repo
|
|
||||||
contents, err := os.ReadFile(filepath.Join(repoRoot(t), name))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
return string(contents)
|
|
||||||
}
|
|
||||||
|
|
||||||
// repoRoot returns the repository root. The test binary runs with its
|
|
||||||
// package directory as the working directory, so the root is found by
|
|
||||||
// walking up until the module file appears.
|
|
||||||
func repoRoot(t *testing.T) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
dir, err := os.Getwd()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
for {
|
|
||||||
_, err = os.Stat(filepath.Join(dir, "go.mod"))
|
|
||||||
if err == nil {
|
|
||||||
return dir
|
|
||||||
}
|
|
||||||
|
|
||||||
parent := filepath.Dir(dir)
|
|
||||||
require.NotEqual(t, dir, parent,
|
|
||||||
"walked to the filesystem root without finding a go.mod")
|
|
||||||
|
|
||||||
dir = parent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-11
@@ -10,16 +10,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
os.Exit(run())
|
|
||||||
}
|
|
||||||
|
|
||||||
// run sets up optional profiling, runs the CLI, and returns the process
|
|
||||||
// exit code. os.Exit lives in main so it fires only after run's deferred
|
|
||||||
// profile writers have flushed. cli.Entry returns a status code rather
|
|
||||||
// than calling os.Exit itself: an os.Exit from inside it would skip
|
|
||||||
// these defers and truncate the profile of a failing command -- exactly
|
|
||||||
// the command one most often wants to profile.
|
|
||||||
func run() int {
|
|
||||||
// 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) //nolint:gosec // G304: operator-set path
|
||||||
@@ -56,5 +46,5 @@ func run() int {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
return cli.Entry()
|
cli.Entry()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,151 +0,0 @@
|
|||||||
package main_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"regexp"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// This file guards the Makefile that builds this program, which is why
|
|
||||||
// it lives beside it rather than in a package of its own.
|
|
||||||
//
|
|
||||||
// Issue #110: `build` was listed in .PHONY with no `build:` rule
|
|
||||||
// anywhere in the file. That combination is silently successful — make
|
|
||||||
// considers a phony target with no prerequisites and no recipe already
|
|
||||||
// satisfied, so `rm -f vaultik && make build` printed "Nothing to be
|
|
||||||
// done for 'build'" and exited 0 with no binary produced. Declaring the
|
|
||||||
// name phony is precisely what converts the "No rule to make target"
|
|
||||||
// error into a green.
|
|
||||||
//
|
|
||||||
// The guard is a parse of the Makefile rather than an invocation of
|
|
||||||
// make. `make test` is what runs these tests, so shelling back into
|
|
||||||
// `make build` here would nest a build inside the test run and drop a
|
|
||||||
// binary into the tree as a side effect of testing. The one property a
|
|
||||||
// parse cannot establish — that the recipe still fails when the build
|
|
||||||
// fails — is not testable from inside the build either; it is verified
|
|
||||||
// by hand against a deliberately broken tree.
|
|
||||||
|
|
||||||
// phonyDirective introduces the list of phony target names.
|
|
||||||
const phonyDirective = ".PHONY:"
|
|
||||||
|
|
||||||
// ruleLine matches a rule's target list: a target starts in column
|
|
||||||
// zero, so recipe lines (tab-indented) and the continuation lines of a
|
|
||||||
// variable assignment (space-indented) are excluded by construction.
|
|
||||||
//
|
|
||||||
// The trailing (?:[^=]|$) rejects `:=` assignments such as
|
|
||||||
// `VERSION := $(shell script/version)`, which are not rules. Directives
|
|
||||||
// and function calls (`.PHONY:`, `ifeq`, `$(error ...)`) do not match
|
|
||||||
// because a target here must begin with a letter, digit or underscore.
|
|
||||||
var ruleLine = regexp.MustCompile(`^([A-Za-z0-9_][A-Za-z0-9_./ -]*):(?:[^=]|$)`)
|
|
||||||
|
|
||||||
// TestPhonyTargetsAllHaveRules fails on any name in .PHONY that has no
|
|
||||||
// rule in the Makefile. Such a name is not a build target at all: it is
|
|
||||||
// a command that reports success without doing anything, which is worse
|
|
||||||
// than one that does not exist, because a caller checking the exit code
|
|
||||||
// cannot tell the difference.
|
|
||||||
func TestPhonyTargetsAllHaveRules(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
makefile := readMakefile(t)
|
|
||||||
|
|
||||||
phony := phonyTargets(makefile)
|
|
||||||
require.NotEmpty(t, phony, "no .PHONY names found; the parser is broken")
|
|
||||||
|
|
||||||
rules := declaredRules(makefile)
|
|
||||||
|
|
||||||
// Sanity check on the rule parser before trusting its verdict: a
|
|
||||||
// parser that found nothing would pass this test by accident.
|
|
||||||
require.Contains(t, rules, "vaultik",
|
|
||||||
"the file rule that builds the binary must be recognized")
|
|
||||||
|
|
||||||
for _, target := range phony {
|
|
||||||
assert.Contains(t, rules, target,
|
|
||||||
"`.PHONY` lists %q but the Makefile declares no %q rule, so "+
|
|
||||||
"`make %s` exits 0 without doing anything", target, target, target)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBuildTargetBuildsTheBinary pins the specific shape of issue #110:
|
|
||||||
// `make build` has to reach the rule that produces the binary. The test
|
|
||||||
// above would also pass if `build:` were given an empty recipe of its
|
|
||||||
// own, which would be the same silent success under a different
|
|
||||||
// spelling.
|
|
||||||
func TestBuildTargetBuildsTheBinary(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
prerequisites := rulePrerequisites(readMakefile(t), "build")
|
|
||||||
require.NotNil(t, prerequisites, "the Makefile declares no `build` rule")
|
|
||||||
|
|
||||||
assert.Contains(t, prerequisites, "vaultik",
|
|
||||||
"`make build` must depend on the rule that builds the binary")
|
|
||||||
}
|
|
||||||
|
|
||||||
// readMakefile returns the contents of the repository's Makefile. The
|
|
||||||
// root is located by the shared walk in lintdocker_test.go.
|
|
||||||
func readMakefile(t *testing.T) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
return readRepoFile(t, "Makefile")
|
|
||||||
}
|
|
||||||
|
|
||||||
// phonyTargets returns every name declared phony, across all .PHONY
|
|
||||||
// lines.
|
|
||||||
func phonyTargets(makefile string) []string {
|
|
||||||
var targets []string
|
|
||||||
|
|
||||||
for line := range strings.SplitSeq(makefile, "\n") {
|
|
||||||
if !strings.HasPrefix(line, phonyDirective) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
targets = append(targets,
|
|
||||||
strings.Fields(strings.TrimPrefix(line, phonyDirective))...)
|
|
||||||
}
|
|
||||||
|
|
||||||
return targets
|
|
||||||
}
|
|
||||||
|
|
||||||
// declaredRules returns the set of target names that have a rule.
|
|
||||||
func declaredRules(makefile string) map[string]bool {
|
|
||||||
rules := make(map[string]bool)
|
|
||||||
|
|
||||||
for line := range strings.SplitSeq(makefile, "\n") {
|
|
||||||
match := ruleLine.FindStringSubmatch(line)
|
|
||||||
if match == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// One rule may name several targets: `a b: prereq`.
|
|
||||||
for target := range strings.FieldsSeq(match[1]) {
|
|
||||||
rules[target] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return rules
|
|
||||||
}
|
|
||||||
|
|
||||||
// rulePrerequisites returns the prerequisites of the named rule, or nil
|
|
||||||
// if no such rule exists. A rule with none returns an empty slice, so
|
|
||||||
// "declared with nothing to do" is distinguishable from "not declared".
|
|
||||||
func rulePrerequisites(makefile, target string) []string {
|
|
||||||
for line := range strings.SplitSeq(makefile, "\n") {
|
|
||||||
match := ruleLine.FindStringSubmatch(line)
|
|
||||||
if match == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if !slices.Contains(strings.Fields(match[1]), target) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
_, after, _ := strings.Cut(line, ":")
|
|
||||||
|
|
||||||
return append([]string{}, strings.Fields(after)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
+5
-10
@@ -257,16 +257,16 @@ exclude:
|
|||||||
|
|
||||||
# Storage URL - use either this OR the s3 section below
|
# Storage URL - use either this OR the s3 section below
|
||||||
# Supports: s3://bucket/prefix, file:///path, rclone://remote/path
|
# Supports: s3://bucket/prefix, file:///path, rclone://remote/path
|
||||||
storage_url: "rclone://myremote/path/to/backups"
|
storage_url: "rclone://las1stor1//srv/pool.2024.04/backups/heraklion"
|
||||||
|
|
||||||
# S3-compatible storage configuration
|
# S3-compatible storage configuration
|
||||||
#s3:
|
#s3:
|
||||||
# # S3-compatible endpoint URL
|
# # S3-compatible endpoint URL
|
||||||
# # Examples: https://s3.amazonaws.com, https://storage.googleapis.com
|
# # Examples: https://s3.amazonaws.com, https://storage.googleapis.com
|
||||||
# endpoint: https://s3.example.com
|
# endpoint: http://10.100.205.122:8333
|
||||||
#
|
#
|
||||||
# # Bucket name where backups will be stored
|
# # Bucket name where backups will be stored
|
||||||
# bucket: mybucket
|
# bucket: testbucket
|
||||||
#
|
#
|
||||||
# # Prefix (folder) within the bucket for this host's backups
|
# # Prefix (folder) within the bucket for this host's backups
|
||||||
# # Useful for organizing backups from multiple hosts
|
# # Useful for organizing backups from multiple hosts
|
||||||
@@ -274,8 +274,8 @@ storage_url: "rclone://myremote/path/to/backups"
|
|||||||
# #prefix: "hosts/myserver/"
|
# #prefix: "hosts/myserver/"
|
||||||
#
|
#
|
||||||
# # S3 access credentials
|
# # S3 access credentials
|
||||||
# access_key_id: YOUR_ACCESS_KEY
|
# access_key_id: Z9GT22M9YFU08WRMC5D4
|
||||||
# secret_access_key: YOUR_SECRET_KEY
|
# secret_access_key: Pi0tPKjFbN4rZlRhcA4zBtEkib04yy2WcIzI+AXk
|
||||||
#
|
#
|
||||||
# # S3 region
|
# # S3 region
|
||||||
# # Default: us-east-1
|
# # Default: us-east-1
|
||||||
@@ -304,11 +304,6 @@ storage_url: "rclone://myremote/path/to/backups"
|
|||||||
|
|
||||||
# Maximum blob size
|
# Maximum blob size
|
||||||
# Multiple chunks are packed into blobs up to this size
|
# Multiple chunks are packed into blobs up to this size
|
||||||
# Must be at least four times chunk_size (the largest chunk the chunker can
|
|
||||||
# emit); a smaller limit would let a single-chunk blob exceed it.
|
|
||||||
# Chunking uses no secret (the FastCDC parameters are fixed and public). At a
|
|
||||||
# large limit a blob holds hundreds of chunks, so individual chunk lengths are
|
|
||||||
# not visible in its size; lowering the limit toward chunk_size exposes them.
|
|
||||||
# Supports: 1GB, 10G, 500MB, 1GiB, etc.
|
# Supports: 1GB, 10G, 500MB, 1GiB, etc.
|
||||||
# Default: 10GB
|
# Default: 10GB
|
||||||
#blob_size_limit: 10GB
|
#blob_size_limit: 10GB
|
||||||
|
|||||||
+9
-30
@@ -5,30 +5,11 @@
|
|||||||
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
|
||||||
This section is the authoritative explanation of the schema/migration story;
|
migrations. The local index is treated as disposable — if the schema changes,
|
||||||
other documents (the README and `AGENTS.md`) link here.
|
delete the local SQLite database (`vaultik database delete`) and run a full
|
||||||
|
backup. The remote storage is unaffected; the new index will re-deduplicate
|
||||||
- **No upgrade path between versions (pre-1.0)**: Vaultik has no supported way to
|
against existing remote blobs.
|
||||||
carry an existing local index across a schema change. The index is disposable
|
|
||||||
— if the on-disk schema changes between versions, delete the local SQLite
|
|
||||||
database (`vaultik database delete`) and run a full backup. Remote storage is
|
|
||||||
unaffected; the new index re-deduplicates against existing remote blobs. This
|
|
||||||
is the standing project policy, and it is separate from the schema bootstrap
|
|
||||||
described next.
|
|
||||||
- **Schema bootstrap**: a fresh database is populated from numbered SQL files
|
|
||||||
embedded in the binary under `internal/database/schema/`. `000.sql` creates the
|
|
||||||
`schema_migrations` table; `001.sql` creates the application tables. On opening
|
|
||||||
a database the code applies each numbered file that has not yet run and records
|
|
||||||
its version in `schema_migrations`. This bootstraps a new database; it does not
|
|
||||||
upgrade an existing one between released versions.
|
|
||||||
- **Changing the schema (pre-1.0)**: edit `internal/database/schema/001.sql` (and
|
|
||||||
the code that touches the affected tables) directly. Do not add new numbered
|
|
||||||
files — there is no installed base to migrate.
|
|
||||||
- **Disposability expires at 1.0**: the index is treated as disposable only until
|
|
||||||
1.0 ships and is tagged. Once 1.0 is tagged that clause expires and the
|
|
||||||
question of upgrading existing indexes returns. It is deliberately left open
|
|
||||||
here.
|
|
||||||
- **Version Compatibility**: In rare cases, you may need to use the same version
|
- **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
|
of Vaultik to restore a backup as was used to create it. This ensures
|
||||||
compatibility with the metadata format stored in S3.
|
compatibility with the metadata format stored in S3.
|
||||||
@@ -90,7 +71,7 @@ Stores information about packed, compressed, and encrypted blob files.
|
|||||||
|
|
||||||
**Columns:**
|
**Columns:**
|
||||||
- `id` (TEXT PRIMARY KEY) - UUID assigned when blob creation starts
|
- `id` (TEXT PRIMARY KEY) - UUID assigned when blob creation starts
|
||||||
- `blob_hash` (TEXT UNIQUE) - `hex(SHA256(SHA256(uncompressed blob contents)))`, computed before compression and encryption (NULL until finalized); see [REPOSTRUCTURE.md](REPOSTRUCTURE.md#blobs-directory-blobs)
|
- `blob_hash` (TEXT UNIQUE) - SHA256 hash of final blob (NULL until finalized)
|
||||||
- `created_ts` (INTEGER NOT NULL) - Creation timestamp
|
- `created_ts` (INTEGER NOT NULL) - Creation timestamp
|
||||||
- `finished_ts` (INTEGER) - Finalization timestamp (NULL if in progress)
|
- `finished_ts` (INTEGER) - Finalization timestamp (NULL if in progress)
|
||||||
- `uncompressed_size` (INTEGER NOT NULL DEFAULT 0) - Total size of chunks before compression
|
- `uncompressed_size` (INTEGER NOT NULL DEFAULT 0) - Total size of chunks before compression
|
||||||
@@ -211,12 +192,10 @@ Tracks blob upload metrics.
|
|||||||
After a snapshot is completed:
|
After a snapshot is completed:
|
||||||
1. Copy database to temporary file
|
1. Copy database to temporary file
|
||||||
2. Clean temporary database to contain only current snapshot data
|
2. Clean temporary database to contain only current snapshot data
|
||||||
3. VACUUM the trimmed database so deleted rows leave no pages behind
|
3. Export to SQL dump using sqlite3
|
||||||
4. Compress with zstd and encrypt with age
|
4. Compress with zstd and encrypt with age
|
||||||
5. Upload to S3 as `metadata/{remote-key}/db.zst.age`
|
5. Upload to S3 as `metadata/{snapshot-id}/db.zst.age`
|
||||||
6. Generate blob manifest and upload as `metadata/{remote-key}/manifest.json.zst`
|
6. Generate blob manifest and upload as `metadata/{snapshot-id}/manifest.json.zst`
|
||||||
|
|
||||||
The `{remote-key}` directory name is a one-way hash of the human snapshot ID, so the ID is never written to the store in plaintext. The hash uses no secret, so a guessed hostname and snapshot name can still be confirmed against a listing; see [REPOSTRUCTURE.md](REPOSTRUCTURE.md#remote-key-derivation) and its [Accepted Risks](REPOSTRUCTURE.md#accepted-risks).
|
|
||||||
|
|
||||||
### 4. Restore Process
|
### 4. Restore Process
|
||||||
|
|
||||||
|
|||||||
+22
-55
@@ -17,13 +17,11 @@ Vaultik stores all backup data in an S3-compatible object store. The repository
|
|||||||
│ └── <hash[2:4]>/
|
│ └── <hash[2:4]>/
|
||||||
│ └── <full-hash>
|
│ └── <full-hash>
|
||||||
└── metadata/
|
└── metadata/
|
||||||
└── <remote-key>/
|
└── <snapshot-id>/
|
||||||
├── db.zst.age
|
├── db.zst.age
|
||||||
└── manifest.json.zst
|
└── manifest.json.zst
|
||||||
```
|
```
|
||||||
|
|
||||||
The metadata subdirectory is named with the **remote key**, a one-way hash of the snapshot ID, not with the human-readable snapshot ID itself. See [Remote Key Derivation](#remote-key-derivation).
|
|
||||||
|
|
||||||
## Blobs Directory (`blobs/`)
|
## Blobs Directory (`blobs/`)
|
||||||
|
|
||||||
### Structure
|
### Structure
|
||||||
@@ -35,18 +33,16 @@ The metadata subdirectory is named with the **remote key**, a one-way hash of th
|
|||||||
- **What it contains**: Packed collections of content-defined chunks from files
|
- **What it contains**: Packed collections of content-defined chunks from files
|
||||||
- **Format**: Zstandard compressed, then Age encrypted
|
- **Format**: Zstandard compressed, then Age encrypted
|
||||||
- **Encryption**: Always encrypted with Age using the configured recipients
|
- **Encryption**: Always encrypted with Age using the configured recipients
|
||||||
- **Naming**: Content-addressed. The blob's name is `hex(SHA256(SHA256(uncompressed blob contents)))` — the double SHA-256 of the concatenated chunk data, computed before compression and encryption, not a hash of the stored (compressed, encrypted) bytes. One consequence: only a holder of the age private key can check a stored blob's integrity, because matching a blob to its name means decrypting and decompressing it first — which is what `restore` and `verify --deep` do. Implemented in `internal/blobgen` (`DoubleSHA256`). This is the canonical description of blob naming; other documents and comments point here.
|
- **Naming**: Content-addressed using SHA256 hash of the encrypted blob
|
||||||
|
|
||||||
### Why Encrypted
|
### Why Encrypted
|
||||||
Blobs contain the actual file data from backups and must be encrypted for security. The content-addressing ensures deduplication while the encryption ensures privacy.
|
Blobs contain the actual file data from backups and must be encrypted for security. The content-addressing ensures deduplication while the encryption ensures privacy.
|
||||||
|
|
||||||
## Metadata Directory (`metadata/`)
|
## Metadata Directory (`metadata/`)
|
||||||
|
|
||||||
Each snapshot has its own subdirectory. The directory is **not** named with the human-readable snapshot ID; it is named with the remote key — a one-way hash of that ID. The human ID is never written to the destination store as a directory name (see [Remote Key Derivation](#remote-key-derivation)).
|
Each snapshot has its own subdirectory named with the snapshot ID.
|
||||||
|
|
||||||
### Snapshot ID Format
|
### Snapshot ID Format
|
||||||
|
|
||||||
The human-readable snapshot ID is used in CLI arguments, log lines, and the local database. It is not written to the destination store.
|
|
||||||
- **Format**: `<hostname>_<snapshot-name>_<RFC3339>` (or `<hostname>_<RFC3339>` if no
|
- **Format**: `<hostname>_<snapshot-name>_<RFC3339>` (or `<hostname>_<RFC3339>` if no
|
||||||
name was specified)
|
name was specified)
|
||||||
- **Example**: `laptop_home_2024-01-15T14:30:52Z`
|
- **Example**: `laptop_home_2024-01-15T14:30:52Z`
|
||||||
@@ -55,19 +51,6 @@ The human-readable snapshot ID is used in CLI arguments, log lines, and the loca
|
|||||||
- Snapshot name from the configured `snapshots:` map (optional)
|
- Snapshot name from the configured `snapshots:` map (optional)
|
||||||
- RFC3339 UTC timestamp
|
- RFC3339 UTC timestamp
|
||||||
|
|
||||||
This ID reveals the hostname, the configured snapshot name, and the backup time, so it is never used as the on-disk directory name — the remote key is used instead.
|
|
||||||
|
|
||||||
### Remote Key Derivation
|
|
||||||
|
|
||||||
The remote key is `hex(SHA256(SHA256("vaultik|" + snapshot-id)))`: a double SHA-256 over the snapshot ID, with a `vaultik|` domain-separation prefix. The result is a 64-character hex string. The hash is not reversible, but it uses no secret: an observer who guesses a candidate hostname and snapshot name can hash it the same way and confirm whether that snapshot is present. The remote key keeps names out of a plain listing; it does not hide them from a guess. Implemented in `internal/snapshot/remotekey.go`.
|
|
||||||
|
|
||||||
Worked example:
|
|
||||||
- Snapshot ID: `server1_home_2025-06-01T12:00:00Z`
|
|
||||||
- Remote key: `17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa`
|
|
||||||
- Directory: `metadata/17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa/`
|
|
||||||
|
|
||||||
A plain listing of the destination store therefore shows only these hashes, not the hostname or snapshot name of any backup — but because the hash uses no secret, a guessed hostname and snapshot name can be hashed and confirmed against the listing. The same remote key is stored in the manifest's `snapshot_id` field.
|
|
||||||
|
|
||||||
### Files in Each Snapshot Directory
|
### Files in Each Snapshot Directory
|
||||||
|
|
||||||
#### `db.zst.age` - Encrypted Database
|
#### `db.zst.age` - Encrypted Database
|
||||||
@@ -85,17 +68,16 @@ A plain listing of the destination store therefore shows only these hashes, not
|
|||||||
- **Structure**:
|
- **Structure**:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"snapshot_id": "17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa",
|
"snapshot_id": "laptop_home_2024-01-15T14:30:52Z",
|
||||||
"timestamp": "2025-06-01T12:00:00Z",
|
"timestamp": "2024-01-15T14:30:52Z",
|
||||||
"blob_count": 42,
|
"blob_count": 42,
|
||||||
"total_compressed_size": 1048576,
|
|
||||||
"blobs": [
|
"blobs": [
|
||||||
{ "hash": "cafebabe1234567890abcdef1234567890abcdef1234567890abcdef12345678", "compressed_size": 24576 },
|
"cafebabe1234567890abcdef1234567890abcdef1234567890abcdef12345678",
|
||||||
{ "hash": "deadbeef1234567890abcdef1234567890abcdef1234567890abcdef12345678", "compressed_size": 32768 }
|
"deadbeef1234567890abcdef1234567890abcdef1234567890abcdef12345678",
|
||||||
|
...
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
`snapshot_id` is the remote key (a hash), not the human ID; `timestamp` is written in the clear.
|
|
||||||
|
|
||||||
### Why Manifest is Unencrypted
|
### Why Manifest is Unencrypted
|
||||||
The manifest must be readable without the private key to enable:
|
The manifest must be readable without the private key to enable:
|
||||||
@@ -104,7 +86,7 @@ The manifest must be readable without the private key to enable:
|
|||||||
3. **Verification** - Checking blob existence without decryption
|
3. **Verification** - Checking blob existence without decryption
|
||||||
4. **Cross-snapshot deduplication analysis** - Finding shared blobs between snapshots
|
4. **Cross-snapshot deduplication analysis** - Finding shared blobs between snapshots
|
||||||
|
|
||||||
The manifest contains the remote key, the backup timestamp, the blob count and total compressed size, and each blob's hash and compressed size. It contains no file names, paths, or other decrypted metadata.
|
The manifest only contains blob hashes, not file names or any other sensitive information.
|
||||||
|
|
||||||
## Security Considerations
|
## Security Considerations
|
||||||
|
|
||||||
@@ -114,54 +96,39 @@ The manifest contains the remote key, the backup timestamp, the blob count and t
|
|||||||
- **File-to-chunk mappings** (in db.zst.age)
|
- **File-to-chunk mappings** (in db.zst.age)
|
||||||
|
|
||||||
### What's Not Encrypted
|
### What's Not Encrypted
|
||||||
- **The remote key** — directory names and the manifest `snapshot_id`, a one-way hash of the snapshot ID (see [Remote Key Derivation](#remote-key-derivation))
|
- **Blob hashes** (in manifest.json.zst)
|
||||||
- **The backup timestamp** (in manifest.json.zst)
|
- **Snapshot IDs** (directory names)
|
||||||
- **Blob hashes and their compressed sizes** (in manifest.json.zst)
|
- **Blob count per snapshot** (in manifest.json.zst)
|
||||||
- **Blob count and total compressed size per snapshot** (in manifest.json.zst)
|
|
||||||
|
|
||||||
### Privacy Implications
|
### Privacy Implications
|
||||||
From the unencrypted data, an observer of the destination store can determine:
|
From the unencrypted data, an observer can determine:
|
||||||
- **When each backup was taken** — not from the directory name, which is a one-way hash, but from the plaintext `timestamp` field in manifest.json.zst, which is published in the clear
|
- When backups were taken (from snapshot IDs)
|
||||||
- How many blobs each snapshot references, and the total compressed size
|
- Which hostname created backups (from snapshot IDs)
|
||||||
- The compressed size of each blob, and which blobs are shared between snapshots (deduplication patterns)
|
- How many blobs each snapshot references
|
||||||
- **Whether a guessed hostname and snapshot name are present** — the remote key is an unkeyed hash, so an observer holding candidate names can hash each one and match it against the directory listing. The human ID is never published, so it cannot be read off directly, but it can be confirmed by guessing.
|
- Which blobs are shared between snapshots (deduplication patterns)
|
||||||
|
- The size of each encrypted blob
|
||||||
Together these give an observer a timing-and-size profile of every snapshot. This is an accepted, documented property of the format, not a defect: the manifest is unencrypted so that pruning can run without the private key, and the timing channel could not be closed by encrypting it anyway — object creation times and per-object sizes stay visible at the storage layer on both `s3://` and `file://` destinations regardless.
|
|
||||||
|
|
||||||
An observer cannot determine:
|
An observer cannot determine:
|
||||||
- The hostname or snapshot name of any backup by reading it off the store — the directory name and the manifest `snapshot_id` are unkeyed hashes of the human ID, so the text is never published (though a guessed name can be confirmed, as above)
|
|
||||||
- File names or paths
|
- File names or paths
|
||||||
- File contents
|
- File contents
|
||||||
- File permissions or ownership
|
- File permissions or ownership
|
||||||
- Directory structure
|
- Directory structure
|
||||||
- Which chunks belong to which files
|
- Which chunks belong to which files
|
||||||
|
|
||||||
### Accepted Risks
|
|
||||||
|
|
||||||
These are known, deliberate properties of the format and the tooling, recorded so an operator can weigh them rather than discover them.
|
|
||||||
|
|
||||||
1. **No proof of authorship.** Restore and `verify --deep` prove that data decrypts with the age private key and matches its unkeyed content hashes. They do not prove who wrote it: anyone who knows a recipient public key and can replace objects on the destination can substitute a snapshot they built. The recipient string is not stored at the destination, but a compromised backed-up host has it. Defences live on the destination side — bucket versioning or object lock, credentials for the source host that cannot delete or overwrite existing versions, and pruning from a trusted host. Note that S3 `PutObject` overwrites an existing key, so PUT permission alone is not append-only.
|
|
||||||
2. **Compression reveals sizes.** Blobs and `db.zst.age` are zstd-compressed then age-encrypted; the manifest is compressed only. age does not pad, so an object's size is the exact compressed length of its contents. All new chunks packed into one blob share a single zstd stream (8 MiB window, 4 MiB at compression levels 1-2), and a blob is closed at `blob_size_limit` and at the end of each configured path. Because a stored chunk is never packed again, someone who can write into a backed-up file and watch blob sizes learns something only when their controlled data and a secret land in the same chunk of a file that keeps changing. Advice: back up any outsider-writable directory as its own snapshot.
|
|
||||||
3. **Chunking uses no secret.** The FastCDC parameters are fixed and public. The default 10 MB average yields chunks between 2.5 MB and 40 MB, and any file of 2.5 MB or less is a single chunk. At the default 10 GB `blob_size_limit` a blob holds hundreds of chunks, so individual chunk lengths are not visible in the blob's size; lowering the limit toward the chunk size begins to expose them.
|
|
||||||
4. **Decrypted data on local disk.** Several commands stage plaintext under `$TMPDIR`: `snapshot restore` writes decrypted blobs under `vaultik-blobcache-*/` (no size cap) and the decrypted metadata database at `vaultik-restore-*/snapshot.db`; `verify --deep` writes that database at `vaultik-verify-*/snapshot.db`; `snapshot create` keeps a plaintext copy of the index at `vaultik-snapshot-*/snapshot.db`. These files are created `0600` and removed on success, but a `kill -9` or a power loss leaves them behind — delete any leftover `vaultik-*` directory under `$TMPDIR` by hand. `$TMPDIR` should be trusted to the same degree as the restore target.
|
|
||||||
5. **Store permissions differ per command.** The backed-up host needs only PUT to run `snapshot create`: it writes blobs and metadata and neither reads nor deletes them. Other commands need more — `snapshot verify`, `snapshot restore`, and `prune` list and read; `prune`, `snapshot purge`, `snapshot remove`, and `remote nuke` also delete. The recommended cron line uses `--prune`, which runs `prune` on the backed-up host, so granting that host `--prune` gives it credentials that can delete its own backups. To keep the source host to PUT only, prune from a separate trusted host instead.
|
|
||||||
6. **Changing recipients does not re-encrypt existing data.** Deduplicated chunks and same-named blobs already on the destination stay encrypted to the recipients in force when they were written. A new snapshot that reuses them cannot be restored with a newly added recipient's key alone, because those reused objects were never encrypted to it. To make everything readable by a new key, run `vaultik database delete` and take a full backup to a fresh destination or prefix.
|
|
||||||
7. **X25519 recipients only.** vaultik rejects age ssh and plugin recipients. Long-lived ciphertext held by a third party (the destination operator) has no fallback if X25519 is ever broken: there is no second recipient type and no post-quantum option.
|
|
||||||
|
|
||||||
## Consistency Guarantees
|
## Consistency Guarantees
|
||||||
|
|
||||||
1. **Blobs are immutable** - Once written, a blob is never modified
|
1. **Blobs are immutable** - Once written, a blob is never modified
|
||||||
2. **Blobs are written before metadata** - A snapshot's metadata is only written after all its blobs are successfully uploaded
|
2. **Blobs are written before metadata** - A snapshot's metadata is only written after all its blobs are successfully uploaded
|
||||||
3. **Metadata is written atomically** - Both db.zst.age and manifest.json.zst are written as complete files
|
3. **Metadata is written atomically** - Both db.zst.age and manifest.json.zst are written as complete files
|
||||||
4. **A snapshot is marked complete in the local DB only after its metadata is uploaded** - `finalizeSnapshotMetadata` runs `ExportSnapshotMetadata` first and records completion (`MarkSnapshotComplete`) only once the export succeeds (see the backup data flow in [ARCHITECTURE.md](../ARCHITECTURE.md)). A crash during the export therefore leaves the snapshot incomplete, so the next backup's `PruneDatabase` drops it and re-backs-up its data, rather than leaving a completed-looking row in the local index with no matching metadata on the destination store. (A crash in the brief moment after the export succeeds but before completion is recorded leaves a fully-restorable snapshot on the destination that the local index drops as incomplete on the next run; `snapshot list` then reports it honestly as remote-only, which is the safe direction: the destination copy stays restorable.)
|
4. **Snapshots are marked complete in local DB only after metadata upload** - Ensures consistency between local and remote state
|
||||||
|
|
||||||
## Pruning Safety
|
## Pruning Safety
|
||||||
|
|
||||||
The prune operation is safe because:
|
The prune operation is safe because:
|
||||||
1. It keeps every blob listed in any snapshot's manifest and deletes only blobs that no manifest references
|
1. It only deletes blobs not referenced in any manifest
|
||||||
2. Manifests are unencrypted and can be read without keys
|
2. Manifests are unencrypted and can be read without keys
|
||||||
3. If any manifest cannot be downloaded or decoded, prune deletes nothing and exits with an error, rather than treating that snapshot's blobs as unreferenced
|
3. The operation compares the latest local DB snapshot with the latest S3 snapshot to ensure consistency
|
||||||
4. Prune requires exclusive access to the destination: running it during a concurrent backup can race a snapshot whose manifest is not yet written, so do not prune while a backup is in progress
|
4. Pruning will fail if these don't match, preventing accidental deletion of needed blobs
|
||||||
|
|
||||||
## Restoration Requirements
|
## Restoration Requirements
|
||||||
|
|
||||||
|
|||||||
+8
-10
@@ -1,16 +1,14 @@
|
|||||||
// Package blob handles the creation of blobs - the final storage units for Vaultik.
|
// Package blob handles the creation of blobs - the final storage units for Vaultik.
|
||||||
// A blob is a large file (up to 10GB) containing many compressed and encrypted chunks
|
// A blob is a large file (up to 10GB) containing many compressed and encrypted chunks
|
||||||
// from multiple source files. Blobs are content-addressed: a blob's filename is
|
// from multiple source files. Blobs are content-addressed, meaning their filename
|
||||||
// hex(SHA256(SHA256(uncompressed blob contents))), computed from the concatenated
|
// is derived from the SHA256 hash of their compressed and encrypted content.
|
||||||
// chunk data before compression and encryption, not from the stored bytes. See
|
|
||||||
// blobgen.DoubleSHA256 and docs/REPOSTRUCTURE.md.
|
|
||||||
//
|
//
|
||||||
// The blob creation process:
|
// The blob creation process:
|
||||||
// 1. Chunks are accumulated from multiple files
|
// 1. Chunks are accumulated from multiple files
|
||||||
// 2. Each chunk's uncompressed bytes are fed to a running SHA-256 and, in the same
|
// 2. The collection is compressed using zstd
|
||||||
// pass, compressed with zstd and encrypted with age into the temp file
|
// 3. The compressed data is encrypted using age
|
||||||
// 3. On finalize, the name is the double SHA-256 of that uncompressed content
|
// 4. The encrypted blob is hashed to create its content-addressed name
|
||||||
// 4. The blob is uploaded to S3 using the name as the filename
|
// 5. The blob is uploaded to S3 using the hash as the filename
|
||||||
//
|
//
|
||||||
// This design optimizes storage efficiency by batching many small chunks into
|
// This design optimizes storage efficiency by batching many small chunks into
|
||||||
// larger blobs, reducing the number of S3 operations and associated costs.
|
// larger blobs, reducing the number of S3 operations and associated costs.
|
||||||
@@ -489,7 +487,7 @@ func (p *Packer) closeBlobWriter() (string, int64, error) {
|
|||||||
return "", 0, fmt.Errorf("seeking to start: %w", err)
|
return "", 0, fmt.Errorf("seeking to start: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
finalHash := p.currentBlob.writer.ContentID()
|
finalHash := p.currentBlob.writer.Sum256()
|
||||||
|
|
||||||
return hex.EncodeToString(finalHash), finalSize, nil
|
return hex.EncodeToString(finalHash), finalSize, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// Package blobgen implements the blob data pipeline: streaming zstd
|
||||||
|
// compression, age encryption, and SHA256 content hashing for blob
|
||||||
|
// creation, plus the matching decrypt/decompress/verify reader.
|
||||||
|
package blobgen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CompressResult contains the results of compression
|
||||||
|
type CompressResult struct {
|
||||||
|
Data []byte
|
||||||
|
UncompressedSize int64
|
||||||
|
CompressedSize int64
|
||||||
|
SHA256 string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompressData compresses and encrypts data, returning the result with hash
|
||||||
|
func CompressData(
|
||||||
|
data []byte, compressionLevel int, recipients []string,
|
||||||
|
) (*CompressResult, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
// Create writer
|
||||||
|
w, err := NewWriter(&buf, compressionLevel, recipients)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("creating writer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write data
|
||||||
|
_, err = w.Write(data)
|
||||||
|
if err != nil {
|
||||||
|
_ = w.Close()
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("writing data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close to flush
|
||||||
|
err = w.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("closing writer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CompressResult{
|
||||||
|
Data: buf.Bytes(),
|
||||||
|
UncompressedSize: int64(len(data)),
|
||||||
|
CompressedSize: int64(buf.Len()),
|
||||||
|
SHA256: hex.EncodeToString(w.Sum256()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompressStream compresses and encrypts from reader to writer, returning
|
||||||
|
// the number of uncompressed bytes written and the content hash.
|
||||||
|
func CompressStream(
|
||||||
|
dst io.Writer, src io.Reader, compressionLevel int, recipients []string,
|
||||||
|
) (int64, string, error) {
|
||||||
|
// Create writer
|
||||||
|
w, err := NewWriter(dst, compressionLevel, recipients)
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", fmt.Errorf("creating writer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
closed := false
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if !closed {
|
||||||
|
_ = w.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Copy data
|
||||||
|
_, err = io.Copy(w, src)
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", fmt.Errorf("copying data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close to flush
|
||||||
|
err = w.Close()
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", fmt.Errorf("closing writer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
closed = true
|
||||||
|
|
||||||
|
return w.BytesWritten(), hex.EncodeToString(w.Sum256()), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package blobgen_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testRecipient is a static age recipient for tests.
|
||||||
|
const testRecipient = "age1cplgrwj77ta54dnmydvvmzn64ltk83ankxl5sww04mrtmu62kv3s89gmvv"
|
||||||
|
|
||||||
|
// TestCompressStreamNoDoubleClose is a regression test for issue #28.
|
||||||
|
// It verifies that CompressStream does not panic or return an error due to
|
||||||
|
// double-closing the underlying blobgen.Writer. Before the fix in PR #33,
|
||||||
|
// the explicit Close() on the happy path combined with defer Close() would
|
||||||
|
// cause a double close.
|
||||||
|
func TestCompressStreamNoDoubleClose(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
input := []byte("regression test data for issue #28 double-close fix")
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
written, hash, err := blobgen.CompressStream(
|
||||||
|
&buf, bytes.NewReader(input), 3, []string{testRecipient})
|
||||||
|
require.NoError(t, err, "CompressStream should not return an error")
|
||||||
|
assert.Positive(t, written, "expected bytes written > 0")
|
||||||
|
assert.NotEmpty(t, hash, "expected non-empty hash")
|
||||||
|
assert.Positive(t, buf.Len(), "expected non-empty output")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCompressStreamLargeInput exercises CompressStream with a larger payload
|
||||||
|
// to ensure no double-close issues surface under heavier I/O.
|
||||||
|
func TestCompressStreamLargeInput(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
data := make([]byte, 512*1024) // 512 KB
|
||||||
|
_, err := rand.Read(data)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
written, hash, err := blobgen.CompressStream(
|
||||||
|
&buf, bytes.NewReader(data), 3, []string{testRecipient})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Positive(t, written)
|
||||||
|
assert.NotEmpty(t, hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCompressStreamEmptyInput verifies CompressStream handles empty input
|
||||||
|
// without double-close issues.
|
||||||
|
func TestCompressStreamEmptyInput(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
_, hash, err := blobgen.CompressStream(
|
||||||
|
&buf, strings.NewReader(""), 3, []string{testRecipient})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCompressDataNoDoubleClose mirrors the stream test for CompressData,
|
||||||
|
// ensuring the explicit Close + error-path Close pattern is also safe.
|
||||||
|
func TestCompressDataNoDoubleClose(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
input := []byte("CompressData regression test for double-close")
|
||||||
|
|
||||||
|
result, err := blobgen.CompressData(input, 3, []string{testRecipient})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Positive(t, result.CompressedSize)
|
||||||
|
assert.Equal(t, result.UncompressedSize, int64(len(input)))
|
||||||
|
assert.NotEmpty(t, result.SHA256)
|
||||||
|
}
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
package blobgen_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"crypto/rand"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"filippo.io/age"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ageChunkSize is age's STREAM plaintext chunk size (64 KiB); each encrypted
|
|
||||||
// chunk adds a 16-byte ChaCha20-Poly1305 tag.
|
|
||||||
const (
|
|
||||||
ageChunkSize = 64 * 1024
|
|
||||||
ageChunkTagSize = 16
|
|
||||||
ageSegmentSize = ageChunkSize + ageChunkTagSize
|
|
||||||
ageNonceSize = 16
|
|
||||||
)
|
|
||||||
|
|
||||||
// makeIdentity returns a fresh X25519 identity and its recipient string.
|
|
||||||
func makeIdentity(t *testing.T) (*age.X25519Identity, string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
id, err := age.GenerateX25519Identity()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
return id, id.Recipient().String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// randomBytes returns n cryptographically random bytes, which do not compress
|
|
||||||
// so the encrypted payload spans multiple age segments.
|
|
||||||
func randomBytes(t *testing.T, n int) []byte {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
b := make([]byte, n)
|
|
||||||
_, err := rand.Read(b)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
// compressibleBytes returns n bytes of a repeating pattern, which zstd packs
|
|
||||||
// down to a small payload.
|
|
||||||
func compressibleBytes(n int) []byte {
|
|
||||||
pattern := bytes.Repeat([]byte("compressible-"), n/13+1)
|
|
||||||
|
|
||||||
return pattern[:n]
|
|
||||||
}
|
|
||||||
|
|
||||||
// encryptBlob compresses, encrypts and returns a blob for plaintext at
|
|
||||||
// compression level 1.
|
|
||||||
func encryptBlob(t *testing.T, plaintext []byte, recipients ...string) []byte {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
|
|
||||||
w, err := blobgen.NewWriter(&buf, 1, recipients)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
_, err = w.Write(plaintext)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, w.Close())
|
|
||||||
|
|
||||||
return buf.Bytes()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ageHeaderLen returns the byte length of blob's age header, i.e. the offset
|
|
||||||
// of the 16-byte payload nonce that follows it. The header ends with a MAC
|
|
||||||
// line "--- <mac>\n"; the nonce begins right after that newline.
|
|
||||||
func ageHeaderLen(t *testing.T, blob []byte) int {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
i := bytes.Index(blob, []byte("\n--- "))
|
|
||||||
require.GreaterOrEqual(t, i, 0, "age MAC footer line not found")
|
|
||||||
|
|
||||||
nl := bytes.IndexByte(blob[i+1:], '\n')
|
|
||||||
require.GreaterOrEqual(t, nl, 0, "newline ending MAC line not found")
|
|
||||||
|
|
||||||
return i + 1 + nl + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// requireBlobUnreadable asserts that data never decrypts to a plaintext with a
|
|
||||||
// nil error: either NewReader fails, or reading it does.
|
|
||||||
func requireBlobUnreadable(t *testing.T, data []byte, id age.Identity) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
r, err := blobgen.NewReader(bytes.NewReader(data), id)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = io.ReadAll(r)
|
|
||||||
_ = r.Close()
|
|
||||||
|
|
||||||
require.Error(t, err, "reading a damaged blob must fail")
|
|
||||||
}
|
|
||||||
|
|
||||||
// errFailWriter is returned by failAfterWriter once its byte limit is passed.
|
|
||||||
var errFailWriter = errors.New("destination write failed")
|
|
||||||
|
|
||||||
// failAfterWriter accepts writes until more than limit bytes have been sent,
|
|
||||||
// then fails every write. It models a destination that dies mid-blob.
|
|
||||||
type failAfterWriter struct {
|
|
||||||
limit int
|
|
||||||
written int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *failAfterWriter) Write(p []byte) (int, error) {
|
|
||||||
f.written += len(p)
|
|
||||||
if f.written > f.limit {
|
|
||||||
return 0, errFailWriter
|
|
||||||
}
|
|
||||||
|
|
||||||
return len(p), nil
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
package blobgen
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ErrOutputTooLarge is returned by a reader from LimitReader once it has
|
|
||||||
// been asked for more than its limit. It bounds how far an untrusted
|
|
||||||
// compressed stream may expand, so a small, highly compressible object
|
|
||||||
// from the store cannot decompress without limit.
|
|
||||||
var ErrOutputTooLarge = errors.New("output exceeds size limit")
|
|
||||||
|
|
||||||
// LimitReader returns a reader that yields at most limit bytes from r and
|
|
||||||
// then fails with ErrOutputTooLarge. Unlike io.LimitReader, which reports
|
|
||||||
// a silent io.EOF at the limit (indistinguishable from a stream that
|
|
||||||
// simply ended), this fails, so a caller decoding or copying the stream
|
|
||||||
// sees an error rather than a truncated value. A stream of exactly limit
|
|
||||||
// bytes reads back cleanly to EOF; the first byte beyond it is the error.
|
|
||||||
func LimitReader(r io.Reader, limit int64) io.Reader {
|
|
||||||
// remaining counts down from limit+1: the extra byte is the one that,
|
|
||||||
// if it ever arrives, proves the stream is longer than the limit.
|
|
||||||
return &limitReader{r: r, remaining: limit + 1}
|
|
||||||
}
|
|
||||||
|
|
||||||
type limitReader struct {
|
|
||||||
r io.Reader
|
|
||||||
remaining int64
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *limitReader) Read(p []byte) (int, error) {
|
|
||||||
if l.remaining <= 0 {
|
|
||||||
return 0, ErrOutputTooLarge
|
|
||||||
}
|
|
||||||
|
|
||||||
if int64(len(p)) > l.remaining {
|
|
||||||
p = p[:l.remaining]
|
|
||||||
}
|
|
||||||
|
|
||||||
n, err := l.r.Read(p)
|
|
||||||
l.remaining -= int64(n)
|
|
||||||
|
|
||||||
if l.remaining <= 0 {
|
|
||||||
// The (limit+1)th byte was just read: the stream is too long.
|
|
||||||
return n, ErrOutputTooLarge
|
|
||||||
}
|
|
||||||
|
|
||||||
return n, err
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
package blobgen_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"io"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestLimitReaderPassesExactSize checks that a stream of exactly the limit
|
|
||||||
// reads back cleanly to EOF: the bound must not reject a legitimate blob
|
|
||||||
// whose plaintext equals its recorded size.
|
|
||||||
func TestLimitReaderPassesExactSize(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const n = 1000
|
|
||||||
|
|
||||||
r := blobgen.LimitReader(bytes.NewReader(bytes.Repeat([]byte("a"), n)), n)
|
|
||||||
|
|
||||||
got, err := io.ReadAll(r)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, got, n)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLimitReaderFailsPastLimit feeds a large, highly compressible run of
|
|
||||||
// zeros — the decompressed output a zip bomb would produce — through a
|
|
||||||
// small limit and checks it fails within the bound rather than passing
|
|
||||||
// the whole stream through.
|
|
||||||
func TestLimitReaderFailsPastLimit(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const limit = 1000
|
|
||||||
|
|
||||||
r := blobgen.LimitReader(
|
|
||||||
bytes.NewReader(bytes.Repeat([]byte{0}, limit*1000)), limit)
|
|
||||||
|
|
||||||
n, err := io.Copy(io.Discard, r)
|
|
||||||
require.ErrorIs(t, err, blobgen.ErrOutputTooLarge)
|
|
||||||
require.LessOrEqual(t, n, int64(limit)+1,
|
|
||||||
"reader must stop within one byte of the limit")
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
package blobgen_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"fmt"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"filippo.io/age"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestNewReaderWrongIdentity covers issue case 4: opening a blob with an
|
|
||||||
// identity other than the recipient reports no matching identity.
|
|
||||||
func TestNewReaderWrongIdentity(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
_, recipient := makeIdentity(t)
|
|
||||||
other, _ := makeIdentity(t)
|
|
||||||
|
|
||||||
blob := encryptBlob(t, []byte("secret payload"), recipient)
|
|
||||||
|
|
||||||
_, err := blobgen.NewReader(bytes.NewReader(blob), other)
|
|
||||||
require.Error(t, err)
|
|
||||||
|
|
||||||
var noMatch *age.NoIdentityMatchError
|
|
||||||
assert.ErrorAs(t, err, &noMatch)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNewReaderTruncated covers issue case 6: a multi-segment blob cut at
|
|
||||||
// several points must never read back as valid data. The point immediately
|
|
||||||
// after the header and nonce is intentionally excluded: it reads as a valid
|
|
||||||
// empty blob today and is the regression case for
|
|
||||||
// https://git.eeqj.de/sneak/vaultik/issues/152.
|
|
||||||
func TestNewReaderTruncated(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
id, recipient := makeIdentity(t)
|
|
||||||
blob := encryptBlob(t, randomBytes(t, 4*65536+123), recipient)
|
|
||||||
h := ageHeaderLen(t, blob)
|
|
||||||
|
|
||||||
require.Greater(t, len(blob), h+ageNonceSize+ageSegmentSize,
|
|
||||||
"test needs a blob of at least two age segments")
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
size int
|
|
||||||
}{
|
|
||||||
{"inside header", h / 2},
|
|
||||||
{"inside nonce", h + 8},
|
|
||||||
{"inside first segment", h + ageNonceSize + 100},
|
|
||||||
{"end of first full segment", h + ageNonceSize + ageSegmentSize},
|
|
||||||
{"last byte removed", len(blob) - 1},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
requireBlobUnreadable(t, blob[:tc.size], id)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNewReaderCorrupted covers issue case 7: one flipped byte in each region
|
|
||||||
// of a multi-segment blob makes it unreadable.
|
|
||||||
func TestNewReaderCorrupted(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
id, recipient := makeIdentity(t)
|
|
||||||
blob := encryptBlob(t, randomBytes(t, 4*65536+123), recipient)
|
|
||||||
h := ageHeaderLen(t, blob)
|
|
||||||
|
|
||||||
firstNL := bytes.IndexByte(blob, '\n')
|
|
||||||
require.Positive(t, firstNL, "header must have a version line")
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
pos int
|
|
||||||
}{
|
|
||||||
{"header stanza", firstNL + 5},
|
|
||||||
{"header MAC line", h - 2},
|
|
||||||
{"nonce", h + 4},
|
|
||||||
{"body segment", h + ageNonceSize + 50},
|
|
||||||
{"final tag", len(blob) - 1},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
corrupt := append([]byte(nil), blob...)
|
|
||||||
corrupt[tc.pos] ^= 0xff
|
|
||||||
requireBlobUnreadable(t, corrupt, id)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNewReaderTrailingAndGarbage covers issue case 8: bytes appended after a
|
|
||||||
// valid blob, empty input, and random garbage each fail to read.
|
|
||||||
func TestNewReaderTrailingAndGarbage(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
id, recipient := makeIdentity(t)
|
|
||||||
|
|
||||||
valid := encryptBlob(t, []byte("small payload"), recipient)
|
|
||||||
appended := append(append([]byte(nil), valid...), []byte("trailing junk")...)
|
|
||||||
|
|
||||||
t.Run("appended bytes", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
requireBlobUnreadable(t, appended, id)
|
|
||||||
})
|
|
||||||
t.Run("empty input", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
requireBlobUnreadable(t, []byte{}, id)
|
|
||||||
})
|
|
||||||
t.Run("random garbage", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
requireBlobUnreadable(t, randomBytes(t, 512), id)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNewWriterInvalidLevel covers the rejected end of issue case 9: an
|
|
||||||
// out-of-range compression level errors and writes nothing to the destination.
|
|
||||||
func TestNewWriterInvalidLevel(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
_, recipient := makeIdentity(t)
|
|
||||||
|
|
||||||
for _, level := range []int{0, -1, 20} {
|
|
||||||
t.Run(fmt.Sprintf("level%d", level), func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
|
|
||||||
w, err := blobgen.NewWriter(&buf, level, []string{recipient})
|
|
||||||
require.ErrorIs(t, err, blobgen.ErrInvalidCompressionLevel)
|
|
||||||
assert.Nil(t, w)
|
|
||||||
assert.Zero(t, buf.Len(), "nothing written on an invalid level")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNewWriterInvalidRecipients covers issue case 10: nil and empty recipient
|
|
||||||
// lists and an unparsable recipient string each error.
|
|
||||||
func TestNewWriterInvalidRecipients(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
recipients []string
|
|
||||||
}{
|
|
||||||
{"nil list", nil},
|
|
||||||
{"empty list", []string{}},
|
|
||||||
{"invalid recipient string", []string{"not-a-recipient"}},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
|
|
||||||
w, err := blobgen.NewWriter(&buf, 1, tc.recipients)
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Nil(t, w)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNewWriterFailingDestination covers issue case 11: a destination that
|
|
||||||
// fails mid-blob surfaces its error from Write or Close.
|
|
||||||
func TestNewWriterFailingDestination(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
_, recipient := makeIdentity(t)
|
|
||||||
|
|
||||||
// The limit clears the age header and nonce so NewWriter succeeds, then
|
|
||||||
// trips once the compressed body starts flowing.
|
|
||||||
dst := &failAfterWriter{limit: 512}
|
|
||||||
|
|
||||||
w, err := blobgen.NewWriter(dst, 1, []string{recipient})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
_, writeErr := w.Write(randomBytes(t, 256*1024))
|
|
||||||
closeErr := w.Close()
|
|
||||||
|
|
||||||
assert.True(t, writeErr != nil || closeErr != nil,
|
|
||||||
"destination failure must surface from Write or Close")
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ package blobgen
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"hash"
|
"hash"
|
||||||
"io"
|
"io"
|
||||||
@@ -21,12 +20,10 @@ type Reader struct {
|
|||||||
bytesRead int64
|
bytesRead int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewReader creates a new Reader that decrypts, decompresses, and verifies
|
// NewReader creates a new Reader that decrypts, decompresses, and verifies data
|
||||||
// data. Every supplied identity is offered to age.Decrypt, so a blob
|
func NewReader(r io.Reader, identity age.Identity) (*Reader, error) {
|
||||||
// encrypted to any one of them can be read.
|
|
||||||
func NewReader(r io.Reader, identities ...age.Identity) (*Reader, error) {
|
|
||||||
// Create decryption reader
|
// Create decryption reader
|
||||||
decReader, err := age.Decrypt(r, identities...)
|
decReader, err := age.Decrypt(r, identity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("creating decryption reader: %w", err)
|
return nil, fmt.Errorf("creating decryption reader: %w", err)
|
||||||
}
|
}
|
||||||
@@ -57,22 +54,6 @@ func (r *Reader) Read(p []byte) (int, error) {
|
|||||||
n, err := r.teeReader.Read(p)
|
n, err := r.teeReader.Read(p)
|
||||||
r.bytesRead += int64(n)
|
r.bytesRead += int64(n)
|
||||||
|
|
||||||
// When the ciphertext is cut right after the age header plus its
|
|
||||||
// 16-byte nonce, the age reader's first read fails with
|
|
||||||
// io.ErrUnexpectedEOF, and the zstd decoder maps that to a clean
|
|
||||||
// io.EOF at frame start. That makes a truncated stream look like a
|
|
||||||
// valid empty one. Distinguish the two: on EOF, read once more from
|
|
||||||
// the age reader. A genuine end leaves it at (0, io.EOF); a truncated
|
|
||||||
// stream leaves its stored io.ErrUnexpectedEOF, which we surface.
|
|
||||||
if errors.Is(err, io.EOF) {
|
|
||||||
var probe [1]byte
|
|
||||||
|
|
||||||
m, ageErr := r.decryptor.Read(probe[:])
|
|
||||||
if m != 0 || !errors.Is(ageErr, io.EOF) {
|
|
||||||
return n, io.ErrUnexpectedEOF
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return n, err
|
return n, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,9 +64,7 @@ func (r *Reader) Close() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sum256 returns the single SHA-256 of the plaintext read so far. This is the
|
// Sum256 returns the SHA256 hash of all data read
|
||||||
// first hash only; the stored object name is its double hash, which callers
|
|
||||||
// obtain by passing this digest to DoubleSHA256.
|
|
||||||
func (r *Reader) Sum256() []byte {
|
func (r *Reader) Sum256() []byte {
|
||||||
return r.hasher.Sum(nil)
|
return r.hasher.Sum(nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
package blobgen_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"io"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"filippo.io/age"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestMultipleRecipients verifies that data written for several recipients can
|
|
||||||
// be read back by each recipient's identity. Moved from internal/crypto, which
|
|
||||||
// held the only multi-recipient test; blobgen is now the sole encryption path.
|
|
||||||
func TestMultipleRecipients(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
identities := make([]*age.X25519Identity, 3)
|
|
||||||
recipients := make([]string, 3)
|
|
||||||
|
|
||||||
for i := range identities {
|
|
||||||
identity, err := age.GenerateX25519Identity()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
identities[i] = identity
|
|
||||||
recipients[i] = identity.Recipient().String()
|
|
||||||
}
|
|
||||||
|
|
||||||
plaintext := []byte("Secret message for multiple recipients")
|
|
||||||
|
|
||||||
var encrypted bytes.Buffer
|
|
||||||
|
|
||||||
writer, err := blobgen.NewWriter(&encrypted, 3, recipients)
|
|
||||||
require.NoError(t, err)
|
|
||||||
_, err = writer.Write(plaintext)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, writer.Close())
|
|
||||||
|
|
||||||
// Every recipient's identity must recover the original plaintext.
|
|
||||||
for i, identity := range identities {
|
|
||||||
reader, err := blobgen.NewReader(
|
|
||||||
bytes.NewReader(encrypted.Bytes()), identity)
|
|
||||||
require.NoError(t, err, "recipient %d should open the reader", i+1)
|
|
||||||
|
|
||||||
got, err := io.ReadAll(reader)
|
|
||||||
require.NoError(t, err, "recipient %d should read the plaintext", i+1)
|
|
||||||
require.NoError(t, reader.Close())
|
|
||||||
|
|
||||||
assert.Equal(t, plaintext, got,
|
|
||||||
"recipient %d should recover the original plaintext", i+1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
package blobgen_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"crypto/sha256"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"filippo.io/age"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
|
||||||
)
|
|
||||||
|
|
||||||
// checkRoundTrip writes input through a Writer, reads it back through a Reader,
|
|
||||||
// and verifies the plaintext, the byte counts, and the content hashes.
|
|
||||||
func checkRoundTrip(
|
|
||||||
t *testing.T, id *age.X25519Identity, recipient string,
|
|
||||||
level int, input []byte,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
|
|
||||||
w, err := blobgen.NewWriter(&buf, level, []string{recipient})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
n, err := w.Write(input)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, len(input), n)
|
|
||||||
require.NoError(t, w.Close())
|
|
||||||
require.Equal(t, int64(len(input)), w.BytesWritten())
|
|
||||||
|
|
||||||
r, err := blobgen.NewReader(bytes.NewReader(buf.Bytes()), id)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
got, err := io.ReadAll(r)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, r.Close())
|
|
||||||
|
|
||||||
assert.Equal(t, input, got, "decrypted output must equal input")
|
|
||||||
require.Equal(t, int64(len(input)), r.BytesRead())
|
|
||||||
|
|
||||||
// The hash values are checked by decrypting: the reader's single SHA-256
|
|
||||||
// is the hash of the plaintext, and hashing it once more (DoubleSHA256)
|
|
||||||
// gives the writer's ContentID.
|
|
||||||
single := sha256.Sum256(got)
|
|
||||||
assert.Equal(t, single[:], r.Sum256())
|
|
||||||
assert.Equal(t, blobgen.DoubleSHA256(r.Sum256()), w.ContentID())
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWriterReaderRoundTrip covers issue cases 1 and 2: every size round trips
|
|
||||||
// for both random and compressible data, and the reader hash, its double hash
|
|
||||||
// and the byte counts all agree.
|
|
||||||
func TestWriterReaderRoundTrip(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
id, recipient := makeIdentity(t)
|
|
||||||
|
|
||||||
// Sizes exercise the age segment boundary (64 KiB) from just below to a
|
|
||||||
// few segments above it, plus the empty and single-byte edges.
|
|
||||||
sizes := []int{0, 1, 65535, 65536, 65537, 4*65536 + 123}
|
|
||||||
|
|
||||||
kinds := []struct {
|
|
||||||
name string
|
|
||||||
fill func(*testing.T, int) []byte
|
|
||||||
}{
|
|
||||||
{"random", randomBytes},
|
|
||||||
{"compressible", func(_ *testing.T, n int) []byte {
|
|
||||||
return compressibleBytes(n)
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, k := range kinds {
|
|
||||||
for _, size := range sizes {
|
|
||||||
name := fmt.Sprintf("%s/%d", k.name, size)
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
checkRoundTrip(t, id, recipient, 1, k.fill(t, size))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestZeroLengthNoWrite covers issue case 3: a Writer closed with no Write at
|
|
||||||
// all produces the double hash of the empty input, and the blob reads back as
|
|
||||||
// empty with no error.
|
|
||||||
func TestZeroLengthNoWrite(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
id, recipient := makeIdentity(t)
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
|
|
||||||
w, err := blobgen.NewWriter(&buf, 1, []string{recipient})
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, w.Close())
|
|
||||||
assert.Equal(t, int64(0), w.BytesWritten())
|
|
||||||
|
|
||||||
empty := sha256.Sum256(nil)
|
|
||||||
doubled := sha256.Sum256(empty[:])
|
|
||||||
assert.Equal(t, doubled[:], w.ContentID(),
|
|
||||||
"ContentID of empty input is SHA256(SHA256(\"\"))")
|
|
||||||
|
|
||||||
r, err := blobgen.NewReader(bytes.NewReader(buf.Bytes()), id)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
got, err := io.ReadAll(r)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, r.Close())
|
|
||||||
|
|
||||||
assert.Empty(t, got, "empty blob decrypts to empty output")
|
|
||||||
assert.Equal(t, int64(0), r.BytesRead())
|
|
||||||
assert.Equal(t, empty[:], r.Sum256())
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNewWriterValidLevelsRoundTrip covers the accepted end of issue case 9:
|
|
||||||
// the boundary compression levels 1 and 19 both round trip.
|
|
||||||
func TestNewWriterValidLevelsRoundTrip(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
id, recipient := makeIdentity(t)
|
|
||||||
input := randomBytes(t, 4096)
|
|
||||||
|
|
||||||
for _, level := range []int{1, 19} {
|
|
||||||
t.Run(fmt.Sprintf("level%d", level), func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
checkRoundTrip(t, id, recipient, level, input)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
package blobgen_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"io"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"filippo.io/age"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestReaderRejectsHeaderNonceTruncation guards against a stream cut right
|
|
||||||
// after the age header plus its 16-byte nonce. age.Decrypt still succeeds on
|
|
||||||
// such an object, and the zstd decoder maps the age reader's
|
|
||||||
// io.ErrUnexpectedEOF to a clean io.EOF at frame start, so without the extra
|
|
||||||
// check the truncated stream would read as a valid empty one. Reading it must
|
|
||||||
// now fail.
|
|
||||||
func TestReaderRejectsHeaderNonceTruncation(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
identity, err := age.GenerateX25519Identity()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Encrypting empty plaintext yields header + nonce(16) + a single
|
|
||||||
// 16-byte final chunk tag. Dropping the trailing tag leaves exactly the
|
|
||||||
// age header plus its nonce — the truncation point that triggers the bug.
|
|
||||||
var full bytes.Buffer
|
|
||||||
|
|
||||||
w, err := age.Encrypt(&full, identity.Recipient())
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, w.Close())
|
|
||||||
|
|
||||||
truncated := full.Bytes()[:full.Len()-16]
|
|
||||||
|
|
||||||
reader, err := blobgen.NewReader(bytes.NewReader(truncated), identity)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
defer func() { _ = reader.Close() }()
|
|
||||||
|
|
||||||
_, err = io.ReadAll(reader)
|
|
||||||
require.Error(t, err)
|
|
||||||
require.ErrorIs(t, err, io.ErrUnexpectedEOF)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestReaderReadsGenuinelyEmptyBlob confirms the truncation check does not
|
|
||||||
// reject a legitimately empty payload: a blob written with no data must round
|
|
||||||
// trip back to zero bytes with no error.
|
|
||||||
func TestReaderReadsGenuinelyEmptyBlob(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
identity, err := age.GenerateX25519Identity()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
var encrypted bytes.Buffer
|
|
||||||
|
|
||||||
writer, err := blobgen.NewWriter(
|
|
||||||
&encrypted, 3, []string{identity.Recipient().String()})
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, writer.Close())
|
|
||||||
|
|
||||||
reader, err := blobgen.NewReader(bytes.NewReader(encrypted.Bytes()), identity)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
defer func() { _ = reader.Close() }()
|
|
||||||
|
|
||||||
data, err := io.ReadAll(reader)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Empty(t, data)
|
|
||||||
}
|
|
||||||
+13
-36
@@ -1,6 +1,3 @@
|
|||||||
// Package blobgen implements the blob data pipeline: streaming zstd
|
|
||||||
// compression, age encryption, and SHA256 content hashing for blob
|
|
||||||
// creation, plus the matching decrypt/decompress/verify reader.
|
|
||||||
package blobgen
|
package blobgen
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -15,24 +12,6 @@ import (
|
|||||||
"github.com/klauspost/compress/zstd"
|
"github.com/klauspost/compress/zstd"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DoubleSHA256 returns the double SHA-256 of content whose single SHA-256
|
|
||||||
// digest is sum: it hashes that digest once more. Stored objects — a blob, and
|
|
||||||
// the metadata database export — are named by this second hash.
|
|
||||||
//
|
|
||||||
// The second hash does not hide whether known content is stored: an attacker
|
|
||||||
// who can reproduce an object's entire plaintext computes the same name simply
|
|
||||||
// by hashing twice, exactly as this code does. What limits that is blob
|
|
||||||
// packing, not the double hash — a blob's name covers all of its concatenated
|
|
||||||
// chunk plaintext, so a name can be confirmed only by someone who can
|
|
||||||
// reproduce the whole blob (a snapshot made entirely of known content, or a
|
|
||||||
// known file large enough to fill blobs on its own). An ordinary file that
|
|
||||||
// shares a blob with other, unknown data cannot be confirmed this way.
|
|
||||||
func DoubleSHA256(sum []byte) []byte {
|
|
||||||
h := sha256.Sum256(sum)
|
|
||||||
|
|
||||||
return h[:]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Zstd compression level bounds accepted by NewWriter.
|
// Zstd compression level bounds accepted by NewWriter.
|
||||||
const (
|
const (
|
||||||
minCompressionLevel = 1
|
minCompressionLevel = 1
|
||||||
@@ -48,11 +27,6 @@ const reservedCompressionCPUs = 2
|
|||||||
var ErrInvalidCompressionLevel = errors.New(
|
var ErrInvalidCompressionLevel = errors.New(
|
||||||
"invalid compression level: must be between 1 and 19")
|
"invalid compression level: must be between 1 and 19")
|
||||||
|
|
||||||
// errInvalidRecipient is returned when a recipient string does not parse as
|
|
||||||
// an X25519 age1... public key. It omits the value, which can be sensitive.
|
|
||||||
var errInvalidRecipient = errors.New(
|
|
||||||
"not a valid X25519 age1... recipient")
|
|
||||||
|
|
||||||
// Writer wraps compression and encryption with SHA256 hashing.
|
// 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.
|
||||||
@@ -83,12 +57,10 @@ func NewWriter(
|
|||||||
// Parse recipients
|
// Parse recipients
|
||||||
var ageRecipients []age.Recipient
|
var ageRecipients []age.Recipient
|
||||||
|
|
||||||
for i, recipient := range recipients {
|
for _, recipient := range recipients {
|
||||||
// The recipient string can be sensitive (e.g. a secret key pasted by
|
|
||||||
// mistake), so the error names its position, never its value.
|
|
||||||
r, err := age.ParseX25519Recipient(recipient)
|
r, err := age.ParseX25519Recipient(recipient)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%w: recipient %d", errInvalidRecipient, i)
|
return nil, fmt.Errorf("parsing recipient %s: %w", recipient, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ageRecipients = append(ageRecipients, r)
|
ageRecipients = append(ageRecipients, r)
|
||||||
@@ -151,12 +123,17 @@ func (w *Writer) Close() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ContentID returns the double SHA-256 of the uncompressed input data: the
|
// Sum256 returns the double SHA256 hash of the uncompressed input data.
|
||||||
// name under which this content is stored. It is the second hash of the
|
// Double hashing (SHA256(SHA256(data))) prevents information leakage about
|
||||||
// running SHA-256, via DoubleSHA256; see that function for what naming content
|
// the plaintext - an attacker cannot confirm existence of known content
|
||||||
// this way does and does not hide.
|
// by computing its hash and checking for a matching blob filename.
|
||||||
func (w *Writer) ContentID() []byte {
|
func (w *Writer) Sum256() []byte {
|
||||||
return DoubleSHA256(w.hasher.Sum(nil))
|
// First hash: SHA256(plaintext)
|
||||||
|
firstHash := w.hasher.Sum(nil)
|
||||||
|
// Second hash: SHA256(firstHash) - this is the blob ID
|
||||||
|
secondHash := sha256.Sum256(firstHash)
|
||||||
|
|
||||||
|
return secondHash[:]
|
||||||
}
|
}
|
||||||
|
|
||||||
// BytesWritten returns the number of uncompressed bytes written
|
// BytesWritten returns the number of uncompressed bytes written
|
||||||
|
|||||||
@@ -12,10 +12,9 @@ import (
|
|||||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestWriterHashIsDoubleHash verifies that Writer.ContentID() returns
|
// TestWriterHashIsDoubleHash verifies that Writer.Sum256() returns
|
||||||
// SHA256(SHA256(plaintext)). Stored objects are named by this second hash so a
|
// the double hash SHA256(SHA256(plaintext)) for security.
|
||||||
// name is not the plaintext's own SHA-256; this does not stop someone who
|
// Double hashing prevents attackers from confirming existence of known content.
|
||||||
// already holds the plaintext from confirming it.
|
|
||||||
func TestWriterHashIsDoubleHash(t *testing.T) {
|
func TestWriterHashIsDoubleHash(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -44,7 +43,7 @@ func TestWriterHashIsDoubleHash(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Get the hash from the writer
|
// Get the hash from the writer
|
||||||
writerHash := hex.EncodeToString(writer.ContentID())
|
writerHash := hex.EncodeToString(writer.Sum256())
|
||||||
|
|
||||||
// Calculate the expected double hash: SHA256(SHA256(plaintext))
|
// Calculate the expected double hash: SHA256(SHA256(plaintext))
|
||||||
firstHash := sha256.Sum256(testData)
|
firstHash := sha256.Sum256(testData)
|
||||||
@@ -61,11 +60,11 @@ func TestWriterHashIsDoubleHash(t *testing.T) {
|
|||||||
|
|
||||||
// The writer hash should match the double hash
|
// The writer hash should match the double hash
|
||||||
assert.Equal(t, expectedDoubleHash, writerHash,
|
assert.Equal(t, expectedDoubleHash, writerHash,
|
||||||
"Writer.ContentID() must be SHA256(SHA256(plaintext))")
|
"Writer.Sum256() should return SHA256(SHA256(plaintext)) for security")
|
||||||
|
|
||||||
// It must be the second hash, not the plaintext's own SHA-256.
|
// Verify it's NOT the single hash (would leak information)
|
||||||
assert.NotEqual(t, singleHashStr, writerHash,
|
assert.NotEqual(t, singleHashStr, writerHash,
|
||||||
"Writer hash must be the double hash, not the single SHA-256")
|
"Writer hash should not be single hash (would allow content confirmation attacks)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestWriterDeterministicHash verifies that the same input always produces
|
// TestWriterDeterministicHash verifies that the same input always produces
|
||||||
@@ -94,8 +93,8 @@ func TestWriterDeterministicHash(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, writer2.Close())
|
require.NoError(t, writer2.Close())
|
||||||
|
|
||||||
hash1 := hex.EncodeToString(writer1.ContentID())
|
hash1 := hex.EncodeToString(writer1.Sum256())
|
||||||
hash2 := hex.EncodeToString(writer2.ContentID())
|
hash2 := hex.EncodeToString(writer2.Sum256())
|
||||||
|
|
||||||
// Hashes should be identical (deterministic)
|
// Hashes should be identical (deterministic)
|
||||||
assert.Equal(t, hash1, hash2, "Same input should produce same hash")
|
assert.Equal(t, hash1, hash2, "Same input should produce same hash")
|
||||||
@@ -109,20 +108,3 @@ func TestWriterDeterministicHash(t *testing.T) {
|
|||||||
t.Logf("Encrypted size 1: %d bytes", buf1.Len())
|
t.Logf("Encrypted size 1: %d bytes", buf1.Len())
|
||||||
t.Logf("Encrypted size 2: %d bytes", buf2.Len())
|
t.Logf("Encrypted size 2: %d bytes", buf2.Len())
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestNewWriterSecretKeyNotEchoed verifies that a secret key mistakenly passed
|
|
||||||
// as a recipient does not appear in the returned error. A recipient string can
|
|
||||||
// be sensitive, so the error must name only the position, not the value.
|
|
||||||
func TestNewWriterSecretKeyNotEchoed(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
secretKey := "AGE-SECRET-KEY-19CR5YSFW59HM4TLD6GX" +
|
|
||||||
"VEDMZFTVVF7PPHKUT68TXSFPK7APHXA2QS2NJA5"
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
|
|
||||||
_, err := blobgen.NewWriter(&buf, 3, []string{secretKey})
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.NotContains(t, err.Error(), secretKey,
|
|
||||||
"error must not echo the recipient value")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -33,10 +33,9 @@ type Chunker struct {
|
|||||||
maxChunkSize int
|
maxChunkSize int
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChunkSizeSpread is the FastCDC-recommended factor between the average
|
// chunkSizeSpread is the FastCDC-recommended factor between the average
|
||||||
// chunk size and the minimum (avg/spread) and maximum (avg*spread) sizes.
|
// chunk size and the minimum (avg/spread) and maximum (avg*spread) sizes.
|
||||||
// The largest chunk the chunker can emit is therefore avg*ChunkSizeSpread.
|
const chunkSizeSpread = 4
|
||||||
const ChunkSizeSpread = 4
|
|
||||||
|
|
||||||
// NewChunker creates a new chunker with the specified average chunk size.
|
// 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
|
||||||
@@ -46,8 +45,8 @@ 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 / chunkSizeSpread),
|
||||||
maxChunkSize: int(avgChunkSize * ChunkSizeSpread),
|
maxChunkSize: int(avgChunkSize * chunkSizeSpread),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+114
-202
@@ -7,9 +7,11 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/adrg/xdg"
|
"github.com/adrg/xdg"
|
||||||
@@ -30,33 +32,14 @@ import (
|
|||||||
// may take before we give up.
|
// may take before we give up.
|
||||||
const shutdownTimeout = 30 * time.Second
|
const shutdownTimeout = 30 * time.Second
|
||||||
|
|
||||||
// lockMode says whether a command mutates persistent state — the local
|
// AppOptions contains common options for creating the fx application.
|
||||||
// index database or the remote store — and so must hold the process-wide
|
// It includes the configuration file path, logging options, and additional
|
||||||
// PID lock, or only reads that state and may run alongside a mutator.
|
// fx modules and invocations that should be included in the application.
|
||||||
type lockMode int
|
|
||||||
|
|
||||||
const (
|
|
||||||
// mutating commands (snapshot create, snapshot purge, snapshot remove,
|
|
||||||
// prune, remote nuke) write the local index or the remote store. They
|
|
||||||
// hold the PID lock so that at most one runs at a time.
|
|
||||||
mutating lockMode = iota
|
|
||||||
// readOnly commands (info, snapshot list, snapshot verify, remote info,
|
|
||||||
// snapshot restore) do not write the local index or the remote store,
|
|
||||||
// so they run without the lock and are never blocked by a running
|
|
||||||
// mutator. restore writes only to the target directory it is given.
|
|
||||||
readOnly
|
|
||||||
)
|
|
||||||
|
|
||||||
// AppOptions contains common options for creating and running the fx
|
|
||||||
// application: the configuration file path, logging options, additional fx
|
|
||||||
// modules and invocations, and whether the command mutates persistent
|
|
||||||
// state (which decides whether it takes the PID lock).
|
|
||||||
type AppOptions struct {
|
type AppOptions struct {
|
||||||
ConfigPath string
|
ConfigPath string
|
||||||
LogOptions log.Options
|
LogOptions log.Options
|
||||||
Modules []fx.Option
|
Modules []fx.Option
|
||||||
Invokes []fx.Option
|
Invokes []fx.Option
|
||||||
Mode lockMode
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// setupGlobals records the startup time and, when an output-suppression
|
// setupGlobals records the startup time and, when an output-suppression
|
||||||
@@ -65,11 +48,6 @@ type AppOptions struct {
|
|||||||
// silenced — per the documented convention that --quiet suppresses
|
// silenced — per the documented convention that --quiet suppresses
|
||||||
// non-error output only. The startup banner is printed by Entry
|
// non-error output only. The startup banner is printed by Entry
|
||||||
// before cobra parses arguments, gated by the same arg-level check.
|
// before cobra parses arguments, gated by the same arg-level check.
|
||||||
//
|
|
||||||
// --json quiets the UI here too, because stdout then carries a JSON
|
|
||||||
// document and human narration would corrupt it. Unlike Quiet it does
|
|
||||||
// not lower the stderr log level (issue #112), so --verbose/--debug
|
|
||||||
// still surface diagnostics alongside the document.
|
|
||||||
func setupGlobals(
|
func setupGlobals(
|
||||||
lc fx.Lifecycle, g *globals.Globals, v *vaultik.Vaultik, opts log.Options,
|
lc fx.Lifecycle, g *globals.Globals, v *vaultik.Vaultik, opts log.Options,
|
||||||
) {
|
) {
|
||||||
@@ -77,7 +55,7 @@ func setupGlobals(
|
|||||||
OnStart: func(_ context.Context) error {
|
OnStart: func(_ context.Context) error {
|
||||||
g.StartTime = time.Now().UTC()
|
g.StartTime = time.Now().UTC()
|
||||||
|
|
||||||
if opts.Cron || opts.Quiet || opts.JSON {
|
if opts.Cron || opts.Quiet {
|
||||||
v.UI.SetQuiet(true)
|
v.UI.SetQuiet(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,148 +136,75 @@ func cleanStartupError(err error) error {
|
|||||||
return &startupError{msg: msg}
|
return &startupError{msg: msg}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunApp starts the fx application, blocks until it is asked to stop, and
|
// RunApp starts and stops the fx application within the given context.
|
||||||
// then stops it. The app is asked to stop either by an OS interrupt
|
// It handles graceful shutdown on interrupt signals (SIGINT, SIGTERM) and
|
||||||
// (SIGINT/SIGTERM — fx installs its own handler when app.Wait is called) or,
|
// ensures the application stops cleanly. The function blocks until the
|
||||||
// on normal completion, by the finished operation calling
|
// application completes or is interrupted. Returns an error if startup fails.
|
||||||
// Shutdowner.Shutdown(); both arrive on the app.Wait channel.
|
|
||||||
//
|
|
||||||
// Stopping runs the fx OnStop hooks, and RunApp does not return until Stop
|
|
||||||
// returns. On an interrupt the operation's OnStop hook cancels the running
|
|
||||||
// command and waits for it to unwind — removing its decrypted scratch files —
|
|
||||||
// so the process cannot proceed to exit mid-cleanup (issue #159). Waiting for
|
|
||||||
// Stop before returning is what makes that hook effective: routing the
|
|
||||||
// interrupt through app.Stop and not returning until it completes is required,
|
|
||||||
// because fx also fires the app.Wait channel on the signal, and an earlier
|
|
||||||
// version returned on that alone — unwinding to os.Exit while the concurrent
|
|
||||||
// cleanup still ran. The stop is bounded by shutdownTimeout. Returns an error
|
|
||||||
// if startup fails.
|
|
||||||
func RunApp(ctx context.Context, app *fx.App) error {
|
func RunApp(ctx context.Context, app *fx.App) error {
|
||||||
|
// Set up signal handling for graceful shutdown
|
||||||
|
sigChan := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
||||||
|
|
||||||
|
// Create a context that will be cancelled on signal
|
||||||
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Start the app
|
||||||
err := app.Start(ctx)
|
err := app.Start(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cleanStartupError(err)
|
return cleanStartupError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block until an interrupt or the finished operation's
|
// Handle shutdown
|
||||||
// Shutdowner.Shutdown() arrives, then stop the app in this goroutine so we
|
shutdownComplete := make(chan struct{})
|
||||||
// return only after its OnStop hooks — including the operation's cleanup
|
|
||||||
// wait — have run. Detach the stop from ctx's cancellation but keep its
|
|
||||||
// values, and bound it by shutdownTimeout.
|
|
||||||
<-app.Wait()
|
|
||||||
|
|
||||||
shutdownCtx, cancel := context.WithTimeout(
|
go func() {
|
||||||
context.WithoutCancel(ctx), shutdownTimeout)
|
defer close(shutdownComplete)
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
err = app.Stop(shutdownCtx)
|
<-sigChan
|
||||||
if err != nil {
|
log.Notice("Received interrupt signal, shutting down gracefully...")
|
||||||
log.Error("Error during shutdown", "error", err)
|
|
||||||
|
// Create a timeout context for shutdown. The parent ctx is being
|
||||||
|
// cancelled, so detach from its cancellation but keep its values.
|
||||||
|
shutdownCtx, shutdownCancel := context.WithTimeout(
|
||||||
|
context.WithoutCancel(ctx), shutdownTimeout)
|
||||||
|
defer shutdownCancel()
|
||||||
|
|
||||||
|
err := app.Stop(shutdownCtx)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Error during shutdown", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for the signal handler to complete shutdown or the app to
|
||||||
|
// request shutdown.
|
||||||
|
select {
|
||||||
|
case <-shutdownComplete:
|
||||||
|
// Shutdown completed via signal
|
||||||
|
return nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
// Context cancelled (shouldn't happen in normal operation)
|
||||||
|
err := app.Stop(context.WithoutCancel(ctx))
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Error stopping app", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.Err()
|
||||||
|
case <-app.Done():
|
||||||
|
// App finished running (e.g., backup completed)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// errReported marks a failure the operation has already shown the user
|
|
||||||
// (and deliberately withheld under --json). Entry turns it into a
|
|
||||||
// non-zero exit status without printing anything further, so the error
|
|
||||||
// line is not doubled. It flows up from RunOperation through cobra to
|
|
||||||
// Entry.
|
|
||||||
var errReported = errors.New("operation failed")
|
|
||||||
|
|
||||||
// RunOperation runs op against the Vaultik instance inside the fx app
|
|
||||||
// and turns a failure into a returned error rather than an os.Exit from
|
|
||||||
// within the goroutine. An os.Exit there skipped main's deferred
|
|
||||||
// profile writers -- so profiling a failing command yielded a truncated
|
|
||||||
// profile (issue #75) -- and RunWithApp's PID-lock release, and denied
|
|
||||||
// the app any graceful shutdown; returning the error to the top runs
|
|
||||||
// all three.
|
|
||||||
//
|
|
||||||
// op runs in a goroutine so OnStart returns promptly and an interrupt
|
|
||||||
// can still cancel through OnStop; when it finishes, success or failure,
|
|
||||||
// it triggers shutdown, which is what lets RunWithApp return. On an
|
|
||||||
// interrupt OnStop cancels op and waits for the goroutine to return, so
|
|
||||||
// op's cleanup (removing decrypted scratch files) runs before the
|
|
||||||
// process exits; the wait is bounded by shutdownTimeout. report is
|
|
||||||
// called with a non-canceled failure so the caller can log it (and
|
|
||||||
// suppress it under --json) before it becomes errReported. A context
|
|
||||||
// cancellation is the interrupt path, not a failure: it is neither
|
|
||||||
// reported nor counted as one.
|
|
||||||
func RunOperation(
|
|
||||||
ctx context.Context, opts AppOptions,
|
|
||||||
op func(v *vaultik.Vaultik) error, report func(err error),
|
|
||||||
) error {
|
|
||||||
var (
|
|
||||||
mu sync.Mutex
|
|
||||||
failed bool
|
|
||||||
)
|
|
||||||
|
|
||||||
opts.Invokes = append(opts.Invokes,
|
|
||||||
fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) {
|
|
||||||
var stop func(context.Context) bool
|
|
||||||
|
|
||||||
lc.Append(fx.Hook{
|
|
||||||
OnStart: func(_ context.Context) error {
|
|
||||||
stop = v.StartOperation(func() {
|
|
||||||
err := op(v)
|
|
||||||
if err != nil && !errors.Is(err, context.Canceled) {
|
|
||||||
report(err)
|
|
||||||
|
|
||||||
mu.Lock()
|
|
||||||
failed = true
|
|
||||||
mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
stopErr := v.Shutdowner.Shutdown()
|
|
||||||
if stopErr != nil {
|
|
||||||
log.Error("Failed to shutdown", "error", stopErr)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
// On an interrupt, cancel the operation and wait for it to
|
|
||||||
// unwind so its cleanup defers (which remove decrypted
|
|
||||||
// scratch files from the temp directory) run before the
|
|
||||||
// process exits. The wait is bounded by ctx, the existing
|
|
||||||
// shutdownTimeout.
|
|
||||||
OnStop: func(ctx context.Context) error {
|
|
||||||
if !stop(ctx) {
|
|
||||||
log.Warn("Shutdown timed out before the operation " +
|
|
||||||
"finished; decrypted temporary files may remain")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}))
|
|
||||||
|
|
||||||
err := RunWithApp(ctx, opts)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// The goroutine sets failed before triggering the shutdown that lets
|
|
||||||
// RunWithApp return, so the write is in place by the time we read it.
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
|
|
||||||
if failed {
|
|
||||||
return errReported
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// runVaultikApp runs the standard single-operation command lifecycle
|
// runVaultikApp runs the standard single-operation command lifecycle
|
||||||
// shared by the snapshot list/purge/remove and remote nuke subcommands:
|
// shared by the list/purge/verify/remove/remote-info subcommands:
|
||||||
// resolve the config, then run op against the Vaultik instance through
|
// resolve the config, start the fx app, run op against the Vaultik
|
||||||
// RunOperation, reporting a failure prefixed with failMsg (suppressed
|
// instance in a goroutine, report a failure prefixed with failMsg
|
||||||
// while suppressErrors is true, e.g. under --json). mode says whether the
|
// (suppressed while suppressErrors is true, e.g. under --json), then
|
||||||
// command takes the PID lock. jsonOutput marks a command whose stdout is a
|
// trigger shutdown. The operation is cancelled when the app stops.
|
||||||
// JSON document: it quiets the UI but, unlike Quiet, leaves the stderr log
|
// extraQuiet is OR-ed into LogOptions.Quiet (e.g. --json output modes).
|
||||||
// level alone.
|
|
||||||
func runVaultikApp(
|
func runVaultikApp(
|
||||||
cmd *cobra.Command, mode lockMode, jsonOutput, suppressErrors bool,
|
cmd *cobra.Command, extraQuiet, suppressErrors bool,
|
||||||
failMsg string, op func(v *vaultik.Vaultik) error,
|
failMsg string, op func(v *vaultik.Vaultik) error,
|
||||||
) error {
|
) error {
|
||||||
configPath, err := ResolveConfigPath()
|
configPath, err := ResolveConfigPath()
|
||||||
@@ -309,68 +214,75 @@ func runVaultikApp(
|
|||||||
|
|
||||||
rootFlags := GetRootFlags()
|
rootFlags := GetRootFlags()
|
||||||
|
|
||||||
return RunOperation(cmd.Context(), AppOptions{
|
return RunWithApp(cmd.Context(), AppOptions{
|
||||||
ConfigPath: configPath,
|
ConfigPath: configPath,
|
||||||
LogOptions: log.Options{
|
LogOptions: log.Options{
|
||||||
Verbose: rootFlags.Verbose,
|
Verbose: rootFlags.Verbose,
|
||||||
Debug: rootFlags.Debug,
|
Debug: rootFlags.Debug,
|
||||||
Quiet: rootFlags.Quiet,
|
Quiet: rootFlags.Quiet || extraQuiet,
|
||||||
JSON: jsonOutput,
|
|
||||||
},
|
},
|
||||||
Mode: mode,
|
Modules: []fx.Option{},
|
||||||
}, op, func(err error) {
|
Invokes: []fx.Option{
|
||||||
if suppressErrors {
|
fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) {
|
||||||
return
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
log.Error(failMsg, "error", err)
|
os.Exit(1)
|
||||||
ReportErrorf("%s: %v", failMsg, err)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
// A mutating command takes the process-wide PID lock before starting so that
|
// It acquires a PID lock before starting to prevent concurrent instances.
|
||||||
// only one runs at a time; a read-only command runs without it and is not
|
|
||||||
// blocked while a mutator holds the lock (opts.Mode).
|
|
||||||
func RunWithApp(ctx context.Context, opts AppOptions) error {
|
func RunWithApp(ctx context.Context, opts AppOptions) error {
|
||||||
release, err := acquireLockIfMutating(opts.Mode,
|
// Acquire PID lock to prevent concurrent instances
|
||||||
filepath.Join(xdg.DataHome, "vaultik"))
|
lockDir := filepath.Join(xdg.DataHome, "vaultik")
|
||||||
|
|
||||||
|
lock, err := pidlock.Acquire(lockDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
if errors.Is(err, pidlock.ErrAlreadyRunning) {
|
||||||
|
return fmt.Errorf("cannot start: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("failed to acquire lock: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer release()
|
defer func() {
|
||||||
|
err := lock.Release()
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("Failed to release PID lock", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
app := NewApp(opts)
|
app := NewApp(opts)
|
||||||
|
|
||||||
return RunApp(ctx, app)
|
return RunApp(ctx, app)
|
||||||
}
|
}
|
||||||
|
|
||||||
// acquireLockIfMutating takes the process-wide PID lock in lockDir for a
|
|
||||||
// mutating command and returns a function that releases it. A read-only
|
|
||||||
// command takes no lock, so it returns a no-op release and is never blocked
|
|
||||||
// while a mutator holds the lock. ErrAlreadyRunning (another mutator holds
|
|
||||||
// the lock) is surfaced as a "cannot start" error.
|
|
||||||
func acquireLockIfMutating(mode lockMode, lockDir string) (func(), error) {
|
|
||||||
if mode != mutating {
|
|
||||||
return func() {}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
lock, err := pidlock.Acquire(lockDir)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pidlock.ErrAlreadyRunning) {
|
|
||||||
return nil, fmt.Errorf("cannot start: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to acquire lock: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return func() {
|
|
||||||
err := lock.Release()
|
|
||||||
if err != nil {
|
|
||||||
log.Warn("Failed to release PID lock", "error", err)
|
|
||||||
}
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ package cli //nolint:testpackage // needs access to unexported cleanStartupError
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"sneak.berlin/go/vaultik/internal/pidlock"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCleanStartupError(t *testing.T) {
|
func TestCleanStartupError(t *testing.T) {
|
||||||
@@ -56,42 +53,3 @@ func TestCleanStartupError(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestLockScopedToMutatingCommands proves the partition the PID lock now
|
|
||||||
// enforces: a read-only command runs while a mutator holds the lock, and
|
|
||||||
// two mutating commands still mutually exclude.
|
|
||||||
func TestLockScopedToMutatingCommands(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
lockDir := filepath.Join(t.TempDir(), "vaultik")
|
|
||||||
|
|
||||||
// A mutating command takes the process-wide lock.
|
|
||||||
releaseMutator, err := acquireLockIfMutating(mutating, lockDir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("mutating command could not acquire lock: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A read-only command runs to completion even while the lock is held.
|
|
||||||
releaseReader, err := acquireLockIfMutating(readOnly, lockDir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read-only command was blocked by held lock: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
releaseReader()
|
|
||||||
|
|
||||||
// A second mutating command is refused while the first holds the lock.
|
|
||||||
_, err = acquireLockIfMutating(mutating, lockDir)
|
|
||||||
if !errors.Is(err, pidlock.ErrAlreadyRunning) {
|
|
||||||
t.Fatalf("second mutating command was not excluded, got: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Once the first mutator releases, another mutating command may run.
|
|
||||||
releaseMutator()
|
|
||||||
|
|
||||||
release, err := acquireLockIfMutating(mutating, lockDir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("mutating command could not acquire released lock: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
release()
|
|
||||||
}
|
|
||||||
|
|||||||
+40
-96
@@ -1,7 +1,6 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
@@ -12,7 +11,6 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
"sneak.berlin/go/vaultik/internal/ui"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// configFileMode is the permission set for freshly written config files;
|
// configFileMode is the permission set for freshly written config files;
|
||||||
@@ -26,11 +24,6 @@ const configSetArgs = 2
|
|||||||
// parent config dirs (e.g. ~/.config) are conventionally traversable.
|
// parent config dirs (e.g. ~/.config) are conventionally traversable.
|
||||||
const configDirMode = 0o755
|
const configDirMode = 0o755
|
||||||
|
|
||||||
// configYAMLIndent matches the 2-space indentation of defaultConfigTemplate,
|
|
||||||
// so `config set` writes the file back with the same indentation rather than
|
|
||||||
// yaml.Marshal's 4-space default.
|
|
||||||
const configYAMLIndent = 2
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
errConfigExists = errors.New("config file already exists")
|
errConfigExists = errors.New("config file already exists")
|
||||||
errEmptyConfig = errors.New("empty config file")
|
errEmptyConfig = errors.New("empty config file")
|
||||||
@@ -46,11 +39,8 @@ const defaultConfigTemplate = `# vaultik configuration
|
|||||||
# ─── REQUIRED ────────────────────────────────────────────────────────────────
|
# ─── REQUIRED ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Age recipient public keys for encryption.
|
# Age recipient public keys for encryption.
|
||||||
# Backups are encrypted to ALL listed recipients; any one of the corresponding
|
# Backups are encrypted to ALL listed recipients. Any one of the corresponding
|
||||||
# private keys can decrypt. Adding a recipient later does not re-encrypt data
|
# private keys can decrypt. Generate a keypair with:
|
||||||
# already stored: deduplicated chunks and existing blobs stay encrypted to the
|
|
||||||
# earlier recipients, so a newly added key cannot restore them on its own (see
|
|
||||||
# docs/REPOSTRUCTURE.md, Accepted Risks). Generate a keypair with:
|
|
||||||
# age-keygen -o vaultik_backup_private_key.txt
|
# age-keygen -o vaultik_backup_private_key.txt
|
||||||
# grep 'public key' vaultik_backup_private_key.txt
|
# grep 'public key' vaultik_backup_private_key.txt
|
||||||
age_recipients:
|
age_recipients:
|
||||||
@@ -196,8 +186,8 @@ storage_url: ""
|
|||||||
# access_key_id: YOUR_ACCESS_KEY
|
# access_key_id: YOUR_ACCESS_KEY
|
||||||
# secret_access_key: YOUR_SECRET_KEY
|
# secret_access_key: YOUR_SECRET_KEY
|
||||||
# # region: us-east-1 # Default: us-east-1
|
# # region: us-east-1 # Default: us-east-1
|
||||||
|
# # use_ssl: true # Default: true
|
||||||
# # part_size: 5MB # Multipart upload part size. Default: 5MB
|
# # part_size: 5MB # Multipart upload part size. Default: 5MB
|
||||||
# # For the s3:// form, disable TLS with ?ssl=false in the URL, not use_ssl.
|
|
||||||
|
|
||||||
# ─── OPTIONAL ────────────────────────────────────────────────────────────────
|
# ─── OPTIONAL ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -216,8 +206,6 @@ storage_url: ""
|
|||||||
# chunk_size: 10MB
|
# chunk_size: 10MB
|
||||||
|
|
||||||
# Maximum blob size before splitting into a new blob.
|
# Maximum blob size before splitting into a new blob.
|
||||||
# Must be at least four times chunk_size (the largest chunk the chunker can
|
|
||||||
# emit); a smaller limit would let a single-chunk blob exceed it.
|
|
||||||
# Accepts: 1GB, 10G, 500MB, etc.
|
# Accepts: 1GB, 10G, 500MB, etc.
|
||||||
# Default: 10GB
|
# Default: 10GB
|
||||||
# blob_size_limit: 10GB
|
# blob_size_limit: 10GB
|
||||||
@@ -265,7 +253,7 @@ The config is written to the path from --config, $VAULTIK_CONFIG, or
|
|||||||
the platform default config directory (e.g. ~/Library/Application Support/
|
the platform default config directory (e.g. ~/Library/Application Support/
|
||||||
on macOS, ~/.config/ on Linux, /etc/vaultik/ as root).`,
|
on macOS, ~/.config/ on Linux, /etc/vaultik/ as root).`,
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
RunE: func(_ *cobra.Command, _ []string) error {
|
||||||
path := configPathForInit()
|
path := configPathForInit()
|
||||||
|
|
||||||
_, err := os.Stat(path)
|
_, err := os.Stat(path)
|
||||||
@@ -285,11 +273,8 @@ on macOS, ~/.config/ on Linux, /etc/vaultik/ as root).`,
|
|||||||
return fmt.Errorf("writing config file: %w", err)
|
return fmt.Errorf("writing config file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A written-confirmation, not scriptable output: route it
|
_, _ = fmt.Fprintf(os.Stdout, "Config written to %s\n", path)
|
||||||
// through the UI so it is styled and --quiet silences it.
|
_, _ = fmt.Fprintln(os.Stdout,
|
||||||
out := commandUI(cmd)
|
|
||||||
out.Infof("Config written to %s.", path)
|
|
||||||
out.Infof(
|
|
||||||
"Edit it to set your age_recipients, snapshots, and storage_url.")
|
"Edit it to set your age_recipients, snapshots, and storage_url.")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -331,7 +316,7 @@ func newConfigGetCommand() *cobra.Command {
|
|||||||
Use: "get <key>",
|
Use: "get <key>",
|
||||||
Short: "Print a config value by dotted path (e.g. storage_url, compression_level)",
|
Short: "Print a config value by dotted path (e.g. storage_url, compression_level)",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(_ *cobra.Command, args []string) error {
|
||||||
path, err := ResolveConfigPath()
|
path, err := ResolveConfigPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -347,13 +332,8 @@ func newConfigGetCommand() *cobra.Command {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// The value is scriptable output: it must stay machine-plain
|
|
||||||
// (no marker, no color) and is never silenced by --quiet, so it
|
|
||||||
// is written straight to stdout rather than through the UI.
|
|
||||||
w := cmd.OutOrStdout()
|
|
||||||
|
|
||||||
if node.Kind == yaml.ScalarNode {
|
if node.Kind == yaml.ScalarNode {
|
||||||
_, _ = fmt.Fprintln(w, node.Value)
|
_, _ = fmt.Fprintln(os.Stdout, node.Value)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -363,7 +343,7 @@ func newConfigGetCommand() *cobra.Command {
|
|||||||
return fmt.Errorf("marshaling value: %w", err)
|
return fmt.Errorf("marshaling value: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _ = fmt.Fprint(w, string(out))
|
_, _ = fmt.Fprint(os.Stdout, string(out))
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@@ -385,82 +365,46 @@ Examples:
|
|||||||
vaultik config set compression_level 9
|
vaultik config set compression_level 9
|
||||||
vaultik config set s3.bucket mybucket # legacy S3 fields still supported`,
|
vaultik config set s3.bucket mybucket # legacy S3 fields still supported`,
|
||||||
Args: cobra.ExactArgs(configSetArgs),
|
Args: cobra.ExactArgs(configSetArgs),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(_ *cobra.Command, args []string) error {
|
||||||
path, err := ResolveConfigPath()
|
path, err := ResolveConfigPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return writeConfigSet(commandUI(cmd), path, args[0], args[1])
|
root, err := loadYAMLFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = yamlPathSet(root, strings.Split(args[0], "."), args[1])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := 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
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeConfigSet applies key=value to the config at path, writes it back
|
|
||||||
// owner-only, and confirms the write by naming just the key through the
|
|
||||||
// UI writer (styled, and silenced by --quiet). The value is never
|
|
||||||
// echoed: it may be a secret such as s3.secret_access_key, and captured
|
|
||||||
// stdout or a pasted terminal would then leak it.
|
|
||||||
func writeConfigSet(out *ui.Writer, path, key, value string) error {
|
|
||||||
root, err := loadYAMLFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = yamlPathSet(root, strings.Split(key, "."), value)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := marshalConfigYAML(root)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("marshaling config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = os.WriteFile(path, data, configFileMode)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("writing config file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// os.WriteFile does not change the mode of a file that already exists,
|
|
||||||
// so a config that was group- or world-readable stays that way. As it
|
|
||||||
// may hold S3 credentials, tighten it to owner-only after writing.
|
|
||||||
info, statErr := os.Stat(path)
|
|
||||||
if statErr == nil && info.Mode().Perm()&0o044 != 0 {
|
|
||||||
err = os.Chmod(path, configFileMode)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("tightening config file permissions: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
out.Infof("Set %s.", key)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// marshalConfigYAML renders a config document tree with 2-space indentation,
|
|
||||||
// matching defaultConfigTemplate. yaml.Marshal defaults to 4 spaces, which
|
|
||||||
// would reindent the whole file on the first `config set` despite the promise
|
|
||||||
// to preserve formatting.
|
|
||||||
func marshalConfigYAML(root *yaml.Node) ([]byte, error) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
|
|
||||||
enc := yaml.NewEncoder(&buf)
|
|
||||||
enc.SetIndent(configYAMLIndent)
|
|
||||||
|
|
||||||
err := enc.Encode(root)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = enc.Close()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return buf.Bytes(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadYAMLFile parses a YAML file into a yaml.Node document tree,
|
// loadYAMLFile parses a YAML file into a yaml.Node document tree,
|
||||||
// which preserves comments and ordering for round-tripping.
|
// which preserves comments and ordering for round-tripping.
|
||||||
func loadYAMLFile(path string) (*yaml.Node, error) {
|
func loadYAMLFile(path string) (*yaml.Node, error) {
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
package cli //nolint:testpackage // exercises unexported yamlPathGet/yamlPathSet
|
package cli //nolint:testpackage // exercises unexported yamlPathGet/yamlPathSet
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
"sneak.berlin/go/vaultik/internal/config"
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
"sneak.berlin/go/vaultik/internal/ui"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestDefaultConfigTemplateParses ensures the init template is valid YAML
|
// TestDefaultConfigTemplateParses ensures the init template is valid YAML
|
||||||
@@ -192,111 +188,6 @@ func TestYAMLPathSet(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestConfigSetPreservesFormatting asserts the `config set` write path
|
|
||||||
// (marshalConfigYAML) round-trips a 2-space-indented file without reindenting
|
|
||||||
// it to yaml.Marshal's 4-space default, and keeps comments.
|
|
||||||
func TestConfigSetPreservesFormatting(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
root := parseTestYAML(t)
|
|
||||||
|
|
||||||
err := yamlPathSet(root, splitPath("s3.bucket"), "newbucket")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("set s3.bucket: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
out, err := marshalConfigYAML(root)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
text := string(out)
|
|
||||||
|
|
||||||
for _, want := range []string{"# top comment", "# inline comment"} {
|
|
||||||
if !contains(text, want) {
|
|
||||||
t.Errorf("round-tripped YAML dropped comment %q:\n%s", want, text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nested map keys stay at 2-space indent; the bug reindented them to 4.
|
|
||||||
if !contains(text, "\n bucket: newbucket") {
|
|
||||||
t.Errorf("expected 2-space indent for s3.bucket, got:\n%s", text)
|
|
||||||
}
|
|
||||||
|
|
||||||
if contains(text, "\n bucket:") {
|
|
||||||
t.Errorf("s3.bucket reindented to 4 spaces:\n%s", text)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sequence items under a key also stay at 2 spaces.
|
|
||||||
if !contains(text, "\n - age1aaa") {
|
|
||||||
t.Errorf("expected 2-space indent for sequence item, got:\n%s", text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWriteConfigSetHidesSecret checks that setting a secret key prints
|
|
||||||
// only the key name, never the value, to the confirmation output.
|
|
||||||
func TestWriteConfigSetHidesSecret(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const secret = "SUPERSECRETVALUE"
|
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
|
||||||
|
|
||||||
err := os.WriteFile(path, []byte("version: 1\n"), 0o600)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("seed config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
|
|
||||||
err = writeConfigSet(ui.NewWithColor(&buf, false), path,
|
|
||||||
"s3.secret_access_key", secret)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("writeConfigSet: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(buf.String(), secret) {
|
|
||||||
t.Errorf("output echoed the secret value: %q", buf.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.Contains(buf.String(), "s3.secret_access_key") {
|
|
||||||
t.Errorf("output did not confirm the key name: %q", buf.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWriteConfigSetTightensMode checks that a pre-existing group- or
|
|
||||||
// world-readable config is tightened to owner-only after a set, since
|
|
||||||
// os.WriteFile leaves an existing file's mode untouched.
|
|
||||||
func TestWriteConfigSetTightensMode(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
|
||||||
|
|
||||||
// Seed a world-readable config; the loose mode is the condition under
|
|
||||||
// test, so gosec's G306 is expected here.
|
|
||||||
err := os.WriteFile(path, []byte("version: 1\n"), 0o644) //nolint:gosec // G306
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("seed config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
|
|
||||||
err = writeConfigSet(ui.NewWithColor(&buf, false), path,
|
|
||||||
"compression_level", "9")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("writeConfigSet: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := os.Stat(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("stat config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if info.Mode().Perm() != 0o600 {
|
|
||||||
t.Errorf("config mode = %04o, want 0600", info.Mode().Perm())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func splitPath(s string) []string {
|
func splitPath(s string) []string {
|
||||||
return strings.Split(s, ".")
|
return strings.Split(s, ".")
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-12
@@ -48,7 +48,7 @@ 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(cmd *cobra.Command, _ []string) error {
|
RunE: func(_ *cobra.Command, _ []string) error {
|
||||||
// Resolve config path
|
// Resolve config path
|
||||||
configPath, err := ResolveConfigPath()
|
configPath, err := ResolveConfigPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -62,31 +62,26 @@ Use --force to skip the confirmation prompt.`,
|
|||||||
}
|
}
|
||||||
|
|
||||||
dbPath := cfg.IndexPath
|
dbPath := cfg.IndexPath
|
||||||
out := commandUI(cmd)
|
|
||||||
|
|
||||||
// Check if database exists
|
// Check if database exists
|
||||||
_, err = os.Stat(dbPath)
|
_, err = os.Stat(dbPath)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
out.Infof("Local state database does not exist: %s.", dbPath)
|
_, _ = fmt.Fprintf(os.Stdout, "Database does not exist: %s\n", dbPath)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Confirm unless --force. The prompt and its immediate result
|
// Confirm unless --force
|
||||||
// are an interactive exchange the operator must see, so they go
|
|
||||||
// straight to stdout rather than through the UI and --quiet does
|
|
||||||
// not silence them.
|
|
||||||
if !force {
|
if !force {
|
||||||
w := cmd.OutOrStdout()
|
_, _ = fmt.Fprintf(os.Stdout,
|
||||||
_, _ = fmt.Fprintf(w,
|
|
||||||
"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.Fprint(w, "Are you sure? Type 'yes' to confirm: ")
|
_, _ = fmt.Fprint(os.Stdout, "Are you sure? Type 'yes' to confirm: ")
|
||||||
|
|
||||||
var confirm string
|
var confirm string
|
||||||
|
|
||||||
_, err = fmt.Scanln(&confirm)
|
_, err = fmt.Scanln(&confirm)
|
||||||
if err != nil || confirm != "yes" {
|
if err != nil || confirm != "yes" {
|
||||||
_, _ = fmt.Fprintln(w, "Aborted.")
|
_, _ = fmt.Fprintln(os.Stdout, "Aborted.")
|
||||||
|
|
||||||
//nolint:nilerr // a failed/aborted confirmation is a clean abort
|
//nolint:nilerr // a failed/aborted confirmation is a clean abort
|
||||||
return nil
|
return nil
|
||||||
@@ -105,7 +100,11 @@ Use --force to skip the confirmation prompt.`,
|
|||||||
_ = os.Remove(walPath) // Ignore errors - files may not exist
|
_ = os.Remove(walPath) // Ignore errors - files may not exist
|
||||||
_ = os.Remove(shmPath)
|
_ = os.Remove(shmPath)
|
||||||
|
|
||||||
out.Infof("Local state database deleted: %s.", dbPath)
|
rootFlags := GetRootFlags()
|
||||||
|
if !rootFlags.Quiet {
|
||||||
|
_, _ = fmt.Fprintf(os.Stdout, "Database deleted: %s\n", dbPath)
|
||||||
|
}
|
||||||
|
|
||||||
log.Info("Local state database deleted", "path", dbPath)
|
log.Info("Local state database deleted", "path", dbPath)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"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
|
||||||
|
// (e.g., "3h30m", "1h45m30s") as well as extended units:
|
||||||
|
// - d: days (e.g., "30d", "7d")
|
||||||
|
// - w: weeks (e.g., "2w", "4w")
|
||||||
|
// - mo: months (30 days) (e.g., "6mo", "1mo")
|
||||||
|
// - y: years (365 days) (e.g., "1y", "2y")
|
||||||
|
//
|
||||||
|
// Can combine units: "1y6mo", "2w3d", "1d12h30m"
|
||||||
|
func parseDuration(s string) (time.Duration, error) {
|
||||||
|
// First try standard Go duration parsing
|
||||||
|
d, err := time.ParseDuration(s)
|
||||||
|
if err == nil {
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extended duration parsing
|
||||||
|
// Check for negative values
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(s), "-") {
|
||||||
|
return 0, errNegativeDuration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern matches: number + unit, repeated
|
||||||
|
re := regexp.MustCompile(`(\d+(?:\.\d+)?)\s*([a-zA-Z]+)`)
|
||||||
|
matches := re.FindAllStringSubmatch(s, -1)
|
||||||
|
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return 0, fmt.Errorf("%w: %q", errInvalidDuration, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
var total time.Duration
|
||||||
|
|
||||||
|
for _, match := range matches {
|
||||||
|
valueStr := match[1]
|
||||||
|
unit := strings.ToLower(match[2])
|
||||||
|
|
||||||
|
value, err := strconv.ParseFloat(valueStr, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("invalid number %q: %w", valueStr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
d, err := durationForUnit(value, unit)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
total += d
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
package cli //nolint:testpackage // needs access to unexported parseDuration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type parseDurationCase struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected time.Duration
|
||||||
|
wantErr bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
input: "30s",
|
||||||
|
expected: 30 * time.Second,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "standard minutes",
|
||||||
|
input: "45m",
|
||||||
|
expected: 45 * time.Minute,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "standard hours",
|
||||||
|
input: "2h",
|
||||||
|
expected: 2 * time.Hour,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "standard combined",
|
||||||
|
input: "3h30m",
|
||||||
|
expected: 3*time.Hour + 30*time.Minute,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "standard complex",
|
||||||
|
input: "1h45m30s",
|
||||||
|
expected: 1*time.Hour + 45*time.Minute + 30*time.Second,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "standard with milliseconds",
|
||||||
|
input: "1s500ms",
|
||||||
|
expected: 1*time.Second + 500*time.Millisecond,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDurationExtendedUnits(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
runParseDurationCases(t, []parseDurationCase{
|
||||||
|
// Extended units - days
|
||||||
|
{
|
||||||
|
name: "single day",
|
||||||
|
input: "1d",
|
||||||
|
expected: 24 * time.Hour,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple days",
|
||||||
|
input: "7d",
|
||||||
|
expected: 7 * 24 * time.Hour,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fractional days",
|
||||||
|
input: "1.5d",
|
||||||
|
expected: 36 * time.Hour,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "days spelled out",
|
||||||
|
input: "3days",
|
||||||
|
expected: 3 * 24 * time.Hour,
|
||||||
|
},
|
||||||
|
// Extended units - weeks
|
||||||
|
{
|
||||||
|
name: "single week",
|
||||||
|
input: "1w",
|
||||||
|
expected: 7 * 24 * time.Hour,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple weeks",
|
||||||
|
input: "4w",
|
||||||
|
expected: 4 * 7 * 24 * time.Hour,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "weeks spelled out",
|
||||||
|
input: "2weeks",
|
||||||
|
expected: 2 * 7 * 24 * time.Hour,
|
||||||
|
},
|
||||||
|
// Extended units - months
|
||||||
|
{
|
||||||
|
name: "single month",
|
||||||
|
input: "1mo",
|
||||||
|
expected: 30 * 24 * time.Hour,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple months",
|
||||||
|
input: "6mo",
|
||||||
|
expected: 6 * 30 * 24 * time.Hour,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "months spelled out",
|
||||||
|
input: "3months",
|
||||||
|
expected: 3 * 30 * 24 * time.Hour,
|
||||||
|
},
|
||||||
|
// Extended units - years
|
||||||
|
{
|
||||||
|
name: "single year",
|
||||||
|
input: "1y",
|
||||||
|
expected: 365 * 24 * time.Hour,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple years",
|
||||||
|
input: "2y",
|
||||||
|
expected: 2 * 365 * 24 * time.Hour,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "years spelled out",
|
||||||
|
input: "1year",
|
||||||
|
expected: 365 * 24 * time.Hour,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDurationCombinedAndErrors(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
runParseDurationCases(t, []parseDurationCase{
|
||||||
|
// Combined extended units
|
||||||
|
{
|
||||||
|
name: "weeks and days",
|
||||||
|
input: "2w3d",
|
||||||
|
expected: 2*7*24*time.Hour + 3*24*time.Hour,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "years and months",
|
||||||
|
input: "1y6mo",
|
||||||
|
expected: 365*24*time.Hour + 6*30*24*time.Hour,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "days and hours",
|
||||||
|
input: "1d12h",
|
||||||
|
expected: 24*time.Hour + 12*time.Hour,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "complex combination",
|
||||||
|
input: "1y2mo3w4d5h6m7s",
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with spaces",
|
||||||
|
input: "1d 12h 30m",
|
||||||
|
expected: 24*time.Hour + 12*time.Hour + 30*time.Minute,
|
||||||
|
},
|
||||||
|
// Edge cases
|
||||||
|
{
|
||||||
|
name: "zero duration",
|
||||||
|
input: "0s",
|
||||||
|
expected: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "large duration",
|
||||||
|
input: "10y",
|
||||||
|
expected: 10 * 365 * 24 * time.Hour,
|
||||||
|
},
|
||||||
|
// Error cases
|
||||||
|
{
|
||||||
|
name: "empty string",
|
||||||
|
input: "",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid format",
|
||||||
|
input: "abc",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown unit",
|
||||||
|
input: "5x",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid number",
|
||||||
|
input: "xyzd",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "negative not supported",
|
||||||
|
input: "-5d",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDurationSpecialCases(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Test that standard Go durations work exactly as expected
|
||||||
|
standardDurations := []string{
|
||||||
|
"300ms",
|
||||||
|
"1.5h",
|
||||||
|
"2h45m",
|
||||||
|
"72h",
|
||||||
|
"1us",
|
||||||
|
"1µs",
|
||||||
|
"1ns",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, d := range standardDurations {
|
||||||
|
expected, err := time.ParseDuration(d)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := parseDuration(d)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, expected, got, "standard duration %q should parse identically", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDurationRealWorldExamples(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Test real-world snapshot purge scenarios
|
||||||
|
tests := []struct {
|
||||||
|
description string
|
||||||
|
input string
|
||||||
|
olderThan time.Duration
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
description: "keep snapshots from last 30 days",
|
||||||
|
input: "30d",
|
||||||
|
olderThan: 30 * 24 * time.Hour,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "keep snapshots from last 6 months",
|
||||||
|
input: "6mo",
|
||||||
|
olderThan: 6 * 30 * 24 * time.Hour,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "keep snapshots from last year",
|
||||||
|
input: "1y",
|
||||||
|
olderThan: 365 * 24 * time.Hour,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "keep snapshots from last week and a half",
|
||||||
|
input: "1w3d",
|
||||||
|
olderThan: 10 * 24 * time.Hour,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "keep snapshots from last 90 days",
|
||||||
|
input: "90d",
|
||||||
|
olderThan: 90 * 24 * time.Hour,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.description, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got, err := parseDuration(tt.input)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.olderThan, got)
|
||||||
|
|
||||||
|
// Verify the duration makes sense for snapshot purging
|
||||||
|
assert.Greater(t, got, time.Hour,
|
||||||
|
"snapshot purge duration should be at least an hour")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
-60
@@ -1,8 +1,6 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -16,53 +14,27 @@ import (
|
|||||||
const shortCommitLen = 12
|
const shortCommitLen = 12
|
||||||
|
|
||||||
// Entry is the main entry point for the CLI application.
|
// Entry is the main entry point for the CLI application.
|
||||||
// It prints the startup banner to stdout (unless a banner-suppressing
|
// It prints the startup banner (unless a quiet flag is present in os.Args),
|
||||||
// flag is present in os.Args — see bannerSuppressedInArgs), executes the
|
// executes the root cobra command, and routes any returned error through
|
||||||
// root cobra command, and routes any returned error through the
|
// the ui.Writer so the user sees a properly formatted "🛑 ERROR:" line.
|
||||||
// ui.Writer so the user sees a properly formatted "🛑 ERROR:" line.
|
func Entry() {
|
||||||
//
|
if !bannerSuppressedInArgs(os.Args[1:]) {
|
||||||
// It returns the process exit code (0 on success, 1 on error) rather
|
short := globals.Commit
|
||||||
// than calling os.Exit, so that main's deferred profile writers run
|
if len(short) > shortCommitLen {
|
||||||
// before the process ends. See run in cmd/vaultik/main.go.
|
short = short[:shortCommitLen]
|
||||||
func Entry() int {
|
}
|
||||||
emitStartupBanner(os.Args[1:], os.Stdout)
|
|
||||||
|
writeStartupBanner(ui.New(os.Stdout), time.Now().UTC(), short)
|
||||||
|
}
|
||||||
|
|
||||||
rootCmd := NewRootCommand()
|
rootCmd := NewRootCommand()
|
||||||
rootCmd.SilenceErrors = true
|
rootCmd.SilenceErrors = true
|
||||||
|
|
||||||
err := rootCmd.Execute()
|
err := rootCmd.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// An operation that ran inside the fx app has already reported
|
ReportErrorf("%s", err.Error())
|
||||||
// its own failure (and suppressed it under --json); errReported
|
os.Exit(1)
|
||||||
// says so. Printing it again here would double the error line.
|
|
||||||
// Every other error — bad arguments, a config that would not
|
|
||||||
// load — reaches Entry unreported, so it is shown here.
|
|
||||||
if !errors.Is(err, errReported) {
|
|
||||||
ReportErrorf("%s", err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
return 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// emitStartupBanner writes the startup banner to w unless args (the
|
|
||||||
// argument vector with the program name already stripped) contains a
|
|
||||||
// flag that suppresses it. Split out of Entry so that the decision — the
|
|
||||||
// only thing standing between a --json invocation and a parseable
|
|
||||||
// stdout — is reachable from a test without running the whole CLI.
|
|
||||||
func emitStartupBanner(args []string, w io.Writer) {
|
|
||||||
if bannerSuppressedInArgs(args) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
short := globals.Commit
|
|
||||||
if len(short) > shortCommitLen {
|
|
||||||
short = short[:shortCommitLen]
|
|
||||||
}
|
|
||||||
|
|
||||||
writeStartupBanner(ui.New(w), time.Now().UTC(), short)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReportErrorf emits a user-facing error to stderr in the standard
|
// ReportErrorf emits a user-facing error to stderr in the standard
|
||||||
@@ -74,20 +46,9 @@ func ReportErrorf(format string, args ...any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// bannerSuppressedInArgs reports whether any of args is a flag that
|
// bannerSuppressedInArgs reports whether any of args is a flag that
|
||||||
// should suppress the startup banner (--quiet/-q/--cron/--json). Stops
|
// should suppress the startup banner (--quiet/-q/--cron). Stops at the
|
||||||
// at the "--" argument terminator. Recognizes both long forms and short
|
// "--" argument terminator. Recognizes both long forms and short -q,
|
||||||
// -q, including combined short flags like "-qv".
|
// including combined short flags like "-qv".
|
||||||
//
|
|
||||||
// This scans the raw argument vector because the banner is printed
|
|
||||||
// before cobra parses anything — deliberately, so that it still appears
|
|
||||||
// when cobra rejects the arguments and on --help. The consequence is
|
|
||||||
// that a flag is matched wherever it occurs in the vector, including
|
|
||||||
// positions where the command it belongs to would not accept it.
|
|
||||||
// --json is a subcommand flag rather than a persistent one, but so is
|
|
||||||
// --cron (it exists only on `snapshot create`), so this adds no new
|
|
||||||
// class of imprecision. The only cost of a false positive is a missing
|
|
||||||
// decorative banner; the cost of a false negative is a corrupt document
|
|
||||||
// on stdout, so the scan errs deliberately in that direction.
|
|
||||||
func bannerSuppressedInArgs(args []string) bool {
|
func bannerSuppressedInArgs(args []string) bool {
|
||||||
for _, a := range args {
|
for _, a := range args {
|
||||||
if a == "--" {
|
if a == "--" {
|
||||||
@@ -95,13 +56,11 @@ func bannerSuppressedInArgs(args []string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch a {
|
switch a {
|
||||||
case "--quiet", "-q", "--cron", "--json":
|
case "--quiet", "-q", "--cron":
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(a, "--quiet=") ||
|
if strings.HasPrefix(a, "--quiet=") || strings.HasPrefix(a, "--cron=") {
|
||||||
strings.HasPrefix(a, "--cron=") ||
|
|
||||||
strings.HasPrefix(a, "--json=") {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// Combined short flags like -qv or -vq.
|
// Combined short flags like -qv or -vq.
|
||||||
|
|||||||
@@ -1,300 +0,0 @@
|
|||||||
package cli //nolint:testpackage // needs access to unexported emitStartupBanner
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/adrg/xdg"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Command words and flags used to build argument vectors below. They are
|
|
||||||
// constants rather than repeated literals so that a rename shows up as a
|
|
||||||
// compile error in one place.
|
|
||||||
const (
|
|
||||||
cmdSnapshot = "snapshot"
|
|
||||||
cmdList = "list"
|
|
||||||
cmdCreate = "create"
|
|
||||||
cmdVerify = "verify"
|
|
||||||
cmdRemove = "remove"
|
|
||||||
cmdPrune = "prune"
|
|
||||||
cmdRemote = "remote"
|
|
||||||
cmdInfo = "info"
|
|
||||||
|
|
||||||
flagJSON = "--json"
|
|
||||||
flagQuiet = "--quiet"
|
|
||||||
flagConfig = "--config"
|
|
||||||
|
|
||||||
// programName is argv[0] as the real process receives it. Entry
|
|
||||||
// strips it before scanning, so it has to be present.
|
|
||||||
programName = "vaultik"
|
|
||||||
|
|
||||||
// someSnapshotID is any snapshot identifier: these tests never run
|
|
||||||
// the command, so it only has to occupy the positional argument.
|
|
||||||
someSnapshotID = "host_2026-01-01T00:00:00Z"
|
|
||||||
)
|
|
||||||
|
|
||||||
// placeholderJSONDocument stands in for whatever document a --json
|
|
||||||
// command writes to stdout. `snapshot list --json` with no snapshots
|
|
||||||
// prints exactly this; the other --json commands print an object rather
|
|
||||||
// than an array, but this test is not about their shape. It is about
|
|
||||||
// what is on stdout *before* them, which is the same for all of them
|
|
||||||
// because Entry prints the banner before cobra has parsed anything and
|
|
||||||
// therefore before it can know which command is running.
|
|
||||||
const placeholderJSONDocument = "[]\n"
|
|
||||||
|
|
||||||
// jsonArgumentVectors are the argument vectors of every --json
|
|
||||||
// invocation the CLI accepts, with the program name stripped exactly as
|
|
||||||
// Entry strips it. Each one must leave stdout untouched by the banner.
|
|
||||||
//
|
|
||||||
//nolint:gochecknoglobals // read-only test fixture shared by two tests
|
|
||||||
var jsonArgumentVectors = map[string][]string{
|
|
||||||
"snapshot list": {cmdSnapshot, cmdList, flagJSON},
|
|
||||||
"snapshot verify": {cmdSnapshot, cmdVerify, someSnapshotID, flagJSON},
|
|
||||||
"snapshot remove": {cmdSnapshot, cmdRemove, someSnapshotID, flagJSON},
|
|
||||||
"prune": {cmdPrune, flagJSON},
|
|
||||||
"remote info": {cmdRemote, cmdInfo, flagJSON},
|
|
||||||
|
|
||||||
// --json before the subcommand, and with an explicit value: the
|
|
||||||
// scan is positional, so both forms have to be recognized.
|
|
||||||
"json first": {flagJSON, cmdSnapshot, cmdList},
|
|
||||||
"json with value": {cmdSnapshot, cmdList, flagJSON + "=true"},
|
|
||||||
|
|
||||||
// A --json invocation that also carries a flag with a value, so the
|
|
||||||
// scan cannot be fooled by an argument that consumes the next one.
|
|
||||||
"json with config": {
|
|
||||||
flagConfig, "/nonexistent/vaultik.yml", cmdSnapshot, cmdList, flagJSON,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestJSONInvocationStdoutIsExactlyOneDocument is the CLI-layer
|
|
||||||
// regression guard for issue #106: `vaultik snapshot list --json | jq`
|
|
||||||
// must work with no other flags.
|
|
||||||
//
|
|
||||||
// internal/vaultik's TestListSnapshots_JSONStdoutIsOnlyTheDocument
|
|
||||||
// guards the same contract one layer down, but it calls the library
|
|
||||||
// function directly and so cannot see Entry, which is where the
|
|
||||||
// contamination was: the startup banner is written to stdout before
|
|
||||||
// cobra parses anything, and the suppression scan did not know about
|
|
||||||
// --json. The two banner lines and the blank line landed ahead of the
|
|
||||||
// document and `jq` refused the result.
|
|
||||||
//
|
|
||||||
// The document is a constant here because this test is about the
|
|
||||||
// argument vectors, one per --json command; the one that runs a real
|
|
||||||
// command end to end is TestEntryJSONStdoutIsExactlyOneDocument below.
|
|
||||||
func TestJSONInvocationStdoutIsExactlyOneDocument(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for name, argv := range jsonArgumentVectors {
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var stdout bytes.Buffer
|
|
||||||
|
|
||||||
emitStartupBanner(argv, &stdout)
|
|
||||||
|
|
||||||
require.Empty(t, stdout.String(),
|
|
||||||
"nothing may reach stdout ahead of a --json document")
|
|
||||||
|
|
||||||
_, err := stdout.WriteString(placeholderJSONDocument)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
requireExactlyOneJSONDocument(t, stdout.String())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBannerStillPrintedWithoutSuppressingFlag pins the other half of
|
|
||||||
// the contract. Without it, deleting the banner outright would satisfy
|
|
||||||
// the test above, and the banner is wanted on interactive invocations.
|
|
||||||
func TestBannerStillPrintedWithoutSuppressingFlag(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for name, argv := range map[string][]string{
|
|
||||||
"no flags": {cmdSnapshot, cmdList},
|
|
||||||
"verbose": {cmdSnapshot, cmdList, "--verbose"},
|
|
||||||
"after the terminator": {
|
|
||||||
cmdSnapshot, "restore", "--", flagJSON,
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var stdout bytes.Buffer
|
|
||||||
|
|
||||||
emitStartupBanner(argv, &stdout)
|
|
||||||
|
|
||||||
assert.Contains(t, stdout.String(), "starting up at",
|
|
||||||
"the banner belongs on invocations that did not opt out")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBannerSuppressedInArgs covers the suppression scan directly,
|
|
||||||
// including the flags that suppressed the banner before --json joined
|
|
||||||
// them, so that adding --json cannot regress them.
|
|
||||||
func TestBannerSuppressedInArgs(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for name, testCase := range map[string]struct {
|
|
||||||
args []string
|
|
||||||
suppressed bool
|
|
||||||
}{
|
|
||||||
"quiet long": {[]string{cmdSnapshot, cmdCreate, flagQuiet}, true},
|
|
||||||
"quiet short": {[]string{cmdSnapshot, cmdCreate, "-q"}, true},
|
|
||||||
"quiet combined": {[]string{cmdSnapshot, cmdCreate, "-qv"}, true},
|
|
||||||
"cron": {[]string{cmdSnapshot, cmdCreate, "--cron"}, true},
|
|
||||||
"json": {[]string{cmdSnapshot, cmdList, flagJSON}, true},
|
|
||||||
"nothing": {[]string{cmdSnapshot, cmdList}, false},
|
|
||||||
"empty": {nil, false},
|
|
||||||
"json after dashes": {
|
|
||||||
[]string{cmdSnapshot, cmdList, "--", flagJSON}, false,
|
|
||||||
},
|
|
||||||
"quiet after dashes": {
|
|
||||||
[]string{cmdSnapshot, cmdCreate, "--", "-q"}, false,
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
assert.Equal(t, testCase.suppressed,
|
|
||||||
bannerSuppressedInArgs(testCase.args))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// hermeticConfig is a complete, valid config that needs no network and
|
|
||||||
// no credentials: file:// storage is exempt from the S3 credential
|
|
||||||
// checks, and FileStorer over a directory that does not exist lists
|
|
||||||
// zero objects without erroring. Chunk, blob and compression settings
|
|
||||||
// are filled in by config.Load.
|
|
||||||
const hermeticConfig = `age_recipients:
|
|
||||||
- age1278m9q7dp3chsh2dcy82qk27v047zywyvtxwnj4cvt0z65jw6a7q5dqhfj
|
|
||||||
snapshots:
|
|
||||||
test:
|
|
||||||
paths:
|
|
||||||
- %s
|
|
||||||
storage_url: file://%s
|
|
||||||
index_path: %s
|
|
||||||
hostname: test-host
|
|
||||||
`
|
|
||||||
|
|
||||||
// TestEntryJSONStdoutIsExactlyOneDocument runs the real thing: Entry,
|
|
||||||
// with a real argument vector, over the process's real stdout file
|
|
||||||
// descriptor, all the way through cobra and the fx graph to the
|
|
||||||
// document. It is the assertion the issue asks for — `vaultik snapshot
|
|
||||||
// list --json | jq .` with no other flags — with the pipe replaced by a
|
|
||||||
// decoder.
|
|
||||||
//
|
|
||||||
// `snapshot list` is the command chosen because it is the only --json
|
|
||||||
// command that reaches its document without a populated destination
|
|
||||||
// store: it reads the local index, streams `metadata/` (empty here),
|
|
||||||
// and treats a barren destination as an empty list rather than a
|
|
||||||
// failure.
|
|
||||||
//
|
|
||||||
// Not parallel: it replaces os.Args, os.Stdout and the xdg globals.
|
|
||||||
func TestEntryJSONStdoutIsExactlyOneDocument(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
configPath := filepath.Join(dir, "config.yml")
|
|
||||||
|
|
||||||
contents := fmt.Sprintf(hermeticConfig,
|
|
||||||
filepath.Join(dir, "source"),
|
|
||||||
filepath.Join(dir, "store"),
|
|
||||||
filepath.Join(dir, "index.sqlite"))
|
|
||||||
|
|
||||||
require.NoError(t,
|
|
||||||
os.WriteFile(configPath, []byte(contents), configFileMode))
|
|
||||||
|
|
||||||
// The PID lock lives under xdg.DataHome, which xdg resolves at
|
|
||||||
// package init; point it at the temp dir so the test neither
|
|
||||||
// touches nor collides with the real one.
|
|
||||||
t.Setenv("XDG_DATA_HOME", filepath.Join(dir, "data"))
|
|
||||||
xdg.Reload()
|
|
||||||
t.Cleanup(xdg.Reload)
|
|
||||||
|
|
||||||
previousArgs := os.Args
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
os.Args = previousArgs
|
|
||||||
rootFlags = RootFlags{}
|
|
||||||
})
|
|
||||||
|
|
||||||
os.Args = []string{
|
|
||||||
programName, flagConfig, configPath, cmdSnapshot, cmdList, flagJSON,
|
|
||||||
}
|
|
||||||
|
|
||||||
stdout := captureProcessStdout(t, func() { _ = Entry() })
|
|
||||||
|
|
||||||
requireExactlyOneJSONDocument(t, stdout)
|
|
||||||
|
|
||||||
var snapshots []any
|
|
||||||
|
|
||||||
require.NoError(t, json.Unmarshal([]byte(stdout), &snapshots))
|
|
||||||
assert.Empty(t, snapshots,
|
|
||||||
"a destination store with no snapshots lists none")
|
|
||||||
}
|
|
||||||
|
|
||||||
// captureProcessStdout redirects the process's own stdout to a pipe for
|
|
||||||
// the duration of fn and returns what was written to it. The redirection
|
|
||||||
// has to be at the file-descriptor level rather than through an injected
|
|
||||||
// writer, because the banner and the JSON encoder reach os.Stdout
|
|
||||||
// independently and the point of the test is that both land in the same
|
|
||||||
// place.
|
|
||||||
//
|
|
||||||
// Not parallel-safe: os.Stdout is process-global.
|
|
||||||
func captureProcessStdout(t *testing.T, fn func()) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
reader, writer, err := os.Pipe()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
previous := os.Stdout
|
|
||||||
os.Stdout = writer
|
|
||||||
|
|
||||||
captured := make(chan string, 1)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
|
|
||||||
_, _ = io.Copy(&buf, reader)
|
|
||||||
captured <- buf.String()
|
|
||||||
}()
|
|
||||||
|
|
||||||
fn()
|
|
||||||
|
|
||||||
os.Stdout = previous
|
|
||||||
|
|
||||||
require.NoError(t, writer.Close())
|
|
||||||
|
|
||||||
out := <-captured
|
|
||||||
|
|
||||||
require.NoError(t, reader.Close())
|
|
||||||
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// requireExactlyOneJSONDocument fails unless stdout decodes as a single
|
|
||||||
// JSON value with nothing before or after it — the property that makes
|
|
||||||
// `| jq` work.
|
|
||||||
func requireExactlyOneJSONDocument(t *testing.T, stdout string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
decoder := json.NewDecoder(strings.NewReader(stdout))
|
|
||||||
|
|
||||||
var document any
|
|
||||||
|
|
||||||
err := decoder.Decode(&document)
|
|
||||||
require.NoError(t, err,
|
|
||||||
"stdout must parse as JSON, got:\n%s", stdout)
|
|
||||||
|
|
||||||
_, err = decoder.Token()
|
|
||||||
require.ErrorIs(t, err, io.EOF,
|
|
||||||
"stdout must hold exactly one JSON document, got:\n%s", stdout)
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
package cli //nolint:testpackage // shares the prune fixtures and capture helpers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// staleRecordLogMessage is the local-cleanup audit line CleanupLocalSnapshots
|
|
||||||
// logs for each stale record. It is exactly the signal issue #112 says a
|
|
||||||
// machine consumer lost under --json: gated off stdout, and pinned below
|
|
||||||
// the log level on stderr because --json used to force Quiet.
|
|
||||||
const staleRecordLogMessage = "Removing stale local snapshot record"
|
|
||||||
|
|
||||||
// TestEntryPruneJSONStderrHonoursVerbosity is the end-to-end regression
|
|
||||||
// guard for issue #112. Under --json the log level must still follow
|
|
||||||
// --verbose/--debug rather than being pinned to WARN, so the
|
|
||||||
// local-cleanup records reach stderr under --verbose while stdout stays
|
|
||||||
// exactly one JSON document; without --verbose they stay below the
|
|
||||||
// level, as they do without --json.
|
|
||||||
//
|
|
||||||
// Both halves are asserted together on the same run, because the fix has
|
|
||||||
// to keep the document clean (issue #108) while freeing stderr.
|
|
||||||
//
|
|
||||||
// Not parallel: it replaces os.Args, os.Stdout, os.Stderr and the xdg
|
|
||||||
// globals.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
|
||||||
func TestEntryPruneJSONStderrHonoursVerbosity(t *testing.T) {
|
|
||||||
for _, testCase := range []struct {
|
|
||||||
name string
|
|
||||||
verbose bool
|
|
||||||
wantOnStderr bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "verbose json surfaces the cleanup record on stderr",
|
|
||||||
verbose: true,
|
|
||||||
wantOnStderr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "json alone keeps the cleanup record below the level",
|
|
||||||
verbose: false,
|
|
||||||
wantOnStderr: false,
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
t.Run(testCase.name, func(t *testing.T) {
|
|
||||||
configPath := writeHermeticPruneConfig(t, true)
|
|
||||||
|
|
||||||
previousArgs := os.Args
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
os.Args = previousArgs
|
|
||||||
rootFlags = RootFlags{}
|
|
||||||
})
|
|
||||||
|
|
||||||
args := []string{
|
|
||||||
programName, flagConfig, configPath, cmdPrune, flagJSON,
|
|
||||||
}
|
|
||||||
if testCase.verbose {
|
|
||||||
args = append(args, "--verbose")
|
|
||||||
}
|
|
||||||
|
|
||||||
os.Args = args
|
|
||||||
|
|
||||||
stdout, stderr := captureProcessStdoutAndStderr(t,
|
|
||||||
func() { _ = Entry() })
|
|
||||||
|
|
||||||
// The document stays clean in both cases: freeing stderr must
|
|
||||||
// not regress issue #108.
|
|
||||||
requireExactlyOneJSONDocument(t, stdout)
|
|
||||||
|
|
||||||
if testCase.wantOnStderr {
|
|
||||||
assert.Contains(t, stderr, staleRecordLogMessage,
|
|
||||||
"--verbose --json must emit the cleanup record on stderr")
|
|
||||||
assert.Contains(t, stderr, stalePruneSnapshotID,
|
|
||||||
"the record must name the snapshot it removed")
|
|
||||||
} else {
|
|
||||||
assert.NotContains(t, stderr, staleRecordLogMessage,
|
|
||||||
"without --verbose the record stays below the log level")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// captureProcessStdoutAndStderr redirects both of the process's own
|
|
||||||
// standard streams to pipes for the duration of fn and returns what was
|
|
||||||
// written to each. The redirection is at the file-descriptor level
|
|
||||||
// because the logger binds os.Stderr when it initializes inside fn, and
|
|
||||||
// the JSON document reaches os.Stdout independently; the point is to see
|
|
||||||
// where each actually lands.
|
|
||||||
//
|
|
||||||
// Not parallel-safe: os.Stdout and os.Stderr are process-global.
|
|
||||||
func captureProcessStdoutAndStderr(t *testing.T, fn func()) (string, string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
outReader, outWriter, err := os.Pipe()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
errReader, errWriter, err := os.Pipe()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
previousOut, previousErr := os.Stdout, os.Stderr
|
|
||||||
os.Stdout, os.Stderr = outWriter, errWriter
|
|
||||||
|
|
||||||
capturedOut := drain(outReader)
|
|
||||||
capturedErr := drain(errReader)
|
|
||||||
|
|
||||||
fn()
|
|
||||||
|
|
||||||
os.Stdout, os.Stderr = previousOut, previousErr
|
|
||||||
|
|
||||||
require.NoError(t, outWriter.Close())
|
|
||||||
require.NoError(t, errWriter.Close())
|
|
||||||
|
|
||||||
out, errOut := <-capturedOut, <-capturedErr
|
|
||||||
|
|
||||||
require.NoError(t, outReader.Close())
|
|
||||||
require.NoError(t, errReader.Close())
|
|
||||||
|
|
||||||
return out, errOut
|
|
||||||
}
|
|
||||||
|
|
||||||
// drain copies a reader to a string on a goroutine and delivers the
|
|
||||||
// result once the writer end is closed.
|
|
||||||
func drain(reader io.Reader) <-chan string {
|
|
||||||
captured := make(chan string, 1)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
|
|
||||||
_, _ = io.Copy(&buf, reader)
|
|
||||||
captured <- buf.String()
|
|
||||||
}()
|
|
||||||
|
|
||||||
return captured
|
|
||||||
}
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
package cli //nolint:testpackage // shares hermeticConfig and the capture helpers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/adrg/xdg"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/vaultik/internal/database"
|
|
||||||
"sneak.berlin/go/vaultik/internal/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
// pruneJSONDocument is the shape `prune --json` writes: the
|
|
||||||
// PruneBlobsResult document, and nothing else.
|
|
||||||
//
|
|
||||||
//nolint:tagliatelle // snake_case is the established JSON output format
|
|
||||||
type pruneJSONDocument struct {
|
|
||||||
BlobsFound int `json:"blobs_found"`
|
|
||||||
BlobsDeleted int `json:"blobs_deleted"`
|
|
||||||
BytesFreed int64 `json:"bytes_freed"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// stalePruneSnapshotID is seeded into the local index with no manifest
|
|
||||||
// on the destination store, which is exactly what makes it stale.
|
|
||||||
const stalePruneSnapshotID = "test-host_test_2026-04-01T09:00:00Z"
|
|
||||||
|
|
||||||
// TestEntryPruneJSONStdoutIsExactlyOneDocument is the end-to-end
|
|
||||||
// regression guard for issue #108: `vaultik prune --json | jq .` must
|
|
||||||
// work with no other flags.
|
|
||||||
//
|
|
||||||
// It runs Entry over the process's real stdout descriptor, through
|
|
||||||
// cobra and the fx graph, against a hermetic file:// destination store
|
|
||||||
// — the same construction TestEntryJSONStdoutIsExactlyOneDocument uses
|
|
||||||
// for `snapshot list`, with the pipe to jq replaced by a decoder.
|
|
||||||
//
|
|
||||||
// Both branches of the local-snapshot reconciliation are exercised
|
|
||||||
// because the three stdout writes that broke this covered all of them:
|
|
||||||
// one line per stale record and a summary when there were any, and a
|
|
||||||
// "No stale local snapshots found." line when there were none. No input
|
|
||||||
// avoided the contamination, so no single branch demonstrates the fix.
|
|
||||||
//
|
|
||||||
// Not parallel: it replaces os.Args, os.Stdout and the xdg globals.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // replaces os.Args, os.Stdout and the xdg globals
|
|
||||||
func TestEntryPruneJSONStdoutIsExactlyOneDocument(t *testing.T) {
|
|
||||||
for _, testCase := range []struct {
|
|
||||||
name string
|
|
||||||
seedStale bool
|
|
||||||
description string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "no stale local records",
|
|
||||||
seedStale: false,
|
|
||||||
description: "the empty-index branch used to print a 'No stale' line",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "stale local records present",
|
|
||||||
seedStale: true,
|
|
||||||
description: "the removal branch used to print a line per record " +
|
|
||||||
"plus a summary",
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
t.Run(testCase.name, func(t *testing.T) {
|
|
||||||
configPath := writeHermeticPruneConfig(t, testCase.seedStale)
|
|
||||||
|
|
||||||
previousArgs := os.Args
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
os.Args = previousArgs
|
|
||||||
rootFlags = RootFlags{}
|
|
||||||
})
|
|
||||||
|
|
||||||
os.Args = []string{
|
|
||||||
programName, flagConfig, configPath, cmdPrune, flagJSON,
|
|
||||||
}
|
|
||||||
|
|
||||||
stdout := captureProcessStdout(t, func() { _ = Entry() })
|
|
||||||
|
|
||||||
requireExactlyOneJSONDocument(t, stdout)
|
|
||||||
|
|
||||||
var document pruneJSONDocument
|
|
||||||
|
|
||||||
require.NoError(t, json.Unmarshal([]byte(stdout), &document),
|
|
||||||
testCase.description)
|
|
||||||
|
|
||||||
// A destination store with no blobs has none to prune. The
|
|
||||||
// assertion that matters is the one above; this one keeps the
|
|
||||||
// test honest about which document it decoded.
|
|
||||||
assert.Equal(t, 0, document.BlobsFound)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeHermeticPruneConfig builds a config over a temp directory and, if
|
|
||||||
// seedStale is set, creates the index database up front with one
|
|
||||||
// snapshot record that has no counterpart on the destination store.
|
|
||||||
// Returns the config path.
|
|
||||||
func writeHermeticPruneConfig(t *testing.T, seedStale bool) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
configPath := filepath.Join(dir, "config.yml")
|
|
||||||
indexPath := filepath.Join(dir, "index.sqlite")
|
|
||||||
|
|
||||||
contents := fmt.Sprintf(hermeticConfig,
|
|
||||||
filepath.Join(dir, "source"),
|
|
||||||
filepath.Join(dir, "store"),
|
|
||||||
indexPath)
|
|
||||||
|
|
||||||
require.NoError(t,
|
|
||||||
os.WriteFile(configPath, []byte(contents), configFileMode))
|
|
||||||
|
|
||||||
// The PID lock lives under xdg.DataHome, which xdg resolves at
|
|
||||||
// package init; point it at the temp dir so the test neither
|
|
||||||
// touches nor collides with the real one.
|
|
||||||
t.Setenv("XDG_DATA_HOME", filepath.Join(dir, "data"))
|
|
||||||
xdg.Reload()
|
|
||||||
t.Cleanup(xdg.Reload)
|
|
||||||
|
|
||||||
if seedStale {
|
|
||||||
seedStaleSnapshotRecord(t, indexPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
return configPath
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedStaleSnapshotRecord creates the index database at path and
|
|
||||||
// inserts one completed snapshot into it. Nothing is written to the
|
|
||||||
// destination store, so `prune` finds the record stale and removes it —
|
|
||||||
// the branch that printed a line per record.
|
|
||||||
func seedStaleSnapshotRecord(t *testing.T, path string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
db, err := database.New(ctx, path)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
defer func() { require.NoError(t, db.Close()) }()
|
|
||||||
|
|
||||||
startedAt := time.Date(2026, 4, 1, 9, 0, 0, 0, time.UTC)
|
|
||||||
completedAt := startedAt.Add(time.Minute)
|
|
||||||
|
|
||||||
snap := &database.Snapshot{
|
|
||||||
ID: types.SnapshotID(stalePruneSnapshotID),
|
|
||||||
Hostname: "test-host",
|
|
||||||
VaultikVersion: "test",
|
|
||||||
StartedAt: startedAt,
|
|
||||||
CompletedAt: &completedAt,
|
|
||||||
}
|
|
||||||
|
|
||||||
repos := database.NewRepositories(db)
|
|
||||||
|
|
||||||
err = repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
|
||||||
return repos.Snapshots.Create(ctx, tx, snap)
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
package cli //nolint:testpackage // shares programName and the capture helpers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestEntryReturnsStatusCode pins the contract main() relies on for
|
|
||||||
// issue #75: Entry reports success or failure through its return value
|
|
||||||
// and never calls os.Exit. An os.Exit from inside Entry would skip
|
|
||||||
// main's deferred profile writers and truncate the profile of a failing
|
|
||||||
// command. main turns this code into os.Exit only after those defers
|
|
||||||
// run, so a failing command must come back with a non-zero code rather
|
|
||||||
// than ending the process here.
|
|
||||||
//
|
|
||||||
// Stdout is captured only to keep the banner and command output off the
|
|
||||||
// test log; the assertion is on the returned code.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // replaces os.Args and rootFlags
|
|
||||||
func TestEntryReturnsStatusCode(t *testing.T) {
|
|
||||||
for _, testCase := range []struct {
|
|
||||||
name string
|
|
||||||
args []string
|
|
||||||
want int
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
// version is self-contained: it needs no config and no
|
|
||||||
// destination store, so it exercises the success path.
|
|
||||||
name: "successful command returns zero",
|
|
||||||
args: []string{programName, "version"},
|
|
||||||
want: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "unknown command returns one",
|
|
||||||
args: []string{programName, "no-such-command"},
|
|
||||||
want: 1,
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
t.Run(testCase.name, func(t *testing.T) {
|
|
||||||
previousArgs := os.Args
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
os.Args = previousArgs
|
|
||||||
rootFlags = RootFlags{}
|
|
||||||
})
|
|
||||||
|
|
||||||
os.Args = testCase.args
|
|
||||||
|
|
||||||
var code int
|
|
||||||
|
|
||||||
_ = captureProcessStdout(t, func() { code = Entry() })
|
|
||||||
|
|
||||||
assert.Equal(t, testCase.want, code)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+37
-7
@@ -1,7 +1,12 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"go.uber.org/fx"
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
)
|
)
|
||||||
@@ -28,19 +33,44 @@ func NewInfoCommand() *cobra.Command {
|
|||||||
// Use the app framework
|
// Use the app framework
|
||||||
rootFlags := GetRootFlags()
|
rootFlags := GetRootFlags()
|
||||||
|
|
||||||
return RunOperation(cmd.Context(), AppOptions{
|
return RunWithApp(cmd.Context(), AppOptions{
|
||||||
ConfigPath: configPath,
|
ConfigPath: configPath,
|
||||||
LogOptions: log.Options{
|
LogOptions: log.Options{
|
||||||
Verbose: rootFlags.Verbose,
|
Verbose: rootFlags.Verbose,
|
||||||
Debug: rootFlags.Debug,
|
Debug: rootFlags.Debug,
|
||||||
Quiet: rootFlags.Quiet,
|
Quiet: rootFlags.Quiet,
|
||||||
},
|
},
|
||||||
Mode: readOnly,
|
Modules: []fx.Option{},
|
||||||
}, func(v *vaultik.Vaultik) error {
|
Invokes: []fx.Option{
|
||||||
return v.ShowInfo()
|
fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) {
|
||||||
}, func(err error) {
|
lc.Append(fx.Hook{
|
||||||
log.Error("Failed to show info", "error", err)
|
OnStart: func(_ context.Context) error {
|
||||||
ReportErrorf("Failed to show info: %v", err)
|
go func() {
|
||||||
|
err := v.ShowInfo()
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
log.Error("Failed to show info", "error", err)
|
||||||
|
ReportErrorf("Failed to show info: %v", 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
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+44
-12
@@ -1,7 +1,12 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"go.uber.org/fx"
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
)
|
)
|
||||||
@@ -36,24 +41,51 @@ 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 RunOperation(cmd.Context(), AppOptions{
|
return RunWithApp(cmd.Context(), AppOptions{
|
||||||
ConfigPath: configPath,
|
ConfigPath: configPath,
|
||||||
LogOptions: log.Options{
|
LogOptions: log.Options{
|
||||||
Verbose: rootFlags.Verbose,
|
Verbose: rootFlags.Verbose,
|
||||||
Debug: rootFlags.Debug,
|
Debug: rootFlags.Debug,
|
||||||
Quiet: rootFlags.Quiet,
|
Quiet: rootFlags.Quiet || opts.JSON,
|
||||||
JSON: opts.JSON,
|
|
||||||
},
|
},
|
||||||
Mode: mutating,
|
Modules: []fx.Option{},
|
||||||
}, func(v *vaultik.Vaultik) error {
|
Invokes: []fx.Option{
|
||||||
return v.Prune(opts)
|
fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) {
|
||||||
}, func(err error) {
|
lc.Append(fx.Hook{
|
||||||
if opts.JSON {
|
OnStart: func(_ context.Context) error {
|
||||||
return
|
// Start the prune operation in a goroutine
|
||||||
}
|
go func() {
|
||||||
|
// Run the prune operation
|
||||||
|
err := v.Prune(opts)
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
if !opts.JSON {
|
||||||
|
log.Error("Prune operation failed", "error", err)
|
||||||
|
ReportErrorf("Prune failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
log.Error("Prune operation failed", "error", err)
|
os.Exit(1)
|
||||||
ReportErrorf("Prune failed: %v", err)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shutdown the app when prune completes
|
||||||
|
err = v.Shutdowner.Shutdown()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to shutdown", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
OnStop: func(_ context.Context) error {
|
||||||
|
log.Debug("Stopping prune operation")
|
||||||
|
v.Cancel()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,206 +0,0 @@
|
|||||||
package cli //nolint:testpackage // sets the unexported rootFlags directly
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
// setRootFlags overrides the global rootFlags for the duration of one
|
|
||||||
// test and restores it afterward. These tests must not run in parallel:
|
|
||||||
// the flags are process-global, so the whole struct is saved and put
|
|
||||||
// back rather than left mutated for the next test.
|
|
||||||
func setRootFlags(t *testing.T, f RootFlags) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
old := rootFlags
|
|
||||||
rootFlags = f
|
|
||||||
|
|
||||||
t.Cleanup(func() { rootFlags = old })
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedFile writes content to a fresh file and returns its path.
|
|
||||||
func seedFile(t *testing.T, dir, name, content string) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
path := filepath.Join(dir, name)
|
|
||||||
|
|
||||||
err := os.WriteFile(path, []byte(content), 0o600)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("seeding %s: %v", name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
// mustExecute runs a command with its output captured and fails the test
|
|
||||||
// if it errors, returning what the command printed.
|
|
||||||
func mustExecute(t *testing.T, cmd *cobra.Command, args ...string) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
|
||||||
|
|
||||||
cmd.SetOut(&out)
|
|
||||||
cmd.SetArgs(args)
|
|
||||||
|
|
||||||
err := cmd.Execute()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("%s failed: %v", cmd.Name(), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return out.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestVersionQuietSuppressesReport checks that --quiet silences the whole
|
|
||||||
// version report: it is human-facing output, not a scriptable value.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // mutates the process-global rootFlags
|
|
||||||
func TestVersionQuietSuppressesReport(t *testing.T) {
|
|
||||||
setRootFlags(t, RootFlags{Quiet: true})
|
|
||||||
|
|
||||||
out := mustExecute(t, NewVersionCommand())
|
|
||||||
|
|
||||||
if out != "" {
|
|
||||||
t.Errorf("--quiet version printed %q, want nothing", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestConfigGetIgnoresQuiet checks that a config value is printed even
|
|
||||||
// under --quiet: it is scriptable output a caller depends on, so --quiet
|
|
||||||
// must not suppress it, and it stays machine-plain (no marker, no color).
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // mutates the process-global rootFlags
|
|
||||||
func TestConfigGetIgnoresQuiet(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := seedFile(t, dir, "config.yml", "storage_url: file:///mnt/x\n")
|
|
||||||
|
|
||||||
setRootFlags(t, RootFlags{Quiet: true, ConfigPath: path})
|
|
||||||
|
|
||||||
out := mustExecute(t, newConfigGetCommand(), "storage_url")
|
|
||||||
|
|
||||||
if out != "file:///mnt/x\n" {
|
|
||||||
t.Errorf("config get --quiet = %q, want the plain value", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestConfigSetQuietSuppressesConfirmation checks that --quiet silences
|
|
||||||
// the confirmation line while still writing the value to the file.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // mutates the process-global rootFlags
|
|
||||||
func TestConfigSetQuietSuppressesConfirmation(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := seedFile(t, dir, "config.yml", "compression_level: 3\n")
|
|
||||||
|
|
||||||
setRootFlags(t, RootFlags{Quiet: true, ConfigPath: path})
|
|
||||||
|
|
||||||
out := mustExecute(t, newConfigSetCommand(), "compression_level", "9")
|
|
||||||
|
|
||||||
if out != "" {
|
|
||||||
t.Errorf("--quiet config set printed %q, want nothing", out)
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := os.ReadFile(path) //nolint:gosec // G304: test-controlled path
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reading config back: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.Contains(string(data), "compression_level: 9") {
|
|
||||||
t.Errorf("config set did not write the value under --quiet:\n%s", data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestConfigSetConfirmsWhenNotQuiet checks that the confirmation names
|
|
||||||
// the key (styled) when --quiet is not set.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // mutates the process-global rootFlags
|
|
||||||
func TestConfigSetConfirmsWhenNotQuiet(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := seedFile(t, dir, "config.yml", "compression_level: 3\n")
|
|
||||||
|
|
||||||
setRootFlags(t, RootFlags{ConfigPath: path})
|
|
||||||
|
|
||||||
out := mustExecute(t, newConfigSetCommand(), "compression_level", "9")
|
|
||||||
|
|
||||||
if !strings.Contains(out, "compression_level") {
|
|
||||||
t.Errorf("config set did not confirm the key: %q", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestConfigInitQuietSuppressesConfirmation checks that --quiet silences
|
|
||||||
// the "config written" confirmation while still writing the file.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // mutates the process-global rootFlags
|
|
||||||
func TestConfigInitQuietSuppressesConfirmation(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "new-config.yml")
|
|
||||||
|
|
||||||
setRootFlags(t, RootFlags{Quiet: true, ConfigPath: path})
|
|
||||||
|
|
||||||
out := mustExecute(t, newConfigInitCommand())
|
|
||||||
|
|
||||||
if out != "" {
|
|
||||||
t.Errorf("--quiet config init printed %q, want nothing", out)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := os.Stat(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("config init did not write the file under --quiet: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedDatabaseDeleteConfig writes a valid config whose index_path is a
|
|
||||||
// seeded database file, and returns both paths.
|
|
||||||
func seedDatabaseDeleteConfig(t *testing.T, dir string) (string, string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
dbPath := seedFile(t, dir, "index.sqlite", "not-a-real-db")
|
|
||||||
cfg := fmt.Sprintf(hermeticConfig,
|
|
||||||
filepath.Join(dir, "source"), filepath.Join(dir, "store"), dbPath)
|
|
||||||
cfgPath := seedFile(t, dir, "config.yml", cfg)
|
|
||||||
|
|
||||||
return dbPath, cfgPath
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDatabaseDeleteQuietSuppressesMessage checks that --quiet silences
|
|
||||||
// the "database deleted" line while still removing the file.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // mutates the process-global rootFlags
|
|
||||||
func TestDatabaseDeleteQuietSuppressesMessage(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
dbPath, cfgPath := seedDatabaseDeleteConfig(t, dir)
|
|
||||||
|
|
||||||
setRootFlags(t, RootFlags{Quiet: true, ConfigPath: cfgPath})
|
|
||||||
|
|
||||||
out := mustExecute(t, newDatabaseDeleteCommand(), "--force")
|
|
||||||
|
|
||||||
if out != "" {
|
|
||||||
t.Errorf("--quiet database delete printed %q, want nothing", out)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := os.Stat(dbPath)
|
|
||||||
if !os.IsNotExist(err) {
|
|
||||||
t.Errorf("database delete did not remove the file: stat err = %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDatabaseDeleteReportsWhenNotQuiet checks that the deletion is
|
|
||||||
// reported when --quiet is not set.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // mutates the process-global rootFlags
|
|
||||||
func TestDatabaseDeleteReportsWhenNotQuiet(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
_, cfgPath := seedDatabaseDeleteConfig(t, dir)
|
|
||||||
|
|
||||||
setRootFlags(t, RootFlags{ConfigPath: cfgPath})
|
|
||||||
|
|
||||||
out := mustExecute(t, newDatabaseDeleteCommand(), "--force")
|
|
||||||
|
|
||||||
if !strings.Contains(out, "deleted") {
|
|
||||||
t.Errorf("database delete did not report the deletion: %q", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+39
-13
@@ -1,9 +1,12 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"os"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"go.uber.org/fx"
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
)
|
)
|
||||||
@@ -45,7 +48,7 @@ This is destructive and irreversible. Requires --force.`,
|
|||||||
return errNukeNeedsForce
|
return errNukeNeedsForce
|
||||||
}
|
}
|
||||||
|
|
||||||
return runVaultikApp(cmd, mutating, false, false, "Remote nuke failed",
|
return runVaultikApp(cmd, false, false, "Remote nuke failed",
|
||||||
func(v *vaultik.Vaultik) error {
|
func(v *vaultik.Vaultik) error {
|
||||||
return v.NukeRemote(true)
|
return v.NukeRemote(true)
|
||||||
})
|
})
|
||||||
@@ -80,24 +83,47 @@ func newRemoteInfoCommand() *cobra.Command {
|
|||||||
|
|
||||||
rootFlags := GetRootFlags()
|
rootFlags := GetRootFlags()
|
||||||
|
|
||||||
return RunOperation(cmd.Context(), AppOptions{
|
return RunWithApp(cmd.Context(), AppOptions{
|
||||||
ConfigPath: configPath,
|
ConfigPath: configPath,
|
||||||
LogOptions: log.Options{
|
LogOptions: log.Options{
|
||||||
Verbose: rootFlags.Verbose,
|
Verbose: rootFlags.Verbose,
|
||||||
Debug: rootFlags.Debug,
|
Debug: rootFlags.Debug,
|
||||||
Quiet: rootFlags.Quiet,
|
Quiet: rootFlags.Quiet || jsonOutput,
|
||||||
JSON: jsonOutput,
|
|
||||||
},
|
},
|
||||||
Mode: readOnly,
|
Modules: []fx.Option{},
|
||||||
}, func(v *vaultik.Vaultik) error {
|
Invokes: []fx.Option{
|
||||||
return v.RemoteInfo(jsonOutput)
|
fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) {
|
||||||
}, func(err error) {
|
lc.Append(fx.Hook{
|
||||||
if jsonOutput {
|
OnStart: func(_ context.Context) error {
|
||||||
return
|
go func() {
|
||||||
}
|
err := v.RemoteInfo(jsonOutput)
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
if !jsonOutput {
|
||||||
|
log.Error("Failed to get remote info", "error", err)
|
||||||
|
ReportErrorf("Failed to get remote info: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
log.Error("Failed to get remote info", "error", err)
|
os.Exit(1)
|
||||||
ReportErrorf("Failed to get remote info: %v", err)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-18
@@ -9,7 +9,6 @@ import (
|
|||||||
|
|
||||||
"github.com/adrg/xdg"
|
"github.com/adrg/xdg"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"sneak.berlin/go/vaultik/internal/ui"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// errConfigNotFound is wrapped by all config-resolution failures.
|
// errConfigNotFound is wrapped by all config-resolution failures.
|
||||||
@@ -58,9 +57,8 @@ on the source system.`,
|
|||||||
cmd.PersistentFlags().BoolVarP(&rootFlags.Quiet, "quiet", "q", false,
|
cmd.PersistentFlags().BoolVarP(&rootFlags.Quiet, "quiet", "q", false,
|
||||||
"Suppress non-error output")
|
"Suppress non-error output")
|
||||||
cmd.PersistentFlags().BoolVar(&rootFlags.SkipErrors, "skip-errors", false,
|
cmd.PersistentFlags().BoolVar(&rootFlags.SkipErrors, "skip-errors", false,
|
||||||
"Skip files that cannot be read when creating a snapshot, or "+
|
"Continue past per-file errors instead of aborting "+
|
||||||
"that cannot be restored when restoring, instead of aborting "+
|
"(applies to snapshot create and restore)")
|
||||||
"(packing and storage errors still abort)")
|
|
||||||
|
|
||||||
// Add subcommands
|
// Add subcommands
|
||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
@@ -82,20 +80,6 @@ func GetRootFlags() RootFlags {
|
|||||||
return rootFlags
|
return rootFlags
|
||||||
}
|
}
|
||||||
|
|
||||||
// commandUI returns a UI writer for a command's stdout, in quiet mode
|
|
||||||
// when the global --quiet flag is set. This is how the pure-cli
|
|
||||||
// commands (version, config, database) reach internal/ui: color follows
|
|
||||||
// the writer (a TTY gets color, a captured test buffer does not), and
|
|
||||||
// --quiet silences the same message classes it silences everywhere else.
|
|
||||||
func commandUI(cmd *cobra.Command) *ui.Writer {
|
|
||||||
w := ui.New(cmd.OutOrStdout())
|
|
||||||
if GetRootFlags().Quiet {
|
|
||||||
w.SetQuiet(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
return w
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResolveConfigPath resolves the config file path from flags, environment, or default.
|
// ResolveConfigPath resolves the config file path from flags, environment, or default.
|
||||||
// Search order: --config flag, VAULTIK_CONFIG env, XDG config dir,
|
// Search order: --config flag, VAULTIK_CONFIG env, XDG config dir,
|
||||||
// /etc/vaultik/config.yml.
|
// /etc/vaultik/config.yml.
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
package cli_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"go.uber.org/fx"
|
|
||||||
"sneak.berlin/go/vaultik/internal/cli"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestRunAppWaitsForOperationCleanupOnShutdown drives RunApp with an fx app
|
|
||||||
// wired the way RunOperation wires a command: a single lifecycle hook whose
|
|
||||||
// OnStart launches the operation in its own goroutine and whose OnStop cancels
|
|
||||||
// it and blocks until that goroutine returns. The operation stands in for a
|
|
||||||
// restore blocked mid-download — it holds a decrypted "scratch" file and only
|
|
||||||
// removes it as it unwinds on cancellation.
|
|
||||||
//
|
|
||||||
// The app is asked to stop once the operation is running (standing in for an
|
|
||||||
// OS interrupt; fx delivers a real signal and Shutdowner.Shutdown() on the
|
|
||||||
// same app.Wait channel, so both drive the identical shutdown path). RunApp
|
|
||||||
// must not return until app.Stop has run the OnStop hook, so the scratch file
|
|
||||||
// must be gone by the time RunApp returns. Before the fix RunApp returned as
|
|
||||||
// soon as the app.Wait/Done channel fired, without running app.Stop, so the
|
|
||||||
// cleanup never ran and this file would still be on disk (issue #159).
|
|
||||||
func TestRunAppWaitsForOperationCleanupOnShutdown(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
scratch := filepath.Join(t.TempDir(), "decrypted-scratch")
|
|
||||||
require.NoError(t, os.WriteFile(scratch, []byte("secret"), 0o600))
|
|
||||||
|
|
||||||
// Cancel and reap the operation even if RunApp returns without doing so
|
|
||||||
// (the buggy path), so the goroutine cannot leak past the test.
|
|
||||||
opCtx, opCancel := context.WithCancel(context.Background())
|
|
||||||
t.Cleanup(opCancel)
|
|
||||||
|
|
||||||
var stop func(context.Context) bool
|
|
||||||
|
|
||||||
app := fx.New(
|
|
||||||
fx.NopLogger,
|
|
||||||
fx.Invoke(func(lc fx.Lifecycle, sh fx.Shutdowner) {
|
|
||||||
lc.Append(fx.Hook{
|
|
||||||
OnStart: func(_ context.Context) error {
|
|
||||||
done := make(chan struct{})
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
defer close(done)
|
|
||||||
|
|
||||||
// Blocked mid-operation until cancelled, then run the
|
|
||||||
// cleanup an interrupted restore would run.
|
|
||||||
<-opCtx.Done()
|
|
||||||
|
|
||||||
_ = os.Remove(scratch)
|
|
||||||
}()
|
|
||||||
|
|
||||||
stop = func(ctx context.Context) bool {
|
|
||||||
opCancel()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
return true
|
|
||||||
case <-ctx.Done():
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ask the app to stop now that the operation is running.
|
|
||||||
go func() { _ = sh.Shutdown() }()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
OnStop: func(ctx context.Context) error {
|
|
||||||
stop(ctx)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
done := make(chan error, 1)
|
|
||||||
go func() { done <- cli.RunApp(context.Background(), app) }()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case err := <-done:
|
|
||||||
require.NoError(t, err)
|
|
||||||
case <-time.After(30 * time.Second):
|
|
||||||
t.Fatal("RunApp did not return after shutdown was requested")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := os.Stat(scratch)
|
|
||||||
require.True(t, os.IsNotExist(err),
|
|
||||||
"RunApp returned before the operation removed its decrypted scratch file")
|
|
||||||
}
|
|
||||||
+84
-38
@@ -1,10 +1,13 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"go.uber.org/fx"
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
)
|
)
|
||||||
@@ -83,8 +86,7 @@ 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()
|
||||||
|
|
||||||
// --cron suppression is wired through v.UI by setupGlobals.
|
return RunWithApp(cmd.Context(), AppOptions{
|
||||||
return RunOperation(cmd.Context(), AppOptions{
|
|
||||||
ConfigPath: configPath,
|
ConfigPath: configPath,
|
||||||
LogOptions: log.Options{
|
LogOptions: log.Options{
|
||||||
Verbose: rootFlags.Verbose,
|
Verbose: rootFlags.Verbose,
|
||||||
@@ -92,25 +94,54 @@ specifying a path using --config or by setting VAULTIK_CONFIG to a path.`,
|
|||||||
Cron: opts.Cron,
|
Cron: opts.Cron,
|
||||||
Quiet: rootFlags.Quiet,
|
Quiet: rootFlags.Quiet,
|
||||||
},
|
},
|
||||||
Mode: mutating,
|
Modules: []fx.Option{},
|
||||||
}, func(v *vaultik.Vaultik) error {
|
Invokes: []fx.Option{
|
||||||
return v.CreateSnapshot(opts)
|
fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) {
|
||||||
}, func(err error) {
|
lc.Append(fx.Hook{
|
||||||
log.Error("Snapshot creation failed", "error", err)
|
OnStart: func(_ context.Context) error {
|
||||||
ReportErrorf("Snapshot creation failed: %v", err)
|
// Start the snapshot creation in a goroutine
|
||||||
|
go func() {
|
||||||
|
// --cron suppression is wired through v.UI by setupGlobals.
|
||||||
|
err := v.CreateSnapshot(opts)
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
log.Error("Snapshot creation failed", "error", err)
|
||||||
|
ReportErrorf("Snapshot creation failed: %v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shutdown the app when snapshot completes
|
||||||
|
err = v.Shutdowner.Shutdown()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to shutdown", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
OnStop: func(_ context.Context) error {
|
||||||
|
log.Debug("Stopping snapshot creation")
|
||||||
|
// Cancel the Vaultik context
|
||||||
|
v.Cancel()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().BoolVar(&opts.Cron, "cron", false,
|
cmd.Flags().BoolVar(&opts.Cron, "cron", false,
|
||||||
"Run in cron mode (silent unless warning or error)")
|
"Run in cron mode (silent unless error)")
|
||||||
cmd.Flags().BoolVar(&opts.Prune, "prune", false,
|
cmd.Flags().BoolVar(&opts.Prune, "prune", false,
|
||||||
"After backup, drop older snapshots of the same name and remove "+
|
"After backup, drop older snapshots of the same name and remove "+
|
||||||
"orphaned blobs")
|
"orphaned blobs")
|
||||||
cmd.Flags().StringVar(&opts.KeepNewerThan, "keep-newer-than", "",
|
cmd.Flags().StringVar(&opts.KeepNewerThan, "keep-newer-than", "",
|
||||||
"With --prune: keep snapshots newer than this duration "+
|
"With --prune: keep snapshots newer than this duration "+
|
||||||
"(e.g. 30d, 4w, 6mo, 1y; m is minutes, mo is months) "+
|
"(e.g. 4w, 30d, 6mo) instead of only the latest")
|
||||||
"instead of only the latest")
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
@@ -126,7 +157,7 @@ func newSnapshotListCommand() *cobra.Command {
|
|||||||
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, _ []string) error {
|
||||||
return runVaultikApp(cmd, readOnly, false, false,
|
return runVaultikApp(cmd, false, false,
|
||||||
"Failed to list snapshots",
|
"Failed to list snapshots",
|
||||||
func(v *vaultik.Vaultik) error {
|
func(v *vaultik.Vaultik) error {
|
||||||
return v.ListSnapshots(jsonOutput)
|
return v.ListSnapshots(jsonOutput)
|
||||||
@@ -162,7 +193,7 @@ restrict the operation to specific snapshot names.`,
|
|||||||
return errPurgeCriteriaBoth
|
return errPurgeCriteriaBoth
|
||||||
}
|
}
|
||||||
|
|
||||||
return runVaultikApp(cmd, mutating, false, false,
|
return runVaultikApp(cmd, false, false,
|
||||||
"Failed to purge snapshots",
|
"Failed to purge snapshots",
|
||||||
func(v *vaultik.Vaultik) error {
|
func(v *vaultik.Vaultik) error {
|
||||||
return v.PurgeSnapshotsWithOptions(opts)
|
return v.PurgeSnapshotsWithOptions(opts)
|
||||||
@@ -173,8 +204,7 @@ restrict the operation to specific snapshot names.`,
|
|||||||
cmd.Flags().BoolVar(&opts.KeepLatest, "keep-latest", false,
|
cmd.Flags().BoolVar(&opts.KeepLatest, "keep-latest", false,
|
||||||
"Keep only the latest snapshot of each name")
|
"Keep only the latest snapshot of each name")
|
||||||
cmd.Flags().StringVar(&opts.OlderThan, "older-than", "",
|
cmd.Flags().StringVar(&opts.OlderThan, "older-than", "",
|
||||||
"Remove snapshots older than duration "+
|
"Remove snapshots older than duration (e.g., 30d, 6m, 1y)")
|
||||||
"(e.g. 30d, 4w, 6mo, 1y; m is minutes, mo is months)")
|
|
||||||
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().StringArrayVar(&opts.Names, "snapshot", nil,
|
||||||
"Restrict to snapshots with these names (repeat for multiple)")
|
"Restrict to snapshots with these names (repeat for multiple)")
|
||||||
@@ -188,16 +218,9 @@ func newSnapshotVerifyCommand() *cobra.Command {
|
|||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "verify <snapshot-id>",
|
Use: "verify <snapshot-id>",
|
||||||
Short: "Check a snapshot's blobs are present with the listed size",
|
Short: "Verify snapshot integrity",
|
||||||
Long: "Checks that every blob the snapshot's manifest lists is present\n" +
|
Long: "Verifies that all blobs referenced in a snapshot exist",
|
||||||
"in storage with the size the manifest records, and that the\n" +
|
Args: requireSnapshotIDArg,
|
||||||
"snapshot's encrypted database is present. It does not read blob\n" +
|
|
||||||
"contents; use --deep to download, decrypt, and re-hash every blob\n" +
|
|
||||||
"to detect corruption -- integrity, not who wrote it.\n\n" +
|
|
||||||
"The snapshot may be named by its ID or, on a host with no local\n" +
|
|
||||||
"index, by the remote key that 'snapshot list' prints for a\n" +
|
|
||||||
"remote-only snapshot (an unambiguous leading part is enough).",
|
|
||||||
Args: requireSnapshotIDArg,
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
snapshotID := args[0]
|
snapshotID := args[0]
|
||||||
|
|
||||||
@@ -209,24 +232,47 @@ func newSnapshotVerifyCommand() *cobra.Command {
|
|||||||
|
|
||||||
rootFlags := GetRootFlags()
|
rootFlags := GetRootFlags()
|
||||||
|
|
||||||
return RunOperation(cmd.Context(), AppOptions{
|
return RunWithApp(cmd.Context(), AppOptions{
|
||||||
ConfigPath: configPath,
|
ConfigPath: configPath,
|
||||||
LogOptions: log.Options{
|
LogOptions: log.Options{
|
||||||
Verbose: rootFlags.Verbose,
|
Verbose: rootFlags.Verbose,
|
||||||
Debug: rootFlags.Debug,
|
Debug: rootFlags.Debug,
|
||||||
Quiet: rootFlags.Quiet,
|
Quiet: rootFlags.Quiet || opts.JSON,
|
||||||
JSON: opts.JSON,
|
|
||||||
},
|
},
|
||||||
Mode: readOnly,
|
Modules: []fx.Option{},
|
||||||
}, func(v *vaultik.Vaultik) error {
|
Invokes: []fx.Option{
|
||||||
return v.VerifySnapshotWithOptions(snapshotID, opts)
|
fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) {
|
||||||
}, func(err error) {
|
lc.Append(fx.Hook{
|
||||||
if opts.JSON {
|
OnStart: func(_ context.Context) error {
|
||||||
return
|
go func() {
|
||||||
}
|
err := v.VerifySnapshotWithOptions(snapshotID, opts)
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
if !opts.JSON {
|
||||||
|
log.Error("Verification failed", "error", err)
|
||||||
|
ReportErrorf("Verification failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
log.Error("Verification failed", "error", err)
|
os.Exit(1)
|
||||||
ReportErrorf("Verification failed: %v", err)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -265,7 +311,7 @@ To wipe the entire destination store and start over, use 'vaultik remote
|
|||||||
nuke --force' — it is the single supported entry point for that.`,
|
nuke --force' — it is the single supported entry point for that.`,
|
||||||
Args: requireSnapshotIDArg,
|
Args: requireSnapshotIDArg,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
return runVaultikApp(cmd, mutating, opts.JSON, opts.JSON,
|
return runVaultikApp(cmd, opts.JSON, opts.JSON,
|
||||||
"Failed to remove snapshot",
|
"Failed to remove snapshot",
|
||||||
func(v *vaultik.Vaultik) error {
|
func(v *vaultik.Vaultik) error {
|
||||||
_, err := v.RemoveSnapshot(args[0], opts)
|
_, err := v.RemoveSnapshot(args[0], opts)
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"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/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/storage"
|
||||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,6 +25,15 @@ type RestoreOptions struct {
|
|||||||
Verify bool // Verify restored files after restore
|
Verify bool // Verify restored files after restore
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RestoreApp contains all dependencies needed for restore
|
||||||
|
type RestoreApp struct {
|
||||||
|
Globals *globals.Globals
|
||||||
|
Config *config.Config
|
||||||
|
Storage storage.Storer
|
||||||
|
Vaultik *vaultik.Vaultik
|
||||||
|
Shutdowner fx.Shutdowner
|
||||||
|
}
|
||||||
|
|
||||||
// newSnapshotRestoreCommand creates the 'snapshot restore' subcommand
|
// newSnapshotRestoreCommand creates the 'snapshot restore' subcommand
|
||||||
func newSnapshotRestoreCommand() *cobra.Command {
|
func newSnapshotRestoreCommand() *cobra.Command {
|
||||||
opts := &RestoreOptions{}
|
opts := &RestoreOptions{}
|
||||||
@@ -31,16 +48,8 @@ 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.
|
||||||
|
|
||||||
The snapshot may be named by its ID or, when restoring on a host with no
|
Requires the VAULTIK_AGE_SECRET_KEY environment variable to be set with
|
||||||
local index, by the remote key that 'snapshot list' prints for a
|
the age private key.
|
||||||
remote-only snapshot (an unambiguous leading part is enough).
|
|
||||||
|
|
||||||
Requires the age private key in the VAULTIK_AGE_SECRET_KEY environment
|
|
||||||
variable. The variable may hold the whole age-keygen file (comments and
|
|
||||||
all of its identities are accepted); read it from the file rather than
|
|
||||||
typing the key, so it does not land in your shell history:
|
|
||||||
|
|
||||||
export VAULTIK_AGE_SECRET_KEY="$(cat vaultik_backup_private_key.txt)"
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
# Restore entire snapshot
|
# Restore entire snapshot
|
||||||
@@ -68,8 +77,7 @@ Examples:
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
// runRestore parses arguments and runs the restore operation through the
|
// runRestore parses arguments and runs the restore operation through the app framework
|
||||||
// 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]
|
||||||
|
|
||||||
@@ -78,31 +86,87 @@ func runRestore(cmd *cobra.Command, args []string, opts *RestoreOptions) error {
|
|||||||
opts.Paths = args[restoreMinArgs:]
|
opts.Paths = args[restoreMinArgs:]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use unified config resolution
|
||||||
configPath, err := ResolveConfigPath()
|
configPath, err := ResolveConfigPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use the app framework like other commands
|
||||||
rootFlags := GetRootFlags()
|
rootFlags := GetRootFlags()
|
||||||
|
|
||||||
return RunOperation(cmd.Context(), AppOptions{
|
return RunWithApp(cmd.Context(), AppOptions{
|
||||||
ConfigPath: configPath,
|
ConfigPath: configPath,
|
||||||
LogOptions: log.Options{
|
LogOptions: log.Options{
|
||||||
Verbose: rootFlags.Verbose,
|
Verbose: rootFlags.Verbose,
|
||||||
Debug: rootFlags.Debug,
|
Debug: rootFlags.Debug,
|
||||||
Quiet: rootFlags.Quiet,
|
Quiet: rootFlags.Quiet,
|
||||||
},
|
},
|
||||||
Mode: readOnly,
|
Modules: buildRestoreModules(),
|
||||||
}, func(v *vaultik.Vaultik) error {
|
Invokes: buildRestoreInvokes(snapshotID, opts),
|
||||||
return v.Restore(&vaultik.RestoreOptions{
|
|
||||||
SnapshotID: snapshotID,
|
|
||||||
TargetDir: opts.TargetDir,
|
|
||||||
Paths: opts.Paths,
|
|
||||||
Verify: opts.Verify,
|
|
||||||
SkipErrors: rootFlags.SkipErrors,
|
|
||||||
})
|
|
||||||
}, func(err error) {
|
|
||||||
log.Error("Restore operation failed", "error", err)
|
|
||||||
ReportErrorf("Restore failed: %v", err)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildRestoreModules returns the fx.Options for dependency injection in restore
|
||||||
|
func buildRestoreModules() []fx.Option {
|
||||||
|
return []fx.Option{
|
||||||
|
fx.Provide(fx.Annotate(
|
||||||
|
func(g *globals.Globals, cfg *config.Config,
|
||||||
|
storer storage.Storer, v *vaultik.Vaultik, shutdowner fx.Shutdowner) *RestoreApp {
|
||||||
|
return &RestoreApp{
|
||||||
|
Globals: g,
|
||||||
|
Config: cfg,
|
||||||
|
Storage: storer,
|
||||||
|
Vaultik: v,
|
||||||
|
Shutdowner: shutdowner,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRestoreInvokes returns the fx.Options that wire up the restore lifecycle
|
||||||
|
func buildRestoreInvokes(snapshotID string, opts *RestoreOptions) []fx.Option {
|
||||||
|
return []fx.Option{
|
||||||
|
fx.Invoke(func(app *RestoreApp, lc fx.Lifecycle) {
|
||||||
|
lc.Append(fx.Hook{
|
||||||
|
OnStart: func(_ context.Context) error {
|
||||||
|
// Start the restore operation in a goroutine
|
||||||
|
go func() {
|
||||||
|
// Run the restore operation
|
||||||
|
restoreOpts := &vaultik.RestoreOptions{
|
||||||
|
SnapshotID: snapshotID,
|
||||||
|
TargetDir: opts.TargetDir,
|
||||||
|
Paths: opts.Paths,
|
||||||
|
Verify: opts.Verify,
|
||||||
|
SkipErrors: GetRootFlags().SkipErrors,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := app.Vaultik.Restore(restoreOpts)
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
log.Error("Restore operation failed", "error", err)
|
||||||
|
ReportErrorf("Restore failed: %v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shutdown the app when restore completes
|
||||||
|
err = app.Shutdowner.Shutdown()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to shutdown", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
OnStop: func(_ context.Context) error {
|
||||||
|
log.Debug("Stopping restore operation")
|
||||||
|
app.Vaultik.Cancel()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
package cli //nolint:testpackage // exercises the unexported command constructor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/spf13/pflag"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestRestoreCommandDoesNotTakeKeyAsArgument guards the fix for the age
|
|
||||||
// key being echoed on the command line: restore must take the key only
|
|
||||||
// from the environment, never as a flag value, and its help must show the
|
|
||||||
// file-based form rather than a literal key that would land in shell
|
|
||||||
// history.
|
|
||||||
func TestRestoreCommandDoesNotTakeKeyAsArgument(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cmd := newSnapshotRestoreCommand()
|
|
||||||
|
|
||||||
cmd.Flags().VisitAll(func(f *pflag.Flag) {
|
|
||||||
lower := strings.ToLower(f.Name)
|
|
||||||
for _, banned := range []string{"key", "secret", "age", "identity"} {
|
|
||||||
if strings.Contains(lower, banned) {
|
|
||||||
t.Errorf("restore must not accept the key as a flag; found --%s", f.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
help := cmd.Long
|
|
||||||
if strings.Contains(help, "AGE-SECRET-KEY-") {
|
|
||||||
t.Error("restore help must not show a literal age private key to type")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.Contains(help, "$(cat ") {
|
|
||||||
t.Error("restore help should read the key from a file, e.g. $(cat ...)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// SnapshotInfo represents snapshot information for listing
|
||||||
|
//
|
||||||
|
//nolint:tagliatelle // snake_case is the established output format
|
||||||
|
type SnapshotInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
CompressedSize int64 `json:"compressed_size"`
|
||||||
|
}
|
||||||
+21
-37
@@ -2,11 +2,11 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"sneak.berlin/go/vaultik/internal/globals"
|
"sneak.berlin/go/vaultik/internal/globals"
|
||||||
"sneak.berlin/go/vaultik/internal/ui"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewVersionCommand creates the version command
|
// NewVersionCommand creates the version command
|
||||||
@@ -16,44 +16,28 @@ 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(cmd *cobra.Command, _ []string) {
|
Run: func(_ *cobra.Command, _ []string) {
|
||||||
writeVersion(commandUI(cmd))
|
_, _ = fmt.Fprintf(os.Stdout, "vaultik %s\n", globals.Version)
|
||||||
|
_, _ = fmt.Fprintf(os.Stdout, " commit: %s\n", globals.Commit)
|
||||||
|
_, _ = fmt.Fprintf(os.Stdout, " build date: %s\n", globals.CommitDate)
|
||||||
|
_, _ = fmt.Fprintf(os.Stdout, " go: %s\n", runtime.Version())
|
||||||
|
_, _ = 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.")
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeVersion prints the version report through the UI writer. The
|
|
||||||
// report is the output this command exists to produce, so it is written
|
|
||||||
// plain (markers would corrupt the aligned report) via the writer's
|
|
||||||
// underlying stdout; --quiet silences it like any other non-error
|
|
||||||
// output.
|
|
||||||
func writeVersion(out *ui.Writer) {
|
|
||||||
if out.Quiet() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w := out.Out()
|
|
||||||
|
|
||||||
_, _ = fmt.Fprintf(w, "vaultik %s\n", globals.Version)
|
|
||||||
_, _ = fmt.Fprintf(w, " commit: %s\n", globals.Commit)
|
|
||||||
_, _ = fmt.Fprintf(w, " build date: %s\n", globals.CommitDate)
|
|
||||||
_, _ = fmt.Fprintf(w, " go: %s\n", runtime.Version())
|
|
||||||
_, _ = fmt.Fprintf(w, " os/arch: %s/%s\n", runtime.GOOS, runtime.GOARCH)
|
|
||||||
_, _ = fmt.Fprintf(w, " author: %s\n", globals.Author)
|
|
||||||
_, _ = fmt.Fprintf(w, " homepage: %s\n", globals.Homepage)
|
|
||||||
_, _ = fmt.Fprintf(w, " license: %s\n", globals.License)
|
|
||||||
|
|
||||||
if globals.IsDevVersion(globals.Version) {
|
|
||||||
_, _ = fmt.Fprintln(w)
|
|
||||||
_, _ = fmt.Fprintln(w,
|
|
||||||
"This is a development build: it was not built from a tagged")
|
|
||||||
_, _ = fmt.Fprintln(w,
|
|
||||||
"commit, so it carries no release version. Released binaries")
|
|
||||||
_, _ = fmt.Fprintf(w,
|
|
||||||
"are published at %s\n", globals.ReleasesURL)
|
|
||||||
_, _ = fmt.Fprintln(w,
|
|
||||||
"and report their tag on the first line above.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
package cli_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"sneak.berlin/go/vaultik/internal/cli"
|
|
||||||
"sneak.berlin/go/vaultik/internal/globals"
|
|
||||||
)
|
|
||||||
|
|
||||||
// runVersionCommand executes `vaultik version` with its output
|
|
||||||
// captured, and returns what it printed.
|
|
||||||
func runVersionCommand(t *testing.T) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
cmd := cli.NewVersionCommand()
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
|
||||||
|
|
||||||
cmd.SetOut(&out)
|
|
||||||
cmd.SetErr(&out)
|
|
||||||
cmd.SetArgs([]string{})
|
|
||||||
|
|
||||||
err := cmd.Execute()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("version command failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return out.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestVersionCommandReportsBuildVersion checks that the first line of
|
|
||||||
// the report is the version the binary was actually built with. The
|
|
||||||
// test binary carries no -ldflags, so that is the "dev" default -- the
|
|
||||||
// same string an untagged `make vaultik` build stamps a prefix of.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // executes a command that reads the global rootFlags
|
|
||||||
func TestVersionCommandReportsBuildVersion(t *testing.T) {
|
|
||||||
out := runVersionCommand(t)
|
|
||||||
|
|
||||||
wantFirst := "vaultik " + globals.Version
|
|
||||||
if first, _, _ := strings.Cut(out, "\n"); first != wantFirst {
|
|
||||||
t.Errorf("first line = %q, want %q", first, wantFirst)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.Contains(out, "commit:") {
|
|
||||||
t.Error("output does not report the commit")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestVersionCommandFlagsDevelopmentBuild is the regression test for
|
|
||||||
// the thing this command exists to prevent: a build that is not a
|
|
||||||
// release must say so. The notice used to be gated on the version
|
|
||||||
// being exactly "dev", so once untagged builds started carrying their
|
|
||||||
// commit sha it would have gone silent and an unreleased binary would
|
|
||||||
// have looked like a release.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // executes a command that reads the global rootFlags
|
|
||||||
func TestVersionCommandFlagsDevelopmentBuild(t *testing.T) {
|
|
||||||
if !globals.IsDevVersion(globals.Version) {
|
|
||||||
t.Skipf("test binary was stamped with release version %q",
|
|
||||||
globals.Version)
|
|
||||||
}
|
|
||||||
|
|
||||||
out := runVersionCommand(t)
|
|
||||||
|
|
||||||
if !strings.Contains(out, "development build") {
|
|
||||||
t.Errorf("dev build did not print the development-build notice:\n%s",
|
|
||||||
out)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.Contains(out, globals.ReleasesURL) {
|
|
||||||
t.Errorf("development-build notice does not point at %s:\n%s",
|
|
||||||
globals.ReleasesURL, out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+35
-106
@@ -16,19 +16,11 @@ import (
|
|||||||
"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/chunker"
|
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
const appName = "vaultik"
|
const appName = "vaultik"
|
||||||
|
|
||||||
// secretKeyPrefix marks an age secret (private) key. It is compared
|
|
||||||
// case-insensitively so a recipient entry that is actually a private key is
|
|
||||||
// caught and never passed to age or echoed back.
|
|
||||||
//
|
|
||||||
//nolint:gosec // G101: marker for detecting a pasted secret key, not a credential
|
|
||||||
const secretKeyPrefix = "AGE-SECRET-KEY-"
|
|
||||||
|
|
||||||
// Defaults and validation bounds for tunable settings.
|
// Defaults and validation bounds for tunable settings.
|
||||||
const (
|
const (
|
||||||
defaultBlobSizeLimit = Size(10 * 1024 * 1024 * 1024) // 10GB
|
defaultBlobSizeLimit = Size(10 * 1024 * 1024 * 1024) // 10GB
|
||||||
@@ -45,19 +37,13 @@ var (
|
|||||||
errNoConfigPath = errors.New("config path not provided")
|
errNoConfigPath = errors.New("config path not provided")
|
||||||
errNoAgeRecipients = errors.New(
|
errNoAgeRecipients = errors.New(
|
||||||
"at least one age_recipient is required (generate with: age-keygen)")
|
"at least one age_recipient is required (generate with: age-keygen)")
|
||||||
errRecipientIsSecretKey = errors.New(
|
|
||||||
"an age secret key was given where a public key (age1...) belongs")
|
|
||||||
errRecipientNotX25519 = errors.New(
|
|
||||||
"not a valid recipient; only X25519 age1... public keys are supported")
|
|
||||||
errNoSnapshots = errors.New(
|
errNoSnapshots = errors.New(
|
||||||
"at least one snapshot must be configured (see config.example.yml)")
|
"at least one snapshot must be configured (see config.example.yml)")
|
||||||
errSnapshotNoPaths = errors.New("snapshot must have at least one path")
|
errSnapshotNoPaths = errors.New("snapshot must have at least one path")
|
||||||
errChunkSizeTooSmall = errors.New("chunk_size must be at least 1MB")
|
errChunkSizeTooSmall = errors.New("chunk_size must be at least 1MB")
|
||||||
errBlobSizeTooSmall = errors.New(
|
errBlobSizeTooSmall = errors.New("blob_size_limit must be at least chunk_size")
|
||||||
"blob_size_limit must be at least the largest chunk the chunker can " +
|
errBadCompression = errors.New("compression_level must be between 1 and 19")
|
||||||
"emit (chunk_size times the FastCDC size spread)")
|
errBadStorageScheme = errors.New(
|
||||||
errBadCompression = errors.New("compression_level must be between 1 and 19")
|
|
||||||
errBadStorageScheme = errors.New(
|
|
||||||
"storage_url must start with s3://, file://, or rclone://")
|
"storage_url must start with s3://, file://, or rclone://")
|
||||||
errStorageNotConfigured = errors.New(
|
errStorageNotConfigured = errors.New(
|
||||||
"storage not configured; set storage_url or provide s3.endpoint + " +
|
"storage not configured; set storage_url or provide s3.endpoint + " +
|
||||||
@@ -135,26 +121,6 @@ func (c *Config) SnapshotNames() []string {
|
|||||||
return names
|
return names
|
||||||
}
|
}
|
||||||
|
|
||||||
// Names of the two places the age secret key can be configured, used by
|
|
||||||
// AgeSecretKeySourceName for error messages that must not echo the value.
|
|
||||||
//
|
|
||||||
//nolint:gosec // G101: these are the names of the config sources, not a key
|
|
||||||
const (
|
|
||||||
ageSecretKeySourceEnv = "VAULTIK_AGE_SECRET_KEY"
|
|
||||||
ageSecretKeySourceConfig = "age_secret_key"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AgeSecretKeySourceName returns the human name of where AgeSecretKey was
|
|
||||||
// configured. A Config built directly (as in tests) has no recorded
|
|
||||||
// source, so it reports the config-file field name.
|
|
||||||
func (c *Config) AgeSecretKeySourceName() string {
|
|
||||||
if c.AgeSecretKeySource != "" {
|
|
||||||
return c.AgeSecretKeySource
|
|
||||||
}
|
|
||||||
|
|
||||||
return ageSecretKeySourceConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
// Config represents the application configuration for Vaultik.
|
// Config represents the application configuration for Vaultik.
|
||||||
// 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.
|
||||||
@@ -164,13 +130,8 @@ func (c *Config) AgeSecretKeySourceName() string {
|
|||||||
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"`
|
||||||
// AgeSecretKeySource names where AgeSecretKey was configured
|
BlobSizeLimit Size `yaml:"blob_size_limit"`
|
||||||
// ("VAULTIK_AGE_SECRET_KEY" or "age_secret_key") so a later parse
|
ChunkSize Size `yaml:"chunk_size"`
|
||||||
// failure can name the source without echoing the secret value. It is
|
|
||||||
// set by Load and never read from or written to the config file.
|
|
||||||
AgeSecretKeySource string `yaml:"-"`
|
|
||||||
BlobSizeLimit Size `yaml:"blob_size_limit"`
|
|
||||||
ChunkSize Size `yaml:"chunk_size"`
|
|
||||||
// Exclude holds global excludes applied to all snapshots.
|
// Exclude holds global excludes applied to all snapshots.
|
||||||
Exclude []string `yaml:"exclude"`
|
Exclude []string `yaml:"exclude"`
|
||||||
Hostname string `yaml:"hostname"`
|
Hostname string `yaml:"hostname"`
|
||||||
@@ -201,10 +162,8 @@ type S3Config struct {
|
|||||||
AccessKeyID string `yaml:"access_key_id"`
|
AccessKeyID string `yaml:"access_key_id"`
|
||||||
SecretAccessKey string `yaml:"secret_access_key"`
|
SecretAccessKey string `yaml:"secret_access_key"`
|
||||||
Region string `yaml:"region"`
|
Region string `yaml:"region"`
|
||||||
// UseSSL selects HTTPS for a scheme-less endpoint. Omitted (nil) means
|
UseSSL bool `yaml:"use_ssl"`
|
||||||
// the default, TLS; set it to false only to force plain HTTP.
|
PartSize Size `yaml:"part_size"`
|
||||||
UseSSL *bool `yaml:"use_ssl"`
|
|
||||||
PartSize Size `yaml:"part_size"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path wraps the config file path for fx dependency injection.
|
// Path wraps the config file path for fx dependency injection.
|
||||||
@@ -279,7 +238,10 @@ func Load(path string) (*Config, error) {
|
|||||||
cfg.IndexPath = expandTilde(envIndexPath)
|
cfg.IndexPath = expandTilde(envIndexPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.setAgeSecretKey()
|
// Check for environment variable override for AgeSecretKey
|
||||||
|
if envAgeSecretKey := os.Getenv("VAULTIK_AGE_SECRET_KEY"); envAgeSecretKey != "" {
|
||||||
|
cfg.AgeSecretKey = extractAgeSecretKey(envAgeSecretKey)
|
||||||
|
}
|
||||||
|
|
||||||
// Get hostname if not set
|
// Get hostname if not set
|
||||||
if cfg.Hostname == "" {
|
if cfg.Hostname == "" {
|
||||||
@@ -323,30 +285,18 @@ func Load(path string) (*Config, error) {
|
|||||||
|
|
||||||
// Validate checks if the configuration is valid and complete.
|
// Validate checks if the configuration is valid and complete.
|
||||||
// It ensures all required fields are present and have valid values:
|
// It ensures all required fields are present and have valid values:
|
||||||
// - At least one age recipient must be specified, and every recipient must
|
// - At least one age recipient must be specified
|
||||||
// parse as an X25519 age1... public key (so a bad entry fails at load, not
|
// - At least one snapshot must be configured with at least one path
|
||||||
// mid-backup); errors name the position, never the value
|
// - Storage must be configured (either storage_url or s3.* fields)
|
||||||
// - At least one snapshot must be configured with at least one path
|
// - Chunk size must be at least 1MB
|
||||||
// - Storage must be configured (either storage_url or s3.* fields)
|
// - Blob size limit must be at least the chunk size
|
||||||
// - Chunk size must be at least 1MB
|
// - Compression level must be between 1 and 19
|
||||||
// - Blob size limit must be at least the largest chunk the chunker can emit
|
|
||||||
// (chunk_size times chunker.ChunkSizeSpread), so a single-chunk blob never
|
|
||||||
// exceeds the configured limit
|
|
||||||
// - Compression level must be between 1 and 19
|
|
||||||
//
|
|
||||||
// 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 errNoAgeRecipients
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, recipient := range c.AgeRecipients {
|
|
||||||
err := validateAgeRecipient(recipient)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("age_recipients[%d]: %w", i, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(c.Snapshots) == 0 {
|
if len(c.Snapshots) == 0 {
|
||||||
return errNoSnapshots
|
return errNoSnapshots
|
||||||
}
|
}
|
||||||
@@ -367,13 +317,8 @@ func (c *Config) Validate() error {
|
|||||||
return errChunkSizeTooSmall
|
return errChunkSizeTooSmall
|
||||||
}
|
}
|
||||||
|
|
||||||
// The chunker can emit chunks up to chunk_size * ChunkSizeSpread, and the
|
if c.BlobSizeLimit.Int64() < c.ChunkSize.Int64() {
|
||||||
// packer places a single such chunk into an otherwise empty blob. A limit
|
return errBlobSizeTooSmall
|
||||||
// below that bound would let a blob exceed it, so reject it.
|
|
||||||
largestChunk := c.ChunkSize.Int64() * chunker.ChunkSizeSpread
|
|
||||||
if c.BlobSizeLimit.Int64() < largestChunk {
|
|
||||||
return fmt.Errorf("%w: need at least %d bytes",
|
|
||||||
errBlobSizeTooSmall, largestChunk)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.CompressionLevel < minCompressionLevel ||
|
if c.CompressionLevel < minCompressionLevel ||
|
||||||
@@ -384,38 +329,6 @@ func (c *Config) Validate() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateAgeRecipient parses one age_recipients entry with the age library
|
|
||||||
// and returns a value-free error on failure. A recipient string can be
|
|
||||||
// sensitive (an operator may paste a secret key by mistake), so neither the
|
|
||||||
// entry nor age's own error (which quotes its input) is ever included.
|
|
||||||
func validateAgeRecipient(recipient string) error {
|
|
||||||
if strings.HasPrefix(strings.ToUpper(recipient), secretKeyPrefix) {
|
|
||||||
return errRecipientIsSecretKey
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := age.ParseX25519Recipient(recipient)
|
|
||||||
if err != nil {
|
|
||||||
return errRecipientNotX25519
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// setAgeSecretKey records the age secret key and where it came from. The
|
|
||||||
// value is stored raw and parsed only where decryption happens
|
|
||||||
// (internal/vaultik), so backup, list and prune keep working whatever the
|
|
||||||
// field holds. The environment variable overrides the config-file field.
|
|
||||||
func (c *Config) setAgeSecretKey() {
|
|
||||||
if c.AgeSecretKey != "" {
|
|
||||||
c.AgeSecretKeySource = ageSecretKeySourceConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
if env := os.Getenv("VAULTIK_AGE_SECRET_KEY"); env != "" {
|
|
||||||
c.AgeSecretKey = env
|
|
||||||
c.AgeSecretKeySource = ageSecretKeySourceEnv
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateStorage validates storage configuration.
|
// validateStorage validates storage configuration.
|
||||||
// If StorageURL is set, it takes precedence. S3 URLs require credentials.
|
// If StorageURL is set, it takes precedence. S3 URLs require credentials.
|
||||||
// File URLs don't require any S3 configuration.
|
// File URLs don't require any S3 configuration.
|
||||||
@@ -472,6 +385,22 @@ func (c *Config) validateStorageURL() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractAgeSecretKey extracts the AGE-SECRET-KEY from the input using
|
||||||
|
// the age library's parser, which handles comments and whitespace.
|
||||||
|
func extractAgeSecretKey(input string) string {
|
||||||
|
identities, err := age.ParseIdentities(strings.NewReader(input))
|
||||||
|
if err != nil || len(identities) == 0 {
|
||||||
|
// Fall back to trimmed input if parsing fails
|
||||||
|
return strings.TrimSpace(input)
|
||||||
|
}
|
||||||
|
// Return the string representation of the first identity
|
||||||
|
if id, ok := identities[0].(*age.X25519Identity); ok {
|
||||||
|
return id.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(input)
|
||||||
|
}
|
||||||
|
|
||||||
// Module exports the config module for fx dependency injection.
|
// 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.
|
||||||
//
|
//
|
||||||
|
|||||||
+39
-213
@@ -1,13 +1,9 @@
|
|||||||
package config //nolint:testpackage // exercises unexported source constants
|
package config //nolint:testpackage // exercises unexported extractAgeSecretKey
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"sneak.berlin/go/vaultik/internal/chunker"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -87,48 +83,6 @@ func TestConfigLoad(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestExampleConfigIsScrubbedAndLoads checks that the shipped
|
|
||||||
// config.example.yml carries only neutral placeholders (no real credentials,
|
|
||||||
// private addresses, or internal host names) and still parses.
|
|
||||||
func TestExampleConfigIsScrubbedAndLoads(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
examplePath := filepath.Join("..", "..", "config.example.yml")
|
|
||||||
|
|
||||||
cfg, err := Load(examplePath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to load config.example.yml: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.StorageURL != "rclone://myremote/path/to/backups" {
|
|
||||||
t.Errorf("Expected neutral storage_url, got '%s'", cfg.StorageURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
//nolint:gosec // G304: examplePath is a fixed in-repo path, not user input
|
|
||||||
raw, err := os.ReadFile(examplePath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to read config.example.yml: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
text := string(raw)
|
|
||||||
|
|
||||||
wantSubstrings := []string{
|
|
||||||
"YOUR_ACCESS_KEY",
|
|
||||||
"YOUR_SECRET_KEY",
|
|
||||||
"endpoint: https://",
|
|
||||||
}
|
|
||||||
for _, want := range wantSubstrings {
|
|
||||||
if !strings.Contains(text, want) {
|
|
||||||
t.Errorf("Expected config.example.yml to contain %q", want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A raw "http://" scheme would mean a plaintext, likely private endpoint.
|
|
||||||
if strings.Contains(text, "http://") {
|
|
||||||
t.Error("config.example.yml should not contain an http:// endpoint")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestConfigFromEnv tests loading config path from environment variable
|
// TestConfigFromEnv tests loading config path from environment variable
|
||||||
func TestConfigFromEnv(t *testing.T) {
|
func TestConfigFromEnv(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -147,57 +101,53 @@ func TestConfigFromEnv(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestValidateBlobSizeLimit checks the blob_size_limit boundary: it must be at
|
// TestExtractAgeSecretKey tests extraction of AGE-SECRET-KEY from various inputs
|
||||||
// least the largest chunk the chunker can emit (chunk_size times
|
func TestExtractAgeSecretKey(t *testing.T) {
|
||||||
// chunker.ChunkSizeSpread), because the packer places a single such chunk into
|
|
||||||
// an otherwise empty blob. A limit between chunk_size and that bound is rejected.
|
|
||||||
func TestValidateBlobSizeLimit(t *testing.T) {
|
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const chunkSize = Size(10 * 1024 * 1024) // 10MB
|
|
||||||
|
|
||||||
largestChunk := chunkSize.Int64() * chunker.ChunkSizeSpread
|
|
||||||
|
|
||||||
newConfig := func(blobLimit Size) *Config {
|
|
||||||
return &Config{
|
|
||||||
AgeRecipients: []string{testSneakAgePublicKey},
|
|
||||||
Snapshots: map[string]SnapshotConfig{"test": {Paths: []string{"/tmp/src"}}},
|
|
||||||
StorageURL: "file:///tmp/vaultik-test-store",
|
|
||||||
ChunkSize: chunkSize,
|
|
||||||
BlobSizeLimit: blobLimit,
|
|
||||||
CompressionLevel: 3,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
blobLimit Size
|
input string
|
||||||
wantErr bool
|
expected string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "at chunk_size but below largest chunk is rejected",
|
name: "plain key",
|
||||||
blobLimit: chunkSize,
|
input: testIntegrationAgePrivateKey,
|
||||||
wantErr: true,
|
expected: testIntegrationAgePrivateKey,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "between chunk_size and largest chunk is rejected",
|
name: "key with trailing newline",
|
||||||
blobLimit: Size(chunkSize.Int64() * 2),
|
input: testIntegrationAgePrivateKey + "\n",
|
||||||
wantErr: true,
|
expected: testIntegrationAgePrivateKey,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "one byte below largest chunk is rejected",
|
name: "full age-keygen output",
|
||||||
blobLimit: Size(largestChunk - 1),
|
input: "# created: 2025-01-14T12:00:00Z\n" +
|
||||||
wantErr: true,
|
"# public key: " + testIntegrationAgePublicKey + "\n" +
|
||||||
|
testIntegrationAgePrivateKey + "\n",
|
||||||
|
expected: testIntegrationAgePrivateKey,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "exactly at largest chunk is accepted",
|
name: "age-keygen output with extra blank lines",
|
||||||
blobLimit: Size(largestChunk),
|
input: "# created: 2025-01-14T12:00:00Z\n" +
|
||||||
wantErr: false,
|
"# public key: " + testIntegrationAgePublicKey + "\n\n" +
|
||||||
|
testIntegrationAgePrivateKey + "\n\n",
|
||||||
|
expected: testIntegrationAgePrivateKey,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "above largest chunk is accepted",
|
name: "key with leading whitespace",
|
||||||
blobLimit: Size(largestChunk * 100),
|
input: " " + testIntegrationAgePrivateKey + " ",
|
||||||
wantErr: false,
|
expected: testIntegrationAgePrivateKey,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty input",
|
||||||
|
input: "",
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "only comments",
|
||||||
|
input: "# this is a comment\n# another comment",
|
||||||
|
expected: "# this is a comment\n# another comment",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,134 +155,10 @@ func TestValidateBlobSizeLimit(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
err := newConfig(tt.blobLimit).Validate()
|
result := extractAgeSecretKey(tt.input)
|
||||||
if tt.wantErr {
|
if result != tt.expected {
|
||||||
if !errors.Is(err, errBlobSizeTooSmall) {
|
t.Errorf("extractAgeSecretKey(%q) = %q, want %q",
|
||||||
t.Fatalf("Validate() error = %v, want errBlobSizeTooSmall", err)
|
tt.input, result, tt.expected)
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Validate() unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateAgeRecipients checks that recipients are parsed at config load
|
|
||||||
// (a bad entry fails immediately, not mid-backup) and that no invalid entry —
|
|
||||||
// least of all a pasted secret key — is echoed in the error.
|
|
||||||
func TestValidateAgeRecipients(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
baseConfig := func(recipients []string) *Config {
|
|
||||||
return &Config{
|
|
||||||
AgeRecipients: recipients,
|
|
||||||
Snapshots: map[string]SnapshotConfig{"test": {Paths: []string{"/tmp/src"}}},
|
|
||||||
StorageURL: "file:///tmp/vaultik-test-store",
|
|
||||||
ChunkSize: Size(10 * 1024 * 1024),
|
|
||||||
BlobSizeLimit: Size(10 * 1024 * 1024 * 1024),
|
|
||||||
CompressionLevel: 3,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
recipients []string
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "config init placeholder is rejected",
|
|
||||||
recipients: []string{"age1REPLACE_WITH_YOUR_PUBLIC_KEY"},
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "ssh-ed25519 recipient is rejected",
|
|
||||||
recipients: []string{"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIexamplekeydata"},
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "truncated age1 string is rejected",
|
|
||||||
recipients: []string{"age1short"},
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "secret key passed as recipient is rejected",
|
|
||||||
recipients: []string{testIntegrationAgePrivateKey},
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "two valid recipients are accepted",
|
|
||||||
recipients: []string{testSneakAgePublicKey, testIntegrationAgePublicKey},
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
err := baseConfig(tt.recipients).Validate()
|
|
||||||
if !tt.wantErr {
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Validate() unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("Validate() returned nil, want error")
|
|
||||||
}
|
|
||||||
|
|
||||||
// The entry itself must never appear in the error, since a
|
|
||||||
// recipient string can be a secret key.
|
|
||||||
for _, recipient := range tt.recipients {
|
|
||||||
if strings.Contains(err.Error(), recipient) {
|
|
||||||
t.Fatalf("Validate() error echoed the recipient value: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestAgeSecretKeySourceName checks the name reported for the configured
|
|
||||||
// age secret key: the recorded source when Load set one, and the
|
|
||||||
// config-file field name for a Config built directly (as in tests).
|
|
||||||
func TestAgeSecretKeySourceName(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
source string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "unset defaults to config field",
|
|
||||||
source: "",
|
|
||||||
want: ageSecretKeySourceConfig,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "environment source",
|
|
||||||
source: ageSecretKeySourceEnv,
|
|
||||||
want: ageSecretKeySourceEnv,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "config-file source",
|
|
||||||
source: ageSecretKeySourceConfig,
|
|
||||||
want: ageSecretKeySourceConfig,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cfg := &Config{AgeSecretKeySource: tt.source}
|
|
||||||
if got := cfg.AgeSecretKeySourceName(); got != tt.want {
|
|
||||||
t.Errorf("AgeSecretKeySourceName() = %q, want %q", got, tt.want)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
// Package crypto provides thread-safe age encryption and decryption
|
||||||
|
// helpers used to protect blob and metadata content.
|
||||||
|
package crypto //nolint:revive,nolintlint // stdlib crypto unused; see #76
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"filippo.io/age"
|
||||||
|
"go.uber.org/fx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrNoRecipients is returned when an encryptor is created or updated
|
||||||
|
// without any recipient public keys.
|
||||||
|
var ErrNoRecipients = errors.New("at least one recipient is required")
|
||||||
|
|
||||||
|
// Encryptor provides thread-safe encryption using the age encryption library.
|
||||||
|
// It supports encrypting data for multiple recipients simultaneously, allowing
|
||||||
|
// any of the corresponding private keys to decrypt the data. This is useful
|
||||||
|
// for backup scenarios where multiple parties should be able to decrypt the data.
|
||||||
|
type Encryptor struct {
|
||||||
|
recipients []age.Recipient
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEncryptor creates a new encryptor with the given age public keys.
|
||||||
|
// Each public key should be a valid age X25519 recipient string (e.g., "age1...")
|
||||||
|
// At least one recipient must be provided. Returns an error if any of the
|
||||||
|
// public keys are invalid or if no recipients are specified.
|
||||||
|
func NewEncryptor(publicKeys []string) (*Encryptor, error) {
|
||||||
|
if len(publicKeys) == 0 {
|
||||||
|
return nil, ErrNoRecipients
|
||||||
|
}
|
||||||
|
|
||||||
|
recipients := make([]age.Recipient, 0, len(publicKeys))
|
||||||
|
for _, key := range publicKeys {
|
||||||
|
recipient, err := age.ParseX25519Recipient(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing age recipient %s: %w", key, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
recipients = append(recipients, recipient)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Encryptor{
|
||||||
|
recipients: recipients,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt encrypts data using age encryption for all configured recipients.
|
||||||
|
// The encrypted data can be decrypted by any of the corresponding private keys.
|
||||||
|
// This method is suitable for small to medium amounts of data that fit in memory.
|
||||||
|
// For large data streams, use EncryptStream or EncryptWriter instead.
|
||||||
|
func (e *Encryptor) Encrypt(data []byte) ([]byte, error) {
|
||||||
|
e.mu.RLock()
|
||||||
|
recipients := e.recipients
|
||||||
|
e.mu.RUnlock()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
// Create encrypted writer for all recipients
|
||||||
|
w, err := age.Encrypt(&buf, recipients...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("creating encrypted writer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write data
|
||||||
|
_, err = w.Write(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("writing encrypted data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close to flush
|
||||||
|
err = w.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("closing encrypted writer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncryptStream encrypts data from reader to writer using age encryption.
|
||||||
|
// This method is suitable for encrypting large files or streams as it processes
|
||||||
|
// data in a streaming fashion without loading everything into memory.
|
||||||
|
// The encrypted data is written directly to the destination writer.
|
||||||
|
func (e *Encryptor) EncryptStream(dst io.Writer, src io.Reader) error {
|
||||||
|
e.mu.RLock()
|
||||||
|
recipients := e.recipients
|
||||||
|
e.mu.RUnlock()
|
||||||
|
|
||||||
|
// Create encrypted writer for all recipients
|
||||||
|
w, err := age.Encrypt(dst, recipients...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating encrypted writer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy data
|
||||||
|
_, err = io.Copy(w, src)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("copying encrypted data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close to flush
|
||||||
|
err = w.Close()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("closing encrypted writer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncryptWriter creates a writer that encrypts data written to it.
|
||||||
|
// All data written to the returned WriteCloser will be encrypted and written
|
||||||
|
// to the destination writer. The caller must call Close() on the returned
|
||||||
|
// writer to ensure all encrypted data is properly flushed and finalized.
|
||||||
|
// This is useful for integrating encryption into existing writer-based pipelines.
|
||||||
|
func (e *Encryptor) EncryptWriter(dst io.Writer) (io.WriteCloser, error) {
|
||||||
|
e.mu.RLock()
|
||||||
|
recipients := e.recipients
|
||||||
|
e.mu.RUnlock()
|
||||||
|
|
||||||
|
// Create encrypted writer for all recipients
|
||||||
|
w, err := age.Encrypt(dst, recipients...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("creating encrypted writer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRecipients updates the recipients for future encryption operations.
|
||||||
|
// This method is thread-safe and can be called while other encryption operations
|
||||||
|
// are in progress. Existing encryption operations will continue with the old
|
||||||
|
// recipients. At least one recipient must be provided. Returns an error if any
|
||||||
|
// of the public keys are invalid or if no recipients are specified.
|
||||||
|
func (e *Encryptor) UpdateRecipients(publicKeys []string) error {
|
||||||
|
if len(publicKeys) == 0 {
|
||||||
|
return ErrNoRecipients
|
||||||
|
}
|
||||||
|
|
||||||
|
recipients := make([]age.Recipient, 0, len(publicKeys))
|
||||||
|
for _, key := range publicKeys {
|
||||||
|
recipient, err := age.ParseX25519Recipient(key)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parsing age recipient %s: %w", key, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
recipients = append(recipients, recipient)
|
||||||
|
}
|
||||||
|
|
||||||
|
e.mu.Lock()
|
||||||
|
e.recipients = recipients
|
||||||
|
e.mu.Unlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decryptor provides thread-safe decryption using the age encryption library.
|
||||||
|
// It uses a private key to decrypt data that was encrypted for the corresponding
|
||||||
|
// public key.
|
||||||
|
type Decryptor struct {
|
||||||
|
identity age.Identity
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDecryptor creates a new decryptor with the given age private key.
|
||||||
|
// The private key should be a valid age X25519 identity string.
|
||||||
|
// Returns an error if the private key is invalid.
|
||||||
|
func NewDecryptor(privateKey string) (*Decryptor, error) {
|
||||||
|
identity, err := age.ParseX25519Identity(privateKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing age identity: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Decryptor{
|
||||||
|
identity: identity,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt decrypts data using age decryption.
|
||||||
|
// This method is suitable for small to medium amounts of data that fit in memory.
|
||||||
|
// For large data streams, use DecryptStream instead.
|
||||||
|
func (d *Decryptor) Decrypt(data []byte) ([]byte, error) {
|
||||||
|
d.mu.RLock()
|
||||||
|
identity := d.identity
|
||||||
|
d.mu.RUnlock()
|
||||||
|
|
||||||
|
r, err := age.Decrypt(bytes.NewReader(data), identity)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("creating decrypted reader: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypted, err := io.ReadAll(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading decrypted data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return decrypted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecryptStream returns a reader that decrypts data from the provided reader.
|
||||||
|
// This method is suitable for decrypting large files or streams as it processes
|
||||||
|
// data in a streaming fashion without loading everything into memory.
|
||||||
|
// The caller should close the input reader when done.
|
||||||
|
func (d *Decryptor) DecryptStream(src io.Reader) (io.Reader, error) {
|
||||||
|
d.mu.RLock()
|
||||||
|
identity := d.identity
|
||||||
|
d.mu.RUnlock()
|
||||||
|
|
||||||
|
r, err := age.Decrypt(src, identity)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("creating decrypted reader: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module exports the crypto module for fx dependency injection.
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals // fx module definitions are package globals
|
||||||
|
var Module = fx.Module("crypto")
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package crypto_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"filippo.io/age"
|
||||||
|
"sneak.berlin/go/vaultik/internal/crypto"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEncryptor(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Generate a test key pair
|
||||||
|
identity, err := age.GenerateX25519Identity()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to generate identity: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
publicKey := identity.Recipient().String()
|
||||||
|
|
||||||
|
// Create encryptor
|
||||||
|
enc, err := crypto.NewEncryptor([]string{publicKey})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create encryptor: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test data
|
||||||
|
plaintext := []byte("Hello, World! This is a test message.")
|
||||||
|
|
||||||
|
// Encrypt
|
||||||
|
ciphertext, err := enc.Encrypt(plaintext)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to encrypt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's actually encrypted (should be larger and different)
|
||||||
|
if bytes.Equal(plaintext, ciphertext) {
|
||||||
|
t.Error("ciphertext equals plaintext")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt to verify
|
||||||
|
r, err := age.Decrypt(bytes.NewReader(ciphertext), identity)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to decrypt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var decrypted bytes.Buffer
|
||||||
|
|
||||||
|
_, err = decrypted.ReadFrom(r)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read decrypted data: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(plaintext, decrypted.Bytes()) {
|
||||||
|
t.Error("decrypted data doesn't match original")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncryptorMultipleRecipients(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Generate three test key pairs
|
||||||
|
identity1, err := age.GenerateX25519Identity()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to generate identity1: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
identity2, err := age.GenerateX25519Identity()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to generate identity2: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
identity3, err := age.GenerateX25519Identity()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to generate identity3: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
publicKeys := []string{
|
||||||
|
identity1.Recipient().String(),
|
||||||
|
identity2.Recipient().String(),
|
||||||
|
identity3.Recipient().String(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create encryptor with multiple recipients
|
||||||
|
enc, err := crypto.NewEncryptor(publicKeys)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create encryptor: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test data
|
||||||
|
plaintext := []byte("Secret message for multiple recipients")
|
||||||
|
|
||||||
|
// Encrypt
|
||||||
|
ciphertext, err := enc.Encrypt(plaintext)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to encrypt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify each recipient can decrypt
|
||||||
|
identities := []age.Identity{identity1, identity2, identity3}
|
||||||
|
for i, identity := range identities {
|
||||||
|
r, err := age.Decrypt(bytes.NewReader(ciphertext), identity)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("recipient %d failed to decrypt: %v", i+1, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var decrypted bytes.Buffer
|
||||||
|
|
||||||
|
_, err = decrypted.ReadFrom(r)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("recipient %d failed to read decrypted data: %v", i+1, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(plaintext, decrypted.Bytes()) {
|
||||||
|
t.Errorf("recipient %d: decrypted data doesn't match original", i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncryptorUpdateRecipients(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Generate two identities
|
||||||
|
identity1, _ := age.GenerateX25519Identity()
|
||||||
|
identity2, _ := age.GenerateX25519Identity()
|
||||||
|
|
||||||
|
publicKey1 := identity1.Recipient().String()
|
||||||
|
publicKey2 := identity2.Recipient().String()
|
||||||
|
|
||||||
|
// Create encryptor with first key
|
||||||
|
enc, err := crypto.NewEncryptor([]string{publicKey1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create encryptor: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt with first key
|
||||||
|
plaintext := []byte("test data")
|
||||||
|
|
||||||
|
ciphertext1, err := enc.Encrypt(plaintext)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to encrypt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update to second key
|
||||||
|
err = enc.UpdateRecipients([]string{publicKey2})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to update recipients: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt with second key
|
||||||
|
ciphertext2, err := enc.Encrypt(plaintext)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to encrypt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First ciphertext should only decrypt with first identity
|
||||||
|
_, err = age.Decrypt(bytes.NewReader(ciphertext1), identity1)
|
||||||
|
if err != nil {
|
||||||
|
t.Error("failed to decrypt with identity1")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = age.Decrypt(bytes.NewReader(ciphertext1), identity2)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("should not decrypt with identity2")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second ciphertext should only decrypt with second identity
|
||||||
|
_, err = age.Decrypt(bytes.NewReader(ciphertext2), identity2)
|
||||||
|
if err != nil {
|
||||||
|
t.Error("failed to decrypt with identity2")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = age.Decrypt(bytes.NewReader(ciphertext2), identity1)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("should not decrypt with identity1")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -208,30 +208,6 @@ func (r *BlobRepository) DeleteOrphaned(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteUnuploaded deletes blob rows whose upload never completed
|
|
||||||
// (uploaded_ts IS NULL) and returns how many were removed. Their
|
|
||||||
// blob_chunks rows are removed by the ON DELETE CASCADE foreign key.
|
|
||||||
// A blob is only ever attached to a snapshot once its upload has been
|
|
||||||
// recorded, so an un-uploaded blob is never referenced by a completed
|
|
||||||
// snapshot: dropping it discards chunk rows that point at data which
|
|
||||||
// was never stored remotely, so the affected content is re-chunked and
|
|
||||||
// re-uploaded on the next run.
|
|
||||||
func (r *BlobRepository) DeleteUnuploaded(ctx context.Context) (int64, error) {
|
|
||||||
query := `DELETE FROM blobs WHERE uploaded_ts IS NULL`
|
|
||||||
|
|
||||||
result, err := r.db.ExecWithLog(ctx, query)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("deleting un-uploaded blobs: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
rowsAffected, _ := result.RowsAffected()
|
|
||||||
if rowsAffected > 0 {
|
|
||||||
log.Debug("Deleted un-uploaded blobs", "count", rowsAffected)
|
|
||||||
}
|
|
||||||
|
|
||||||
return rowsAffected, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getOne fetches a single blob row matched on the given column, or
|
// getOne fetches a single blob row matched on the given column, or
|
||||||
// (nil, nil) when no row matches.
|
// (nil, nil) when no row matches.
|
||||||
func (r *BlobRepository) getOne(
|
func (r *BlobRepository) getOne(
|
||||||
|
|||||||
@@ -7,32 +7,12 @@ import (
|
|||||||
|
|
||||||
// List returns every chunk in the index, ordered by chunk hash.
|
// 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) {
|
||||||
return r.list(ctx, `
|
query := `
|
||||||
SELECT chunk_hash, size
|
SELECT chunk_hash, size
|
||||||
FROM chunks
|
FROM chunks
|
||||||
ORDER BY chunk_hash
|
ORDER BY chunk_hash
|
||||||
`)
|
`
|
||||||
}
|
|
||||||
|
|
||||||
// ListInUploadedBlobs returns the chunks that are stored in a blob whose
|
|
||||||
// upload has completed (uploaded_ts set), ordered by chunk hash. These
|
|
||||||
// are the only chunks a backup may safely deduplicate against: a chunk
|
|
||||||
// recorded solely in a blob that was never uploaded refers to data that
|
|
||||||
// is not in remote storage, so trusting it would silently drop that data
|
|
||||||
// from later snapshots.
|
|
||||||
func (r *ChunkRepository) ListInUploadedBlobs(ctx context.Context) ([]*Chunk, error) {
|
|
||||||
return r.list(ctx, `
|
|
||||||
SELECT DISTINCT c.chunk_hash, c.size
|
|
||||||
FROM chunks c
|
|
||||||
JOIN blob_chunks bc ON c.chunk_hash = bc.chunk_hash
|
|
||||||
JOIN blobs b ON bc.blob_id = b.id
|
|
||||||
WHERE b.uploaded_ts IS NOT NULL
|
|
||||||
ORDER BY c.chunk_hash
|
|
||||||
`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// list runs a chunk-selecting query and scans the (chunk_hash, size) rows.
|
|
||||||
func (r *ChunkRepository) list(ctx context.Context, query string) ([]*Chunk, error) {
|
|
||||||
rows, err := r.db.conn.QueryContext(ctx, query)
|
rows, err := r.db.conn.QueryContext(ctx, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("querying chunks: %w", err)
|
return nil, fmt.Errorf("querying chunks: %w", err)
|
||||||
|
|||||||
@@ -3,10 +3,8 @@
|
|||||||
//
|
//
|
||||||
// Blobs in Vaultik are the final storage units uploaded to S3. Each blob is a
|
// Blobs in Vaultik are the final storage units uploaded to S3. Each blob is a
|
||||||
// large (up to 10GB) file containing many compressed and encrypted chunks from
|
// large (up to 10GB) file containing many compressed and encrypted chunks from
|
||||||
// multiple source files. Blobs are content-addressed: the filename in S3 is
|
// multiple source files. Blobs are content-addressed, meaning their filename
|
||||||
// hex(SHA256(SHA256(uncompressed blob contents))), computed from the chunk data
|
// is derived from their SHA256 hash after compression and encryption.
|
||||||
// before compression and encryption (not from the stored bytes). See
|
|
||||||
// blobgen.DoubleSHA256 and docs/REPOSTRUCTURE.md.
|
|
||||||
//
|
//
|
||||||
// Schema is managed via numbered SQL migrations embedded in the schema/
|
// Schema is managed via numbered SQL migrations embedded in the schema/
|
||||||
// directory. Migration 000.sql bootstraps the schema_migrations tracking
|
// directory. Migration 000.sql bootstraps the schema_migrations tracking
|
||||||
@@ -19,7 +17,6 @@ import (
|
|||||||
"embed"
|
"embed"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -222,135 +219,6 @@ func openWithRecovery(ctx context.Context, path string) (*DB, error) {
|
|||||||
return db, nil
|
return db, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// errUntrustedSnapshotSchema is returned when a downloaded snapshot
|
|
||||||
// database carries schema objects the real schema never defines, or is
|
|
||||||
// missing a table the restore and deep-verify queries read.
|
|
||||||
var errUntrustedSnapshotSchema = errors.New(
|
|
||||||
"downloaded snapshot database has an untrusted schema")
|
|
||||||
|
|
||||||
// snapshotReadOnlyDSN builds the driver DSN that opens a materialized
|
|
||||||
// snapshot database file read-only. mode=ro opens the file read-only at
|
|
||||||
// the OS level, query_only rejects any write the engine is asked to make,
|
|
||||||
// and trusted_schema=OFF refuses to run application code named in the
|
|
||||||
// schema. The file: URI form is required for the driver to honour the
|
|
||||||
// mode parameter.
|
|
||||||
func snapshotReadOnlyDSN(path string) string {
|
|
||||||
u := url.URL{
|
|
||||||
Scheme: "file",
|
|
||||||
Path: path,
|
|
||||||
RawQuery: "mode=ro&_pragma=query_only(true)&_pragma=trusted_schema(false)",
|
|
||||||
}
|
|
||||||
|
|
||||||
return u.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// OpenReadOnly opens an already-materialized SQLite file for read-only
|
|
||||||
// querying of a snapshot database downloaded from the store, used by
|
|
||||||
// restore and deep verify. Unlike New it never applies schema migrations
|
|
||||||
// and never writes: the connection is opened read-only with query_only
|
|
||||||
// and trusted_schema=OFF. It refuses any file whose schema carries a
|
|
||||||
// trigger, view or virtual table, or lacks an expected table, so a forged
|
|
||||||
// file cannot redefine what the restore queries return. The caller owns
|
|
||||||
// the file and must remove it.
|
|
||||||
func OpenReadOnly(ctx context.Context, path string) (*DB, error) {
|
|
||||||
conn, err := sql.Open("sqlite", snapshotReadOnlyDSN(path))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("opening read-only database: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
configureConnPool(conn)
|
|
||||||
|
|
||||||
err = conn.PingContext(ctx)
|
|
||||||
if err != nil {
|
|
||||||
_ = conn.Close()
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("opening read-only database: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = verifySnapshotSchema(ctx, conn)
|
|
||||||
if err != nil {
|
|
||||||
_ = conn.Close()
|
|
||||||
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &DB{conn: conn, path: path}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// verifySnapshotSchema rejects a downloaded database whose schema is not
|
|
||||||
// the plain table set the real schema defines. Any trigger, view or
|
|
||||||
// virtual table, or a missing expected table, fails the open.
|
|
||||||
func verifySnapshotSchema(ctx context.Context, conn *sql.DB) error {
|
|
||||||
// expectedSnapshotTables are the tables the restore and deep-verify
|
|
||||||
// queries read. A downloaded database missing any of them is not a
|
|
||||||
// genuine snapshot database and is refused.
|
|
||||||
expectedSnapshotTables := []string{
|
|
||||||
"blob_chunks",
|
|
||||||
"blobs",
|
|
||||||
"chunks",
|
|
||||||
"file_chunks",
|
|
||||||
"files",
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := conn.QueryContext(
|
|
||||||
ctx, "SELECT type, name, sql FROM sqlite_master")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("reading snapshot schema: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = rows.Close() }()
|
|
||||||
|
|
||||||
present := make(map[string]struct{})
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
var objType, name string
|
|
||||||
|
|
||||||
var objSQL sql.NullString
|
|
||||||
|
|
||||||
err = rows.Scan(&objType, &name, &objSQL)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("reading snapshot schema: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch objType {
|
|
||||||
case "trigger", "view":
|
|
||||||
return fmt.Errorf(
|
|
||||||
"%w: unexpected %s %q", errUntrustedSnapshotSchema, objType, name)
|
|
||||||
case "table":
|
|
||||||
if isVirtualTableSQL(objSQL.String) {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"%w: unexpected virtual table %q",
|
|
||||||
errUntrustedSnapshotSchema, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
present[name] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = rows.Err()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("reading snapshot schema: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, table := range expectedSnapshotTables {
|
|
||||||
if _, ok := present[table]; !ok {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"%w: missing table %q", errUntrustedSnapshotSchema, table)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// isVirtualTableSQL reports whether a sqlite_master row's SQL defines a
|
|
||||||
// virtual table. Virtual tables are recorded with type 'table' but a
|
|
||||||
// "CREATE VIRTUAL TABLE" definition and can run module code, so they are
|
|
||||||
// refused alongside triggers and views.
|
|
||||||
func isVirtualTableSQL(createSQL string) bool {
|
|
||||||
return strings.HasPrefix(
|
|
||||||
strings.ToUpper(strings.TrimSpace(createSQL)), "CREATE VIRTUAL TABLE")
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewTestDB creates an in-memory SQLite database for testing purposes.
|
// NewTestDB creates an in-memory SQLite database for testing purposes.
|
||||||
// The database is automatically initialized with the schema and is ready
|
// The database is automatically initialized with the schema and is ready
|
||||||
// for use. Each call creates a new independent database instance.
|
// for use. Each call creates a new independent database instance.
|
||||||
|
|||||||
@@ -51,15 +51,15 @@ type Chunk struct {
|
|||||||
// Blob represents a blob record in the database.
|
// Blob represents a blob record in the database.
|
||||||
// A blob is Vaultik's final storage unit - a large file (up to 10GB) containing
|
// A blob is Vaultik's final storage unit - a large file (up to 10GB) containing
|
||||||
// many compressed and encrypted chunks from multiple source files.
|
// many compressed and encrypted chunks from multiple source files.
|
||||||
// Blobs are content-addressed: the filename in S3 is
|
// Blobs are content-addressed, meaning their filename in S3 is derived from
|
||||||
// hex(SHA256(SHA256(uncompressed blob contents))), computed from the chunk data
|
// the SHA256 hash of their compressed and encrypted content.
|
||||||
// before compression and encryption (not from the stored bytes). See
|
// The blob creation process is: chunks are accumulated -> compressed with zstd
|
||||||
// blobgen.DoubleSHA256 and docs/REPOSTRUCTURE.md.
|
// -> 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 is hex(SHA256(SHA256(uncompressed blob contents)))
|
// Hash is the SHA256 of the final compressed+encrypted content
|
||||||
// (empty until finalized); see the type comment above.
|
// (empty until finalized).
|
||||||
Hash types.BlobHash
|
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)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package database
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -16,44 +15,6 @@ import (
|
|||||||
// the index describes the backed-up file tree and must stay private.
|
// the index describes the backed-up file tree and must stay private.
|
||||||
const indexDirPerm = 0o700
|
const indexDirPerm = 0o700
|
||||||
|
|
||||||
// indexFilePerm restricts the index file to the owning user; it lists every
|
|
||||||
// backed-up path and chunk hash and must stay private.
|
|
||||||
const indexFilePerm = 0o600
|
|
||||||
|
|
||||||
// ensureIndexFileMode makes the index file owner-only before the SQLite
|
|
||||||
// driver opens it: it creates the file 0600 if absent, or chmods an existing
|
|
||||||
// one to 0600. Doing this first matters because SQLite creates its -wal and
|
|
||||||
// -shm side files with the mode of the main database file, so a private main
|
|
||||||
// file yields private side files. The driver treats a zero-byte file as an
|
|
||||||
// empty database, so pre-creating it here is safe.
|
|
||||||
func ensureIndexFileMode(path string) error {
|
|
||||||
info, err := os.Stat(path)
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case err == nil:
|
|
||||||
if info.Mode().Perm() == indexFilePerm {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
err = os.Chmod(path, indexFilePerm)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("restricting index file permissions: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
case errors.Is(err, os.ErrNotExist):
|
|
||||||
//nolint:gosec // G304: the index path is operator-configured by design
|
|
||||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, indexFilePerm)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("creating index file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return f.Close()
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("checking index file: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Module provides database dependencies
|
// Module provides database dependencies
|
||||||
//
|
//
|
||||||
//nolint:gochecknoglobals // fx module definitions are package globals by convention
|
//nolint:gochecknoglobals // fx module definitions are package globals by convention
|
||||||
@@ -73,11 +34,6 @@ func provideDatabase(lc fx.Lifecycle, cfg *config.Config) (*DB, error) {
|
|||||||
return nil, fmt.Errorf("creating index directory: %w", err)
|
return nil, fmt.Errorf("creating index directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = ensureIndexFileMode(cfg.IndexPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
db, err := New(context.Background(), cfg.IndexPath)
|
db, err := New(context.Background(), cfg.IndexPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("opening database: %w", err)
|
return nil, fmt.Errorf("opening database: %w", err)
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"syscall"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"go.uber.org/fx/fxtest"
|
|
||||||
"sneak.berlin/go/vaultik/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestProvideDatabaseFreshIndexMode verifies that provideDatabase creates a
|
|
||||||
// missing index file owner-only (0600), even under a lenient 022 umask that
|
|
||||||
// would otherwise leave a freshly created file world-readable.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // syscall.Umask is process-global; parallel tests would clash
|
|
||||||
func TestProvideDatabaseFreshIndexMode(t *testing.T) {
|
|
||||||
restore := syscall.Umask(0o022)
|
|
||||||
defer syscall.Umask(restore)
|
|
||||||
|
|
||||||
indexPath := filepath.Join(t.TempDir(), "index.sqlite")
|
|
||||||
|
|
||||||
openIndex(t, indexPath)
|
|
||||||
assertPerm(t, indexPath, 0o600)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestProvideDatabaseExistingIndexMode verifies that provideDatabase tightens
|
|
||||||
// an existing world-readable index (0644) in a group/other-readable directory
|
|
||||||
// down to owner-only (0600).
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // syscall.Umask is process-global; parallel tests would clash
|
|
||||||
func TestProvideDatabaseExistingIndexMode(t *testing.T) {
|
|
||||||
restore := syscall.Umask(0o022)
|
|
||||||
defer syscall.Umask(restore)
|
|
||||||
|
|
||||||
dir := filepath.Join(t.TempDir(), "data")
|
|
||||||
|
|
||||||
//nolint:gosec // G301: the test intentionally uses a 0755 directory
|
|
||||||
err := os.MkdirAll(dir, 0o755)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("creating index directory: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
//nolint:gosec // G302: the test intentionally uses a 0755 directory
|
|
||||||
err = os.Chmod(dir, 0o755)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("relaxing index directory permissions: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
indexPath := filepath.Join(dir, "index.sqlite")
|
|
||||||
|
|
||||||
//nolint:gosec // G306: the test intentionally starts from a 0644 index
|
|
||||||
err = os.WriteFile(indexPath, nil, 0o644)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("creating pre-existing index: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
//nolint:gosec // G302: the test intentionally starts from a 0644 index
|
|
||||||
err = os.Chmod(indexPath, 0o644)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("relaxing pre-existing index permissions: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
openIndex(t, indexPath)
|
|
||||||
assertPerm(t, indexPath, 0o600)
|
|
||||||
}
|
|
||||||
|
|
||||||
// openIndex runs provideDatabase against indexPath and closes the resulting
|
|
||||||
// database before returning.
|
|
||||||
func openIndex(t *testing.T, indexPath string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
cfg := &config.Config{IndexPath: indexPath}
|
|
||||||
|
|
||||||
db, err := provideDatabase(fxtest.NewLifecycle(t), cfg)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("provideDatabase: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = db.Close()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("closing database: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertPerm fails the test unless path has exactly the given permission bits.
|
|
||||||
func assertPerm(t *testing.T, path string, want os.FileMode) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
info, err := os.Stat(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("stat %s: %v", path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
got := info.Mode().Perm()
|
|
||||||
if got != want {
|
|
||||||
t.Fatalf("permissions of %s = %#o, want %#o", path, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
//nolint:testpackage // exercises unexported read-only open internals
|
|
||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"errors"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// genuineSnapshotDB writes a real snapshot database (the full schema
|
|
||||||
// applied) to a fresh file and returns its path.
|
|
||||||
func genuineSnapshotDB(t *testing.T) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "snapshot.db")
|
|
||||||
|
|
||||||
db, err := New(context.Background(), path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("creating snapshot database: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = db.Close()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("closing snapshot database: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
// forgedDB creates an empty database file and runs the given statements
|
|
||||||
// against it read-write, so a test can plant schema objects the real
|
|
||||||
// schema never defines.
|
|
||||||
func forgedDB(t *testing.T, stmts ...string) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "forged.db")
|
|
||||||
|
|
||||||
db, err := sql.Open("sqlite", path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("opening forged database: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, stmt := range stmts {
|
|
||||||
_, err = db.ExecContext(context.Background(), stmt)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("executing %q: %v", stmt, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = db.Close()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("closing forged database: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOpenReadOnlyAcceptsGenuineSnapshot(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
db, err := OpenReadOnly(context.Background(), genuineSnapshotDB(t))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("OpenReadOnly refused a genuine snapshot database: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Cleanup(func() { _ = db.Close() })
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOpenReadOnlyRefusesWrites(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
db, err := OpenReadOnly(context.Background(), genuineSnapshotDB(t))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("OpenReadOnly: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Cleanup(func() { _ = db.Close() })
|
|
||||||
|
|
||||||
// A schema write depends on no table columns, so the only reason it
|
|
||||||
// can fail is that the database is open read-only.
|
|
||||||
_, err = db.Conn().ExecContext(context.Background(),
|
|
||||||
"CREATE TABLE probe_readonly (x)")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected a write to a read-only snapshot database to fail")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOpenReadOnlyRejectsView(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
path := forgedDB(t, "CREATE VIEW files AS SELECT 1 AS path")
|
|
||||||
|
|
||||||
_, err := OpenReadOnly(context.Background(), path)
|
|
||||||
if !errors.Is(err, errUntrustedSnapshotSchema) {
|
|
||||||
t.Fatalf("expected a view named files to be refused, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOpenReadOnlyRejectsTrigger(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
path := forgedDB(t,
|
|
||||||
"CREATE TABLE files (path TEXT)",
|
|
||||||
"CREATE TRIGGER t AFTER INSERT ON files BEGIN SELECT 1; END")
|
|
||||||
|
|
||||||
_, err := OpenReadOnly(context.Background(), path)
|
|
||||||
if !errors.Is(err, errUntrustedSnapshotSchema) {
|
|
||||||
t.Fatalf("expected a trigger to be refused, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOpenReadOnlyRejectsMissingTable(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Only one of the expected tables is present.
|
|
||||||
path := forgedDB(t, "CREATE TABLE files (path TEXT)")
|
|
||||||
|
|
||||||
_, err := OpenReadOnly(context.Background(), path)
|
|
||||||
if !errors.Is(err, errUntrustedSnapshotSchema) {
|
|
||||||
t.Fatalf("expected a missing expected table to be refused, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsVirtualTableSQL(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
sql string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{"CREATE VIRTUAL TABLE t USING fts5(x)", true},
|
|
||||||
{" create virtual table t using fts5(x)", true},
|
|
||||||
{"CREATE TABLE t (x)", false},
|
|
||||||
{"CREATE VIEW t AS SELECT 1", false},
|
|
||||||
{"", false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, c := range cases {
|
|
||||||
if got := isVirtualTableSQL(c.sql); got != c.want {
|
|
||||||
t.Errorf("isVirtualTableSQL(%q) = %v, want %v", c.sql, got, c.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,18 +11,6 @@ import (
|
|||||||
"sneak.berlin/go/vaultik/internal/types"
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sentinel errors for the single-snapshot invariant that an exported
|
|
||||||
// per-snapshot metadata database must satisfy.
|
|
||||||
var (
|
|
||||||
// ErrNoSnapshotInDatabase means the metadata database has no snapshot
|
|
||||||
// row at all.
|
|
||||||
ErrNoSnapshotInDatabase = errors.New("database contains no snapshot")
|
|
||||||
// ErrMultipleSnapshotsInDatabase means the metadata database holds
|
|
||||||
// more than the single snapshot an export is supposed to contain.
|
|
||||||
ErrMultipleSnapshotsInDatabase = errors.New(
|
|
||||||
"database contains more than one snapshot")
|
|
||||||
)
|
|
||||||
|
|
||||||
// SnapshotRepository provides access to the snapshots table and its
|
// SnapshotRepository provides access to the snapshots table and its
|
||||||
// snapshot_files / snapshot_blobs association tables.
|
// snapshot_files / snapshot_blobs association tables.
|
||||||
type SnapshotRepository struct {
|
type SnapshotRepository struct {
|
||||||
@@ -218,48 +206,6 @@ func (r *SnapshotRepository) GetByID(
|
|||||||
return &snapshot, nil
|
return &snapshot, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetOnlySnapshot returns the sole snapshot in an exported per-snapshot
|
|
||||||
// metadata database. The backup path writes each snapshot's database with
|
|
||||||
// exactly one snapshot row (see cleanSnapshotDB), so restore and deep
|
|
||||||
// verify expect exactly one. Zero rows return ErrNoSnapshotInDatabase and
|
|
||||||
// more than one returns ErrMultipleSnapshotsInDatabase; callers treat
|
|
||||||
// either as a failed identity check on the downloaded database.
|
|
||||||
func (r *SnapshotRepository) GetOnlySnapshot(ctx context.Context) (*Snapshot, error) {
|
|
||||||
query := `
|
|
||||||
SELECT id, hostname, vaultik_version, vaultik_git_revision,
|
|
||||||
started_at, completed_at, file_count, chunk_count, blob_count,
|
|
||||||
total_size, blob_size, compression_ratio
|
|
||||||
FROM snapshots
|
|
||||||
LIMIT 2
|
|
||||||
`
|
|
||||||
|
|
||||||
rows, err := r.db.conn.QueryContext(ctx, query)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("querying snapshots: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
err := rows.Close()
|
|
||||||
if err != nil {
|
|
||||||
Fatalf("failed to close rows: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
snapshots, err := r.scanSnapshotRows(rows)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
switch len(snapshots) {
|
|
||||||
case 1:
|
|
||||||
return snapshots[0], nil
|
|
||||||
case 0:
|
|
||||||
return nil, ErrNoSnapshotInDatabase
|
|
||||||
default:
|
|
||||||
return nil, ErrMultipleSnapshotsInDatabase
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListRecent returns up to limit snapshots, most recently started first.
|
// ListRecent returns up to limit snapshots, most recently started first.
|
||||||
func (r *SnapshotRepository) ListRecent(
|
func (r *SnapshotRepository) ListRecent(
|
||||||
ctx context.Context, limit int,
|
ctx context.Context, limit int,
|
||||||
|
|||||||
@@ -3,23 +3,14 @@
|
|||||||
package globals
|
package globals
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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 = "vaultik" //nolint:gochecknoglobals // set via -ldflags at build time
|
||||||
|
|
||||||
// DevVersion is the version a binary reports when it was not built
|
|
||||||
// from a tagged commit. script/version emits either this exact string
|
|
||||||
// (outside a git checkout) or this string followed by "-" and the
|
|
||||||
// commit it was built from, and goreleaser's snapshot template matches
|
|
||||||
// that shape. It is deliberately not a number: a build that is not a
|
|
||||||
// release must not name itself like one.
|
|
||||||
const DevVersion = "dev"
|
|
||||||
|
|
||||||
// Version is the application version, populated from main().
|
// Version is the application version, populated from main().
|
||||||
var Version = DevVersion //nolint:gochecknoglobals // set via -ldflags at build time
|
var Version = "dev" //nolint:gochecknoglobals // set via -ldflags at build time
|
||||||
|
|
||||||
// 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 = "unknown" //nolint:gochecknoglobals // set via -ldflags at build time
|
||||||
@@ -33,9 +24,6 @@ const Author = "Jeffrey Paul <sneak@sneak.berlin>"
|
|||||||
// Homepage is the canonical URL for vaultik.
|
// Homepage is the canonical URL for vaultik.
|
||||||
const Homepage = "https://sneak.berlin/go/vaultik"
|
const Homepage = "https://sneak.berlin/go/vaultik"
|
||||||
|
|
||||||
// ReleasesURL is where tagged release artifacts are published.
|
|
||||||
const ReleasesURL = "https://git.eeqj.de/sneak/vaultik/releases"
|
|
||||||
|
|
||||||
// License is the SPDX identifier for the project license.
|
// License is the SPDX identifier for the project license.
|
||||||
const License = "MIT"
|
const License = "MIT"
|
||||||
|
|
||||||
@@ -59,21 +47,6 @@ func New() (*Globals, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsDevVersion reports whether v names a development build rather than
|
|
||||||
// a release. Both "dev" and "dev-<sha>" (and its "-dirty" variant)
|
|
||||||
// count: a caller that compares against "dev" exactly would treat every
|
|
||||||
// commit-stamped development build as a release.
|
|
||||||
//
|
|
||||||
// The empty string counts too. Nothing that knows its version reports
|
|
||||||
// no version, so an empty Version means the stamping failed, and the
|
|
||||||
// safe reading of "we could not establish that this is a release" is
|
|
||||||
// that it is not one. The Makefile refuses to build at all in that
|
|
||||||
// case; this is the second line of defence, for a binary linked by
|
|
||||||
// something other than the Makefile.
|
|
||||||
func IsDevVersion(v string) bool {
|
|
||||||
return v == "" || v == DevVersion || strings.HasPrefix(v, DevVersion+"-")
|
|
||||||
}
|
|
||||||
|
|
||||||
// shortCommitLen is the number of commit-hash characters ShortCommit keeps.
|
// shortCommitLen is the number of commit-hash characters ShortCommit keeps.
|
||||||
const shortCommitLen = 12
|
const shortCommitLen = 12
|
||||||
|
|
||||||
|
|||||||
@@ -32,56 +32,3 @@ func TestGlobalsNew(t *testing.T) {
|
|||||||
t.Error("Commit should not be empty")
|
t.Error("Commit should not be empty")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestIsDevVersion covers the boundary that matters: everything
|
|
||||||
// script/version and goreleaser's snapshot template can emit for an
|
|
||||||
// untagged build must be recognised as a development build, and a real
|
|
||||||
// tag must not be. A plain equality check against "dev" used to decide
|
|
||||||
// this, which classified every commit-stamped dev build as a release.
|
|
||||||
func TestIsDevVersion(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
version string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
// What an untagged build produces.
|
|
||||||
{"dev", true},
|
|
||||||
{"dev-b6e4a218a39e", true},
|
|
||||||
{"dev-b6e4a218a39e-dirty", true},
|
|
||||||
// What a tagged build produces (script/version strips the
|
|
||||||
// leading "v", matching goreleaser's .Version).
|
|
||||||
{"1.0.0", false},
|
|
||||||
{"0.1.0", false},
|
|
||||||
{"1.0.0-rc.1", false},
|
|
||||||
{"v1.0.0", false},
|
|
||||||
// A release must not be mistaken for a dev build just because
|
|
||||||
// the string happens to contain "dev".
|
|
||||||
{"1.0.0-dev", false},
|
|
||||||
{"developer", false},
|
|
||||||
// A binary with no version string at all did not get stamped,
|
|
||||||
// which is a build failure, not a release. It must never print
|
|
||||||
// as one. The Makefile refuses to build when script/version
|
|
||||||
// yields nothing; this covers a binary linked some other way.
|
|
||||||
{"", true},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
if got := globals.IsDevVersion(tc.version); got != tc.want {
|
|
||||||
t.Errorf("IsDevVersion(%q) = %v, want %v", tc.version, got, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDefaultVersionIsDev pins the linker-flag contract: an unstamped
|
|
||||||
// binary (no -ldflags at all, which is what `go build ./...` and `go
|
|
||||||
// install` produce) must report itself as a development build rather
|
|
||||||
// than as some default release number.
|
|
||||||
func TestDefaultVersionIsDev(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
if !globals.IsDevVersion(globals.DevVersion) {
|
|
||||||
t.Errorf("DevVersion %q is not recognised as a dev version",
|
|
||||||
globals.DevVersion)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+5
-23
@@ -1,9 +1,5 @@
|
|||||||
// Package log provides the application-wide structured logger: slog
|
// Package log provides the application-wide structured logger: slog
|
||||||
// writing to stderr, with a colorized TTY handler when stderr is a
|
// with a colorized TTY handler on terminals and JSON output otherwise.
|
||||||
// terminal and JSON output otherwise.
|
|
||||||
//
|
|
||||||
// Everything this package emits is a diagnostic, so it all goes to
|
|
||||||
// stderr. stdout belongs to the output the user asked for.
|
|
||||||
package log //nolint:revive,nolintlint // stdlib log unused here; see #76
|
package log //nolint:revive,nolintlint // stdlib log unused here; see #76
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -73,27 +69,13 @@ func Initialize(cfg Config) {
|
|||||||
Level: level,
|
Level: level,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Diagnostics go to stderr, never to stdout. stdout is reserved for
|
// Check if stdout is a TTY.
|
||||||
// the output the user asked for: every --json subcommand writes its
|
if term.IsTerminal(int(os.Stdout.Fd())) {
|
||||||
// document there, and WARN/ERROR are never suppressed, so a logger
|
|
||||||
// on stdout puts log records inside that document and makes it
|
|
||||||
// unparseable. A config file with group- or world-readable
|
|
||||||
// permissions is enough to trigger it (see internal/config), so this
|
|
||||||
// was not a theoretical collision.
|
|
||||||
//
|
|
||||||
// The format is chosen by the TTY-ness of the stream the records
|
|
||||||
// actually land on. AGENTS.md policy 9 says "if stdout is not a
|
|
||||||
// terminal, emit jsonl"; it says stdout because that is where logs
|
|
||||||
// used to go, and the property it is really asking for is that
|
|
||||||
// output nobody is watching be machine-readable. Testing stdout here
|
|
||||||
// would colorize records on a redirected stderr whenever stdout
|
|
||||||
// happened to be a terminal, and vice versa.
|
|
||||||
if term.IsTerminal(int(os.Stderr.Fd())) {
|
|
||||||
// Use colorized TTY handler
|
// Use colorized TTY handler
|
||||||
logger = slog.New(NewTTYHandler(os.Stderr, opts))
|
logger = slog.New(NewTTYHandler(os.Stdout, opts))
|
||||||
} else {
|
} else {
|
||||||
// Use JSON format for non-TTY output
|
// Use JSON format for non-TTY output
|
||||||
logger = slog.New(slog.NewJSONHandler(os.Stderr, opts))
|
logger = slog.New(slog.NewJSONHandler(os.Stdout, opts))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set as default logger
|
// Set as default logger
|
||||||
|
|||||||
+1
-15
@@ -14,18 +14,8 @@ var Module = fx.Module("log",
|
|||||||
)
|
)
|
||||||
|
|
||||||
// New creates a new logger configuration from provided options.
|
// New creates a new logger configuration from provided options.
|
||||||
//
|
|
||||||
// JSON is intentionally not carried into Config: a command emitting a
|
|
||||||
// JSON document on stdout must keep its stderr log level under
|
|
||||||
// --verbose/--debug, so --json must not lower it (issue #112). JSON
|
|
||||||
// silences the stdout UI in setupGlobals instead.
|
|
||||||
func New(opts Options) Config {
|
func New(opts Options) Config {
|
||||||
return Config{
|
return Config(opts)
|
||||||
Verbose: opts.Verbose,
|
|
||||||
Debug: opts.Debug,
|
|
||||||
Cron: opts.Cron,
|
|
||||||
Quiet: opts.Quiet,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Options are provided by the CLI.
|
// Options are provided by the CLI.
|
||||||
@@ -34,8 +24,4 @@ type Options struct {
|
|||||||
Debug bool
|
Debug bool
|
||||||
Cron bool
|
Cron bool
|
||||||
Quiet bool
|
Quiet bool
|
||||||
// JSON marks a command whose stdout carries a machine-readable
|
|
||||||
// document. It silences the human UI on stdout (see setupGlobals),
|
|
||||||
// but unlike Quiet it leaves the stderr log level alone.
|
|
||||||
JSON bool
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
package log_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"log/slog"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestTTYHandlerEscapesControlCharacters logs a message and an attribute
|
|
||||||
// value that each carry an ESC and a newline — the shape a crafted path or
|
|
||||||
// storage error from the destination would take — and checks neither raw
|
|
||||||
// byte reaches the output. The handler's own colour codes (ESC ... m) are
|
|
||||||
// stripped first; any ESC left after that came from the untrusted value.
|
|
||||||
func TestTTYHandlerEscapesControlCharacters(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
|
|
||||||
logger := slog.New(log.NewTTYHandler(&buf, debugHandlerOptions()))
|
|
||||||
logger.Info("start\x1b[31mZAP\nend", "target", "a\x1b[31mZAP\nb")
|
|
||||||
|
|
||||||
out := buf.String()
|
|
||||||
|
|
||||||
// The only newline is the line terminator; the injected ones were escaped.
|
|
||||||
require.Equal(t, 1, strings.Count(out, "\n"),
|
|
||||||
"a newline in the message or a value must be escaped, not emitted raw")
|
|
||||||
|
|
||||||
// After the handler's own colour codes are removed, no ESC survives.
|
|
||||||
stripped := ansiEscape.ReplaceAllString(out, "")
|
|
||||||
require.NotContains(t, stripped, "\x1b",
|
|
||||||
"a raw ESC from the message or a value must not reach the terminal")
|
|
||||||
|
|
||||||
// The escaped form is what appears instead.
|
|
||||||
require.Contains(t, out, `\x1b`)
|
|
||||||
}
|
|
||||||
+30
-197
@@ -5,36 +5,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// groupSeparator joins an open group path to an attribute key. This
|
|
||||||
// format has no nesting, so a group becomes a dotted key prefix:
|
|
||||||
// slog.New(h).WithGroup("db").With("rows", 3) renders "db.rows=3".
|
|
||||||
const groupSeparator = "."
|
|
||||||
|
|
||||||
// bytesAttrKey is the attribute key whose int64 value is rendered as a
|
|
||||||
// human-readable byte count rather than a bare number. Keys reaching
|
|
||||||
// writeAttr are group-qualified, so the match is made against the final
|
|
||||||
// dot-separated segment: without that, a "bytes" attribute logged under
|
|
||||||
// an open group would arrive as "transfer.bytes" and silently lose its
|
|
||||||
// formatting.
|
|
||||||
const bytesAttrKey = "bytes"
|
|
||||||
|
|
||||||
// isBytesAttr reports whether a group-qualified attribute key names the
|
|
||||||
// byte-count attribute, i.e. whether its last segment is bytesAttrKey.
|
|
||||||
func isBytesAttr(key string) bool {
|
|
||||||
if idx := strings.LastIndex(key, groupSeparator); idx >= 0 {
|
|
||||||
key = key[idx+len(groupSeparator):]
|
|
||||||
}
|
|
||||||
|
|
||||||
return key == bytesAttrKey
|
|
||||||
}
|
|
||||||
|
|
||||||
// ANSI color codes
|
// ANSI color codes
|
||||||
const (
|
const (
|
||||||
colorReset = "\033[0m"
|
colorReset = "\033[0m"
|
||||||
@@ -48,26 +22,10 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// TTYHandler is a custom slog handler for TTY output with colors.
|
// TTYHandler is a custom slog handler for TTY output with colors.
|
||||||
//
|
|
||||||
// A handler and the handlers derived from it via WithAttrs/WithGroup
|
|
||||||
// all write to the same stream, so they share one mutex; that is why mu
|
|
||||||
// is a pointer. A value mutex would give every derived handler its own
|
|
||||||
// lock and stop serializing writes to the stream they have in common.
|
|
||||||
type TTYHandler struct {
|
type TTYHandler struct {
|
||||||
opts slog.HandlerOptions
|
opts slog.HandlerOptions
|
||||||
mu *sync.Mutex
|
mu sync.Mutex
|
||||||
out io.Writer
|
out io.Writer
|
||||||
|
|
||||||
// attrs are the attributes accumulated through WithAttrs, emitted
|
|
||||||
// ahead of each record's own attributes. Their keys already carry
|
|
||||||
// the group path that was open when they were added, so no
|
|
||||||
// qualification happens at write time.
|
|
||||||
attrs []slog.Attr
|
|
||||||
|
|
||||||
// groups is the group path opened by WithGroup, applied as a key
|
|
||||||
// prefix to attributes that arrive later — both on a record and
|
|
||||||
// through a further WithAttrs.
|
|
||||||
groups []string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTTYHandler creates a new TTY handler with colored output.
|
// NewTTYHandler creates a new TTY handler with colored output.
|
||||||
@@ -79,7 +37,6 @@ func NewTTYHandler(out io.Writer, opts *slog.HandlerOptions) *TTYHandler {
|
|||||||
return &TTYHandler{
|
return &TTYHandler{
|
||||||
out: out,
|
out: out,
|
||||||
opts: *opts,
|
opts: *opts,
|
||||||
mu: &sync.Mutex{},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,29 +75,36 @@ func (h *TTYHandler) Handle(_ context.Context, r slog.Record) error {
|
|||||||
levelColor = colorReset
|
levelColor = colorReset
|
||||||
}
|
}
|
||||||
|
|
||||||
// Print main message. The message is escaped before the colour codes
|
// Print main message
|
||||||
// are written around it: it can carry text from an untrusted source
|
|
||||||
// (a storage error, for one), and a raw control character would
|
|
||||||
// otherwise reach the terminal.
|
|
||||||
_, _ = fmt.Fprintf(h.out, "%s%s%s %s%s%s %s%s%s",
|
_, _ = fmt.Fprintf(h.out, "%s%s%s %s%s%s %s%s%s",
|
||||||
colorGray, timestamp, colorReset,
|
colorGray, timestamp, colorReset,
|
||||||
levelColor, level, colorReset,
|
levelColor, level, colorReset,
|
||||||
colorBold, sanitize(r.Message), colorReset)
|
colorBold, r.Message, colorReset)
|
||||||
|
|
||||||
// Attributes carried by the handler come first, then the record's
|
|
||||||
// own. Handler attributes were qualified when they were added; the
|
|
||||||
// record's are qualified now, against whatever group path is open.
|
|
||||||
for _, a := range h.attrs {
|
|
||||||
h.writeAttr(a)
|
|
||||||
}
|
|
||||||
|
|
||||||
prefix := strings.Join(h.groups, groupSeparator)
|
|
||||||
|
|
||||||
|
// Print attributes
|
||||||
r.Attrs(func(a slog.Attr) bool {
|
r.Attrs(func(a slog.Attr) bool {
|
||||||
for _, flat := range appendAttr(nil, prefix, a) {
|
value := a.Value.String()
|
||||||
h.writeAttr(flat)
|
// Special handling for certain attribute types
|
||||||
|
switch a.Value.Kind() {
|
||||||
|
case slog.KindDuration:
|
||||||
|
if d, ok := a.Value.Any().(time.Duration); ok {
|
||||||
|
value = formatDuration(d)
|
||||||
|
}
|
||||||
|
case slog.KindInt64:
|
||||||
|
if a.Key == "bytes" {
|
||||||
|
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",
|
||||||
|
colorCyan, a.Key, colorReset,
|
||||||
|
colorBlue, value, colorReset)
|
||||||
|
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -149,145 +113,14 @@ func (h *TTYHandler) Handle(_ context.Context, r slog.Record) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// appendAttr flattens a into dst, folding prefix into its key and
|
// WithAttrs returns a new handler with the given attributes.
|
||||||
// expanding group values into further dotted keys. Following the
|
func (h *TTYHandler) WithAttrs(_ []slog.Attr) slog.Handler {
|
||||||
// slog.Handler contract: an empty Attr is dropped, a group with no
|
return h // Simplified for now
|
||||||
// attributes is dropped, and a group with an empty key is inlined into
|
|
||||||
// its parent rather than contributing a level.
|
|
||||||
func appendAttr(dst []slog.Attr, prefix string, a slog.Attr) []slog.Attr {
|
|
||||||
a.Value = a.Value.Resolve()
|
|
||||||
|
|
||||||
if a.Equal(slog.Attr{}) {
|
|
||||||
return dst
|
|
||||||
}
|
|
||||||
|
|
||||||
key := a.Key
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case prefix == "":
|
|
||||||
// key stands alone.
|
|
||||||
case key == "":
|
|
||||||
key = prefix
|
|
||||||
default:
|
|
||||||
key = prefix + groupSeparator + key
|
|
||||||
}
|
|
||||||
|
|
||||||
if a.Value.Kind() != slog.KindGroup {
|
|
||||||
return append(dst, slog.Attr{Key: key, Value: a.Value})
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, member := range a.Value.Group() {
|
|
||||||
dst = appendAttr(dst, key, member)
|
|
||||||
}
|
|
||||||
|
|
||||||
return dst
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithAttrs returns a new handler that emits attrs on every record it
|
// WithGroup returns a new handler with the given group name.
|
||||||
// handles, in addition to whatever the handler already carried. Keys
|
func (h *TTYHandler) WithGroup(_ string) slog.Handler {
|
||||||
// are qualified by the group path open at the time of the call, so
|
return h // Simplified for now
|
||||||
// WithGroup("db").WithAttrs(rows=3) later renders "db.rows=3".
|
|
||||||
//
|
|
||||||
// The receiver is not modified.
|
|
||||||
func (h *TTYHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
|
||||||
if len(attrs) == 0 {
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
|
|
||||||
prefix := strings.Join(h.groups, groupSeparator)
|
|
||||||
next := h.clone()
|
|
||||||
|
|
||||||
for _, a := range attrs {
|
|
||||||
next.attrs = appendAttr(next.attrs, prefix, a)
|
|
||||||
}
|
|
||||||
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithGroup returns a new handler that qualifies every subsequent
|
|
||||||
// attribute key with name. This format is a single line with nowhere to
|
|
||||||
// nest, so grouping is rendered as a dotted key prefix: after
|
|
||||||
// WithGroup("db"), an attribute "rows" is emitted as "db.rows".
|
|
||||||
//
|
|
||||||
// An empty name returns the receiver unchanged, per the slog.Handler
|
|
||||||
// contract. The receiver is not modified.
|
|
||||||
func (h *TTYHandler) WithGroup(name string) slog.Handler {
|
|
||||||
if name == "" {
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
|
|
||||||
next := h.clone()
|
|
||||||
next.groups = append(next.groups, name)
|
|
||||||
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
// clone returns a copy of h that shares its output stream and mutex but
|
|
||||||
// owns its attribute and group slices.
|
|
||||||
//
|
|
||||||
// The slices are copied rather than resliced on purpose. slog permits
|
|
||||||
// one handler to be derived from concurrently, and two derivations that
|
|
||||||
// appended into a shared backing array would each overwrite the other's
|
|
||||||
// attribute — a data race with a silent wrong-output failure mode.
|
|
||||||
func (h *TTYHandler) clone() *TTYHandler {
|
|
||||||
next := &TTYHandler{
|
|
||||||
opts: h.opts,
|
|
||||||
mu: h.mu,
|
|
||||||
out: h.out,
|
|
||||||
attrs: make([]slog.Attr, len(h.attrs), len(h.attrs)+1),
|
|
||||||
groups: make([]string, len(h.groups), len(h.groups)+1),
|
|
||||||
}
|
|
||||||
|
|
||||||
copy(next.attrs, h.attrs)
|
|
||||||
copy(next.groups, h.groups)
|
|
||||||
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeAttr renders one already-flattened, already-qualified attribute
|
|
||||||
// as " key=value". Callers hold h.mu.
|
|
||||||
func (h *TTYHandler) writeAttr(a slog.Attr) {
|
|
||||||
value := a.Value.String()
|
|
||||||
// Special handling for certain attribute types
|
|
||||||
switch a.Value.Kind() {
|
|
||||||
case slog.KindDuration:
|
|
||||||
if d, ok := a.Value.Any().(time.Duration); ok {
|
|
||||||
value = formatDuration(d)
|
|
||||||
}
|
|
||||||
case slog.KindInt64:
|
|
||||||
if isBytesAttr(a.Key) {
|
|
||||||
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.
|
|
||||||
}
|
|
||||||
|
|
||||||
// Escape the key and value before the colour codes are written around
|
|
||||||
// them. Both can carry text from an untrusted source — a manifest
|
|
||||||
// timestamp, a storage error, a path or symlink target read back from
|
|
||||||
// the snapshot database — so a control character in one of them must
|
|
||||||
// be rendered as an escape sequence rather than reaching the terminal,
|
|
||||||
// where it could move the cursor or inject its own colours.
|
|
||||||
_, _ = fmt.Fprintf(h.out, " %s%s%s=%s%s%s",
|
|
||||||
colorCyan, sanitize(a.Key), colorReset,
|
|
||||||
colorBlue, sanitize(value), colorReset)
|
|
||||||
}
|
|
||||||
|
|
||||||
// sanitize returns s unchanged when every rune in it is printable, and a
|
|
||||||
// double-quoted, backslash-escaped form (\n, \x1b, …) otherwise. It is
|
|
||||||
// applied to untrusted text before any colour code is written, so a
|
|
||||||
// control character can never reach the terminal raw.
|
|
||||||
func sanitize(s string) string {
|
|
||||||
for _, r := range s {
|
|
||||||
if !unicode.IsPrint(r) {
|
|
||||||
return strconv.Quote(s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return s
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatDuration formats a duration in a human-readable way
|
// formatDuration formats a duration in a human-readable way
|
||||||
|
|||||||
@@ -1,422 +0,0 @@
|
|||||||
package log_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"math"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ansiEscape matches the SGR sequences TTYHandler wraps every field in.
|
|
||||||
// Stripping them is what lets a test compare TTYHandler's rendering with
|
|
||||||
// slog.JSONHandler's.
|
|
||||||
var ansiEscape = regexp.MustCompile(`\x1b\[[0-9;]*m`)
|
|
||||||
|
|
||||||
// countKey is an attribute key reused across the comparison cases.
|
|
||||||
const countKey = "count"
|
|
||||||
|
|
||||||
// debugHandlerOptions enables every level, so a test never has to reason
|
|
||||||
// about the default level while reasoning about attributes.
|
|
||||||
func debugHandlerOptions() *slog.HandlerOptions {
|
|
||||||
return &slog.HandlerOptions{Level: slog.LevelDebug}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ttyAttrs renders one record through a TTYHandler and returns its
|
|
||||||
// attributes as key -> value, with color stripped.
|
|
||||||
//
|
|
||||||
// TTYHandler emits " key=value" per attribute after the message, and the
|
|
||||||
// message itself is the last thing before the first attribute, so
|
|
||||||
// splitting on spaces and keeping the tokens containing "=" recovers the
|
|
||||||
// attribute set. Test values below therefore avoid spaces and "=".
|
|
||||||
func ttyAttrs(t *testing.T, derive func(*slog.Logger) *slog.Logger,
|
|
||||||
msg string, args ...any,
|
|
||||||
) map[string]string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
|
|
||||||
logger := slog.New(log.NewTTYHandler(&buf, debugHandlerOptions()))
|
|
||||||
derive(logger).Info(msg, args...)
|
|
||||||
|
|
||||||
line := ansiEscape.ReplaceAllString(buf.String(), "")
|
|
||||||
attrs := make(map[string]string)
|
|
||||||
|
|
||||||
for token := range strings.FieldsSeq(line) {
|
|
||||||
key, value, found := strings.Cut(token, "=")
|
|
||||||
if !found {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
attrs[key] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
return attrs
|
|
||||||
}
|
|
||||||
|
|
||||||
// jsonAttrs renders one record through slog.JSONHandler and returns its
|
|
||||||
// attributes flattened to the same dotted-key form TTYHandler uses, so
|
|
||||||
// the two are directly comparable. The built-in time/level/msg fields
|
|
||||||
// are dropped: they are the record, not its attributes.
|
|
||||||
func jsonAttrs(t *testing.T, derive func(*slog.Logger) *slog.Logger,
|
|
||||||
msg string, args ...any,
|
|
||||||
) map[string]string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
|
|
||||||
logger := slog.New(slog.NewJSONHandler(&buf, debugHandlerOptions()))
|
|
||||||
derive(logger).Info(msg, args...)
|
|
||||||
|
|
||||||
var decoded map[string]any
|
|
||||||
|
|
||||||
require.NoError(t, json.Unmarshal(buf.Bytes(), &decoded))
|
|
||||||
|
|
||||||
delete(decoded, slog.TimeKey)
|
|
||||||
delete(decoded, slog.LevelKey)
|
|
||||||
delete(decoded, slog.MessageKey)
|
|
||||||
|
|
||||||
attrs := make(map[string]string)
|
|
||||||
flattenJSON(attrs, "", decoded)
|
|
||||||
|
|
||||||
return attrs
|
|
||||||
}
|
|
||||||
|
|
||||||
// flattenJSON turns JSONHandler's nested group objects into the dotted
|
|
||||||
// keys TTYHandler writes.
|
|
||||||
func flattenJSON(dst map[string]string, prefix string, src map[string]any) {
|
|
||||||
for key, value := range src {
|
|
||||||
full := key
|
|
||||||
if prefix != "" {
|
|
||||||
full = prefix + "." + key
|
|
||||||
}
|
|
||||||
|
|
||||||
nested, ok := value.(map[string]any)
|
|
||||||
if ok {
|
|
||||||
flattenJSON(dst, full, nested)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
dst[full] = valueString(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// valueString renders a decoded JSON scalar the way slog.Value.String
|
|
||||||
// renders the corresponding Go value, so the two handlers' outputs can
|
|
||||||
// be compared as strings. encoding/json decodes every number as
|
|
||||||
// float64, so an integral one is rendered back as an integer — which is
|
|
||||||
// what the Go value that produced it was.
|
|
||||||
func valueString(v any) string {
|
|
||||||
switch typed := v.(type) {
|
|
||||||
case string:
|
|
||||||
return typed
|
|
||||||
case bool:
|
|
||||||
return strconv.FormatBool(typed)
|
|
||||||
case float64:
|
|
||||||
if typed == math.Trunc(typed) {
|
|
||||||
return strconv.FormatInt(int64(typed), 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
return strconv.FormatFloat(typed, 'g', -1, 64)
|
|
||||||
default:
|
|
||||||
return fmt.Sprint(v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTTYHandlerWithAttrsEmitsAttributes is the direct regression test
|
|
||||||
// for the reported defect: WithAttrs discarded its argument, so an
|
|
||||||
// attribute attached to a logger never reached the output.
|
|
||||||
func TestTTYHandlerWithAttrsEmitsAttributes(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
attrs := ttyAttrs(t, func(l *slog.Logger) *slog.Logger {
|
|
||||||
return l.With("key", "value")
|
|
||||||
}, "hello")
|
|
||||||
|
|
||||||
assert.Equal(t, "value", attrs["key"],
|
|
||||||
"an attribute attached with With must appear on every record")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTTYHandlerWithAttrsPersistsAcrossRecords checks that the
|
|
||||||
// attributes are retained rather than emitted once. A handler that
|
|
||||||
// stored them but consumed them would pass the test above.
|
|
||||||
func TestTTYHandlerWithAttrsPersistsAcrossRecords(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
|
|
||||||
logger := slog.New(log.NewTTYHandler(&buf, debugHandlerOptions())).
|
|
||||||
With("request", "abc123")
|
|
||||||
|
|
||||||
logger.Info("first")
|
|
||||||
logger.Info("second")
|
|
||||||
|
|
||||||
plain := ansiEscape.ReplaceAllString(buf.String(), "")
|
|
||||||
lines := strings.Split(strings.TrimSuffix(plain, "\n"), "\n")
|
|
||||||
|
|
||||||
require.Len(t, lines, 2)
|
|
||||||
|
|
||||||
for _, line := range lines {
|
|
||||||
assert.Contains(t, line, "request=abc123")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTTYHandlerWithGroupQualifiesKeys checks that WithGroup does
|
|
||||||
// something real rather than being discarded. This format has no
|
|
||||||
// nesting, so grouping shows up as a dotted key prefix.
|
|
||||||
func TestTTYHandlerWithGroupQualifiesKeys(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
attrs := ttyAttrs(t, func(l *slog.Logger) *slog.Logger {
|
|
||||||
return l.WithGroup("db").With("rows", 3)
|
|
||||||
}, "queried", "table", "chunks")
|
|
||||||
|
|
||||||
assert.Equal(t, "3", attrs["db.rows"],
|
|
||||||
"an attribute added under a group must be qualified by it")
|
|
||||||
assert.Equal(t, "chunks", attrs["db.table"],
|
|
||||||
"a record attribute must also be qualified by the open group")
|
|
||||||
assert.NotContains(t, attrs, "rows")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTTYHandlerByteFormattingSurvivesGrouping guards the interaction
|
|
||||||
// between the two features. The human-readable rendering of a "bytes"
|
|
||||||
// attribute is selected by comparing the key, and keys reaching that
|
|
||||||
// comparison are group-qualified, so a "bytes" attribute logged under an
|
|
||||||
// open group arrived as "transfer.bytes" and fell back to a bare number.
|
|
||||||
// No caller groups a byte count today, which is exactly why this needs a
|
|
||||||
// test rather than a bug report.
|
|
||||||
func TestTTYHandlerByteFormattingSurvivesGrouping(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const oneAndAHalfKiB = 1536
|
|
||||||
|
|
||||||
for name, testCase := range map[string]struct {
|
|
||||||
derive func(*slog.Logger) *slog.Logger
|
|
||||||
key string
|
|
||||||
}{
|
|
||||||
"ungrouped": {
|
|
||||||
derive: func(l *slog.Logger) *slog.Logger { return l },
|
|
||||||
key: "bytes",
|
|
||||||
},
|
|
||||||
"grouped": {
|
|
||||||
derive: func(l *slog.Logger) *slog.Logger {
|
|
||||||
return l.WithGroup("transfer")
|
|
||||||
},
|
|
||||||
key: "transfer.bytes",
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
|
|
||||||
logger := slog.New(log.NewTTYHandler(&buf, debugHandlerOptions()))
|
|
||||||
testCase.derive(logger).Info("uploaded", "bytes", oneAndAHalfKiB)
|
|
||||||
|
|
||||||
line := ansiEscape.ReplaceAllString(buf.String(), "")
|
|
||||||
|
|
||||||
assert.Contains(t, line, testCase.key+"=1.5 KB",
|
|
||||||
"a byte count must be human-readable however it is qualified")
|
|
||||||
assert.NotContains(t, line, strconv.Itoa(oneAndAHalfKiB),
|
|
||||||
"the raw number must not survive the formatting")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTTYHandlerMatchesJSONHandlerAttributes is the drift guard. The
|
|
||||||
// handler is chosen by TTY-ness, so a difference between these two is
|
|
||||||
// invisible in whichever environment the developer is not in — which is
|
|
||||||
// how the original defect survived: attributes vanished on a terminal
|
|
||||||
// and were correct in CI.
|
|
||||||
func TestTTYHandlerMatchesJSONHandlerAttributes(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
derive func(*slog.Logger) *slog.Logger
|
|
||||||
args []any
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "record attributes only",
|
|
||||||
derive: func(l *slog.Logger) *slog.Logger { return l },
|
|
||||||
args: []any{"path", "/etc/vaultik", countKey, 7},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "handler attributes",
|
|
||||||
derive: func(l *slog.Logger) *slog.Logger {
|
|
||||||
return l.With("host", "alpha")
|
|
||||||
},
|
|
||||||
args: []any{countKey, 7},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "handler attributes accumulate",
|
|
||||||
derive: func(l *slog.Logger) *slog.Logger {
|
|
||||||
return l.With("host", "alpha").With("snapshot", "s1")
|
|
||||||
},
|
|
||||||
args: []any{countKey, 7},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "group qualifies later attributes",
|
|
||||||
derive: func(l *slog.Logger) *slog.Logger {
|
|
||||||
return l.WithGroup("db").With("rows", 3)
|
|
||||||
},
|
|
||||||
args: []any{"table", "chunks"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "nested groups",
|
|
||||||
derive: func(l *slog.Logger) *slog.Logger {
|
|
||||||
return l.WithGroup("outer").WithGroup("inner").
|
|
||||||
With("leaf", "v")
|
|
||||||
},
|
|
||||||
args: []any{"other", "w"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "attributes before and after a group",
|
|
||||||
derive: func(l *slog.Logger) *slog.Logger {
|
|
||||||
return l.With("top", "t").WithGroup("g").With("in", "i")
|
|
||||||
},
|
|
||||||
args: []any{"rec", "r"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "inline group value on the record",
|
|
||||||
derive: func(l *slog.Logger) *slog.Logger { return l },
|
|
||||||
args: []any{slog.Group("net",
|
|
||||||
slog.String("proto", "s3"), slog.Int("retries", 2))},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, testCase := range cases {
|
|
||||||
t.Run(testCase.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tty := ttyAttrs(t, testCase.derive, "message", testCase.args...)
|
|
||||||
js := jsonAttrs(t, testCase.derive, "message", testCase.args...)
|
|
||||||
|
|
||||||
assert.Equal(t, sortedKeys(js), sortedKeys(tty),
|
|
||||||
"TTY and JSON handlers must emit the same attribute keys")
|
|
||||||
assert.Equal(t, js, tty,
|
|
||||||
"TTY and JSON handlers must emit the same attribute values")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// sortedKeys returns m's keys in order, for a stable comparison message.
|
|
||||||
func sortedKeys(m map[string]string) []string {
|
|
||||||
keys := make([]string, 0, len(m))
|
|
||||||
for key := range m {
|
|
||||||
keys = append(keys, key)
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Strings(keys)
|
|
||||||
|
|
||||||
return keys
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTTYHandlerWithAttrsDoesNotMutateReceiver checks that deriving does
|
|
||||||
// not write through to the parent or to a sibling. slog permits a
|
|
||||||
// handler to be shared, so a WithAttrs that appended into the receiver's
|
|
||||||
// state would leak attributes between unrelated loggers.
|
|
||||||
func TestTTYHandlerWithAttrsDoesNotMutateReceiver(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
|
|
||||||
base := slog.New(log.NewTTYHandler(&buf, debugHandlerOptions()))
|
|
||||||
first := base.With("branch", "one")
|
|
||||||
second := base.With("branch", "two")
|
|
||||||
|
|
||||||
base.Info("base")
|
|
||||||
first.Info("first")
|
|
||||||
second.Info("second")
|
|
||||||
|
|
||||||
plain := ansiEscape.ReplaceAllString(buf.String(), "")
|
|
||||||
lines := strings.Split(strings.TrimSuffix(plain, "\n"), "\n")
|
|
||||||
|
|
||||||
require.Len(t, lines, 3)
|
|
||||||
|
|
||||||
assert.NotContains(t, lines[0], "branch=",
|
|
||||||
"deriving must not add attributes to the handler derived from")
|
|
||||||
assert.Contains(t, lines[1], "branch=one")
|
|
||||||
assert.NotContains(t, lines[1], "branch=two")
|
|
||||||
assert.Contains(t, lines[2], "branch=two")
|
|
||||||
assert.NotContains(t, lines[2], "branch=one")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTTYHandlerConcurrentDerivation exercises the same handler being
|
|
||||||
// derived from and written through by several goroutines at once, which
|
|
||||||
// is what slog permits and what a mutating WithAttrs would make a data
|
|
||||||
// race. Run under -race by script/test.
|
|
||||||
func TestTTYHandlerConcurrentDerivation(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const workers = 16
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
|
|
||||||
base := slog.New(log.NewTTYHandler(&buf, debugHandlerOptions())).
|
|
||||||
With("shared", "yes")
|
|
||||||
|
|
||||||
var group sync.WaitGroup
|
|
||||||
|
|
||||||
group.Add(workers)
|
|
||||||
|
|
||||||
for worker := range workers {
|
|
||||||
go func() {
|
|
||||||
defer group.Done()
|
|
||||||
|
|
||||||
base.With("worker", worker).
|
|
||||||
WithGroup("g").
|
|
||||||
With("nested", worker).
|
|
||||||
Info("concurrent")
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
group.Wait()
|
|
||||||
|
|
||||||
plain := ansiEscape.ReplaceAllString(buf.String(), "")
|
|
||||||
lines := strings.Split(strings.TrimSuffix(plain, "\n"), "\n")
|
|
||||||
|
|
||||||
require.Len(t, lines, workers)
|
|
||||||
|
|
||||||
for _, line := range lines {
|
|
||||||
assert.Contains(t, line, "shared=yes")
|
|
||||||
assert.Contains(t, line, "worker=")
|
|
||||||
assert.Contains(t, line, "g.nested=")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTTYHandlerEmptyGroupAndAttrsAreNoOps covers the slog.Handler
|
|
||||||
// contract corners: WithGroup("") and WithAttrs(nil) change nothing, and
|
|
||||||
// an empty Attr is dropped rather than rendered as "=".
|
|
||||||
func TestTTYHandlerEmptyGroupAndAttrsAreNoOps(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
|
|
||||||
handler := log.NewTTYHandler(&buf, debugHandlerOptions())
|
|
||||||
|
|
||||||
assert.Same(t, handler, handler.WithGroup(""),
|
|
||||||
"an empty group name must not open a group")
|
|
||||||
assert.Same(t, handler, handler.WithAttrs(nil),
|
|
||||||
"deriving with no attributes must not allocate a handler")
|
|
||||||
|
|
||||||
slog.New(handler).LogAttrs(context.Background(), slog.LevelInfo, "msg",
|
|
||||||
slog.Attr{}, slog.String("kept", "yes"))
|
|
||||||
|
|
||||||
plain := ansiEscape.ReplaceAllString(buf.String(), "")
|
|
||||||
|
|
||||||
assert.Contains(t, plain, "kept=yes")
|
|
||||||
assert.NotContains(t, plain, " =")
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
//nolint:testpackage // needs the package logger; see TestWithAttributesReachTTYOutput
|
|
||||||
package log //nolint:revive,nolintlint // stdlib log unused here; see #76
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"log/slog"
|
|
||||||
"regexp"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// withTestANSIEscape matches the SGR sequences TTYHandler emits.
|
|
||||||
var withTestANSIEscape = regexp.MustCompile(`\x1b\[[0-9;]*m`)
|
|
||||||
|
|
||||||
// TestWithAttributesReachTTYOutput exercises the exported package-level
|
|
||||||
// With through a TTYHandler, which is the path the reported defect was
|
|
||||||
// on: the handler is selected by TTY-ness, so on a terminal With's
|
|
||||||
// attributes were silently dropped while the same code printed them
|
|
||||||
// correctly in CI.
|
|
||||||
//
|
|
||||||
// This is an in-package test so it can point the package logger at a
|
|
||||||
// buffer. Building an slog.Logger over a TTYHandler by hand would test
|
|
||||||
// slog, not this package's With, and there is no injectable sink to
|
|
||||||
// reach it from outside. The package logger is process-global, so this
|
|
||||||
// test must not run in parallel.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // replaces the process-global package logger
|
|
||||||
func TestWithAttributesReachTTYOutput(t *testing.T) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
|
|
||||||
previous := logger
|
|
||||||
|
|
||||||
t.Cleanup(func() { logger = previous })
|
|
||||||
|
|
||||||
logger = slog.New(NewTTYHandler(&buf, &slog.HandlerOptions{
|
|
||||||
Level: slog.LevelDebug,
|
|
||||||
}))
|
|
||||||
|
|
||||||
With("key", "value").Info("hello")
|
|
||||||
|
|
||||||
plain := withTestANSIEscape.ReplaceAllString(buf.String(), "")
|
|
||||||
|
|
||||||
require.NotEmpty(t, plain)
|
|
||||||
assert.Contains(t, plain, "hello")
|
|
||||||
assert.Contains(t, plain, "key=value",
|
|
||||||
"log.With attributes must reach TTYHandler output")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWithoutInitializedLoggerFallsBack pins the documented behavior of
|
|
||||||
// With before Initialize has run: it hands back the slog default rather
|
|
||||||
// than a nil logger that would panic at the call site.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // replaces the process-global package logger
|
|
||||||
func TestWithoutInitializedLoggerFallsBack(t *testing.T) {
|
|
||||||
previous := logger
|
|
||||||
|
|
||||||
t.Cleanup(func() { logger = previous })
|
|
||||||
|
|
||||||
logger = nil
|
|
||||||
|
|
||||||
assert.NotNil(t, With("key", "value"))
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// Package models defines shared value types describing files, chunks,
|
||||||
|
// blobs, and snapshots as they move through the backup pipeline.
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileInfo represents a file in the backup system
|
||||||
|
type FileInfo struct {
|
||||||
|
Path string
|
||||||
|
MTime time.Time
|
||||||
|
Size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChunkInfo represents a content-addressed chunk
|
||||||
|
type ChunkInfo struct {
|
||||||
|
Hash string // SHA256 hash
|
||||||
|
Size int64
|
||||||
|
Offset int64 // Offset within source file
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChunkRef represents a reference to a chunk in a blob or file
|
||||||
|
type ChunkRef struct {
|
||||||
|
ChunkHash string
|
||||||
|
Offset int64
|
||||||
|
Length int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlobInfo represents an encrypted blob containing multiple chunks
|
||||||
|
type BlobInfo struct {
|
||||||
|
Hash string // SHA256 hash of the blob content (content-addressable)
|
||||||
|
CreatedAt time.Time
|
||||||
|
Size int64
|
||||||
|
ChunkCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot represents a backup snapshot
|
||||||
|
type Snapshot struct {
|
||||||
|
ID string // ISO8601 timestamp
|
||||||
|
Hostname string
|
||||||
|
Version string
|
||||||
|
CreatedAt time.Time
|
||||||
|
FileCount int64
|
||||||
|
ChunkCount int64
|
||||||
|
BlobCount int64
|
||||||
|
TotalSize int64
|
||||||
|
MetadataSize int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// SnapshotMetadata contains the full metadata for a snapshot
|
||||||
|
type SnapshotMetadata struct {
|
||||||
|
Snapshot *Snapshot
|
||||||
|
Files map[string]*FileInfo
|
||||||
|
Chunks map[string]*ChunkInfo
|
||||||
|
Blobs map[string]*BlobInfo
|
||||||
|
FileChunks map[string][]*ChunkRef // path -> chunks
|
||||||
|
BlobChunks map[string][]*ChunkRef // blob hash -> chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chunk represents a data chunk for processing
|
||||||
|
type Chunk struct {
|
||||||
|
Data []byte
|
||||||
|
Hash string
|
||||||
|
Offset int64
|
||||||
|
Length int64
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package models_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/vaultik/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestModelsCompilation ensures all model types can be instantiated
|
||||||
|
func TestModelsCompilation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// This test primarily serves as a compilation test
|
||||||
|
// to ensure all types are properly defined
|
||||||
|
|
||||||
|
// Test FileInfo
|
||||||
|
fi := &models.FileInfo{
|
||||||
|
Path: "/test/file.txt",
|
||||||
|
MTime: time.Now(),
|
||||||
|
Size: 1024,
|
||||||
|
}
|
||||||
|
if fi.Path != "/test/file.txt" {
|
||||||
|
t.Errorf("FileInfo.Path not set correctly")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test ChunkInfo
|
||||||
|
ci := &models.ChunkInfo{
|
||||||
|
Hash: "abc123",
|
||||||
|
Size: 512,
|
||||||
|
Offset: 0,
|
||||||
|
}
|
||||||
|
if ci.Hash != "abc123" {
|
||||||
|
t.Errorf("ChunkInfo.Hash not set correctly")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test BlobInfo
|
||||||
|
bi := &models.BlobInfo{
|
||||||
|
Hash: "blob123",
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
Size: 1024,
|
||||||
|
ChunkCount: 2,
|
||||||
|
}
|
||||||
|
if bi.Hash != "blob123" {
|
||||||
|
t.Errorf("BlobInfo.Hash not set correctly")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Snapshot
|
||||||
|
s := &models.Snapshot{
|
||||||
|
ID: "2024-01-01T00:00:00Z",
|
||||||
|
Hostname: "test-host",
|
||||||
|
Version: "1.0.0",
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
if s.ID != "2024-01-01T00:00:00Z" {
|
||||||
|
t.Errorf("Snapshot.ID not set correctly")
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-13
@@ -219,7 +219,11 @@ func (c *Client) HeadObject(ctx context.Context, key string) (bool, error) {
|
|||||||
Key: aws.String(fullKey),
|
Key: aws.String(fullKey),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if IsNotFound(err) {
|
var (
|
||||||
|
notFound *s3types.NotFound
|
||||||
|
noSuchKey *s3types.NoSuchKey
|
||||||
|
)
|
||||||
|
if errors.As(err, ¬Found) || errors.As(err, &noSuchKey) {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,18 +233,6 @@ func (c *Client) HeadObject(ctx context.Context, key string) (bool, error) {
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsNotFound reports whether err indicates that an object does not exist.
|
|
||||||
// Head and Get requests surface a missing object as different SDK types,
|
|
||||||
// so both are checked here.
|
|
||||||
func IsNotFound(err error) bool {
|
|
||||||
var (
|
|
||||||
notFound *s3types.NotFound
|
|
||||||
noSuchKey *s3types.NoSuchKey
|
|
||||||
)
|
|
||||||
|
|
||||||
return errors.As(err, ¬Found) || errors.As(err, &noSuchKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ObjectInfo contains information about an S3 object.
|
// ObjectInfo contains information about an S3 object.
|
||||||
// It is used by ListObjectsStream to return object metadata
|
// It is used by ListObjectsStream to return object metadata
|
||||||
// along with any errors encountered during listing.
|
// along with any errors encountered during listing.
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
//nolint:testpackage // exercises the unexported copyFile helper
|
|
||||||
package snapshot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"syscall"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestCopyFileExportCopyMode verifies that the exported snapshot database
|
|
||||||
// copy is created owner-only (0600), even under a lenient 022 umask that
|
|
||||||
// would otherwise leave a fresh file world-readable.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // syscall.Umask is process-global; parallel tests would clash
|
|
||||||
func TestCopyFileExportCopyMode(t *testing.T) {
|
|
||||||
restore := syscall.Umask(0o022)
|
|
||||||
defer syscall.Umask(restore)
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
|
|
||||||
src := filepath.Join(dir, "index.sqlite")
|
|
||||||
|
|
||||||
err := os.WriteFile(src, []byte("index data"), 0o600)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("creating source index: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
dst := filepath.Join(dir, "snapshot.db")
|
|
||||||
|
|
||||||
sm := &SnapshotManager{fs: afero.NewOsFs()}
|
|
||||||
|
|
||||||
err = sm.copyFile(src, dst)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("copyFile: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := os.Stat(dst)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("stat export copy: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
got := info.Mode().Perm()
|
|
||||||
if got != 0o600 {
|
|
||||||
t.Fatalf("export copy permissions = %#o, want %#o", got, 0o600)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,19 +7,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
|
|
||||||
"github.com/klauspost/compress/zstd"
|
"github.com/klauspost/compress/zstd"
|
||||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Manifest size bounds. A manifest lists one small entry per blob, and
|
|
||||||
// blobs are large (the default target is 10 GB), so even a manifest for a
|
|
||||||
// petabyte-scale backup is a few megabytes. These caps are far above any
|
|
||||||
// manifest the writer can emit, yet stop a crafted, highly compressible
|
|
||||||
// manifest from expanding without limit when decoded: the manifest is
|
|
||||||
// fetched from the store, which is not trusted, and json.Decode buffers
|
|
||||||
// the whole value in memory.
|
|
||||||
const (
|
|
||||||
manifestMaxCompressed = 256 * 1024 * 1024 // 256 MiB
|
|
||||||
manifestMaxDecompressed = 1024 * 1024 * 1024 // 1 GiB
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Manifest represents the structure of a snapshot's blob manifest
|
// Manifest represents the structure of a snapshot's blob manifest
|
||||||
@@ -41,31 +28,19 @@ type BlobInfo struct {
|
|||||||
CompressedSize int64 `json:"compressed_size"`
|
CompressedSize int64 `json:"compressed_size"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DecodeManifest decodes a manifest from a reader containing compressed
|
// DecodeManifest decodes a manifest from a reader containing compressed JSON
|
||||||
// JSON, reading through byte limits on both the compressed input and the
|
|
||||||
// decompressed output so an untrusted manifest cannot exhaust memory.
|
|
||||||
func DecodeManifest(r io.Reader) (*Manifest, error) {
|
func DecodeManifest(r io.Reader) (*Manifest, error) {
|
||||||
return decodeManifest(r, manifestMaxCompressed, manifestMaxDecompressed)
|
// Decompress using zstd
|
||||||
}
|
zr, err := zstd.NewReader(r)
|
||||||
|
|
||||||
// decodeManifest is DecodeManifest with explicit limits, so tests can drive
|
|
||||||
// the bounds with small inputs instead of gigabyte-scale ones.
|
|
||||||
func decodeManifest(
|
|
||||||
r io.Reader, maxCompressed, maxDecompressed int64,
|
|
||||||
) (*Manifest, error) {
|
|
||||||
// Decompress using zstd, bounding how many compressed bytes are read.
|
|
||||||
zr, err := zstd.NewReader(blobgen.LimitReader(r, maxCompressed))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("creating zstd reader: %w", err)
|
return nil, fmt.Errorf("creating zstd reader: %w", err)
|
||||||
}
|
}
|
||||||
defer zr.Close()
|
defer zr.Close()
|
||||||
|
|
||||||
// Decode JSON manifest, bounding how far the compressed input may
|
// Decode JSON manifest
|
||||||
// expand: json.Decode buffers the whole value, so without this a
|
|
||||||
// small, highly compressible manifest could expand to gigabytes.
|
|
||||||
var manifest Manifest
|
var manifest Manifest
|
||||||
|
|
||||||
err = json.NewDecoder(blobgen.LimitReader(zr, maxDecompressed)).Decode(&manifest)
|
err = json.NewDecoder(zr).Decode(&manifest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("decoding manifest: %w", err)
|
return nil, fmt.Errorf("decoding manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
//nolint:testpackage // exercises the unexported decodeManifest bounds
|
|
||||||
package snapshot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
|
||||||
)
|
|
||||||
|
|
||||||
// testSnapshotID is a stand-in snapshot ID reused across the bound cases.
|
|
||||||
const testSnapshotID = "host_home_2026-01-01T00:00:00Z"
|
|
||||||
|
|
||||||
// TestDecodeManifestRoundTrip is the baseline: with generous bounds a
|
|
||||||
// manifest the writer produced decodes back unchanged.
|
|
||||||
func TestDecodeManifestRoundTrip(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
want := &Manifest{
|
|
||||||
SnapshotID: testSnapshotID,
|
|
||||||
Timestamp: "2026-01-01T00:00:00Z",
|
|
||||||
BlobCount: 2,
|
|
||||||
TotalCompressedSize: 42,
|
|
||||||
Blobs: []BlobInfo{
|
|
||||||
{Hash: "aa", CompressedSize: 21},
|
|
||||||
{Hash: "bb", CompressedSize: 21},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
compressed, err := EncodeManifest(want, 3)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
got, err := decodeManifest(
|
|
||||||
bytes.NewReader(compressed), manifestMaxCompressed, manifestMaxDecompressed)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, want, got)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDecodeManifestBoundsDecompressedOutput feeds a valid but highly
|
|
||||||
// compressible manifest — one whose timestamp is a megabyte of the same
|
|
||||||
// character — through a small decompressed bound. The compressed form is
|
|
||||||
// tiny, so only the decompressed bound stops it; decoding must fail within
|
|
||||||
// that bound rather than expanding the value in memory.
|
|
||||||
func TestDecodeManifestBoundsDecompressedOutput(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
bomb := &Manifest{
|
|
||||||
SnapshotID: testSnapshotID,
|
|
||||||
Timestamp: strings.Repeat("a", 1<<20),
|
|
||||||
}
|
|
||||||
|
|
||||||
compressed, err := EncodeManifest(bomb, 3)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Less(t, len(compressed), 4096,
|
|
||||||
"the compressible manifest must be small compressed")
|
|
||||||
|
|
||||||
_, err = decodeManifest(bytes.NewReader(compressed), 1<<20, 4096)
|
|
||||||
require.ErrorIs(t, err, blobgen.ErrOutputTooLarge)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDecodeManifestBoundsCompressedInput checks the compressed-input
|
|
||||||
// bound fires independently: a valid manifest with a generous decompressed
|
|
||||||
// bound but a tiny compressed bound still fails.
|
|
||||||
func TestDecodeManifestBoundsCompressedInput(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
manifest := &Manifest{
|
|
||||||
SnapshotID: testSnapshotID,
|
|
||||||
Timestamp: strings.Repeat("a", 4096),
|
|
||||||
}
|
|
||||||
|
|
||||||
compressed, err := EncodeManifest(manifest, 3)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
_, err = decodeManifest(bytes.NewReader(compressed), 8, manifestMaxDecompressed)
|
|
||||||
require.Error(t, err)
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
//nolint:testpackage // exercises the unexported generateBlobManifest
|
|
||||||
package snapshot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/vaultik/internal/config"
|
|
||||||
"sneak.berlin/go/vaultik/internal/database"
|
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
|
||||||
"sneak.berlin/go/vaultik/internal/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestGenerateBlobManifest_MissingBlobFails is the regression guard for
|
|
||||||
// issue #157: a blob the snapshot references but that is absent from the
|
|
||||||
// blobs table used to be logged and skipped, yielding a manifest with
|
|
||||||
// fewer blobs than the snapshot needs. Since prune trusts the manifest
|
|
||||||
// alone, that omitted blob would be deleted at the next prune. Manifest
|
|
||||||
// generation must fail instead.
|
|
||||||
func TestGenerateBlobManifest_MissingBlobFails(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
dbPath := filepath.Join(t.TempDir(), "snapshot.db")
|
|
||||||
|
|
||||||
db, err := database.New(ctx, dbPath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
repos := database.NewRepositories(db)
|
|
||||||
|
|
||||||
// A real blob row satisfies the snapshot_blobs foreign key on
|
|
||||||
// blob_id; the snapshot then references a different, absent hash.
|
|
||||||
presentBlob := &database.Blob{
|
|
||||||
ID: types.NewBlobID(),
|
|
||||||
Hash: types.BlobHash("present-blob-hash"),
|
|
||||||
CreatedTS: time.Now().Truncate(time.Second),
|
|
||||||
}
|
|
||||||
require.NoError(t, repos.Blobs.Create(ctx, nil, presentBlob))
|
|
||||||
|
|
||||||
snap := &database.Snapshot{
|
|
||||||
ID: "testhost_home_2026-05-01T00:00:00Z",
|
|
||||||
Hostname: "testhost",
|
|
||||||
}
|
|
||||||
require.NoError(t, repos.Snapshots.Create(ctx, nil, snap))
|
|
||||||
require.NoError(t, repos.Snapshots.AddBlob(ctx, nil,
|
|
||||||
snap.ID.String(), presentBlob.ID, types.BlobHash("absent-blob-hash")))
|
|
||||||
|
|
||||||
require.NoError(t, db.Close())
|
|
||||||
|
|
||||||
sm := &SnapshotManager{
|
|
||||||
config: &config.Config{CompressionLevel: 3},
|
|
||||||
fs: afero.NewOsFs(),
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = sm.generateBlobManifest(ctx, dbPath, snap.ID.String())
|
|
||||||
require.Error(t, err, "manifest generation must fail on a missing blob")
|
|
||||||
assert.Contains(t, err.Error(), "absent-blob-hash")
|
|
||||||
}
|
|
||||||
@@ -22,9 +22,8 @@ const remoteKeyPrefix = "vaultik|"
|
|||||||
//
|
//
|
||||||
// - the "metadata/<remote-key>/..." subdirectory on the storage
|
// - the "metadata/<remote-key>/..." subdirectory on the storage
|
||||||
// backend so a directory listing of the bucket / file:// dest
|
// backend so a directory listing of the bucket / file:// dest
|
||||||
// doesn't reveal hostnames or configured snapshot names. (The
|
// doesn't reveal hostnames, configured snapshot names, or backup
|
||||||
// backup time is not hidden: the manifest.json.zst inside that
|
// timestamps;
|
||||||
// directory carries a plaintext RFC3339 timestamp.)
|
|
||||||
// - the `snapshot_id` field of the unencrypted manifest.json.zst
|
// - the `snapshot_id` field of the unencrypted manifest.json.zst
|
||||||
// for the same reason;
|
// for the same reason;
|
||||||
// - any code path that needs to translate a known local snapshot ID
|
// - any code path that needs to translate a known local snapshot ID
|
||||||
|
|||||||
@@ -63,9 +63,7 @@ type Scanner struct {
|
|||||||
exclude []string // Glob patterns for files/directories to exclude
|
exclude []string // Glob patterns for files/directories to exclude
|
||||||
compiledExclude []compiledPattern // Compiled glob patterns
|
compiledExclude []compiledPattern // Compiled glob patterns
|
||||||
progress *ProgressReporter
|
progress *ProgressReporter
|
||||||
// skipErrors skips files that cannot be opened or read (logged loudly);
|
skipErrors bool // Skip file read errors (log loudly but continue)
|
||||||
// packer, database, encryption, and upload errors still abort the run.
|
|
||||||
skipErrors bool
|
|
||||||
// ui is the user-facing output; never nil (defaults to a discarding writer).
|
// ui is the user-facing output; never nil (defaults to a discarding writer).
|
||||||
ui *ui.Writer
|
ui *ui.Writer
|
||||||
|
|
||||||
@@ -119,13 +117,11 @@ type ScannerConfig struct {
|
|||||||
Storage storage.Storer
|
Storage storage.Storer
|
||||||
MaxBlobSize int64
|
MaxBlobSize int64
|
||||||
CompressionLevel int
|
CompressionLevel int
|
||||||
AgeRecipients []string // required; output is always encrypted
|
AgeRecipients []string // Optional, empty means no encryption
|
||||||
EnableProgress bool // Enable the live progress reporter (ETAs, throughput)
|
EnableProgress bool // Enable the live progress reporter (ETAs, throughput)
|
||||||
UI *ui.Writer // Where user-facing scanner messages go; nil = discard
|
UI *ui.Writer // Where user-facing scanner messages go; nil = discard
|
||||||
Exclude []string // Glob patterns for files/directories to exclude
|
Exclude []string // Glob patterns for files/directories to exclude
|
||||||
// SkipErrors skips files that cannot be opened or read (log loudly but
|
SkipErrors bool // Skip file read errors (log loudly but continue)
|
||||||
// continue); packer, database, encryption, and upload errors still abort.
|
|
||||||
SkipErrors bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScanResult contains the results of a scan operation
|
// ScanResult contains the results of a scan operation
|
||||||
@@ -224,14 +220,7 @@ func (s *Scanner) Scan(
|
|||||||
defer s.progress.Stop()
|
defer s.progress.Stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 0: Repair any state left by an interrupted previous run, then
|
// Phase 0: Load known files and chunks from database into memory for fast lookup
|
||||||
// load known files and chunks from the database into memory for fast
|
|
||||||
// lookup.
|
|
||||||
err := s.repairInterruptedBlobs(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
knownFiles, err := s.loadDatabaseState(ctx, path)
|
knownFiles, err := s.loadDatabaseState(ctx, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -328,38 +317,6 @@ func (s *Scanner) loadDatabaseState(
|
|||||||
return knownFiles, nil
|
return knownFiles, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// repairInterruptedBlobs discards blob rows left by a previous run whose
|
|
||||||
// upload never completed. Such a blob has its chunks, blob_chunks, and
|
|
||||||
// blobs rows committed to the local index before the upload is attempted,
|
|
||||||
// so a crash or dropped connection mid-upload leaves them behind while the
|
|
||||||
// data never reaches remote storage. Deduplicating against those chunks on
|
|
||||||
// a later run would produce a snapshot that reports success but cannot be
|
|
||||||
// restored. Dropping the un-uploaded blobs (their blob_chunks cascade) and
|
|
||||||
// then any chunks left unreferenced forces the affected data to be
|
|
||||||
// re-chunked and re-uploaded this run. A blob is attached to a snapshot
|
|
||||||
// only once its upload is recorded, so this never touches a completed
|
|
||||||
// snapshot's data.
|
|
||||||
func (s *Scanner) repairInterruptedBlobs(ctx context.Context) error {
|
|
||||||
removed, err := s.repos.Blobs.DeleteUnuploaded(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("removing un-uploaded blob records: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if removed == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Warn("Discarded blob records from an interrupted previous run; "+
|
|
||||||
"their data will be re-uploaded", "blobs", removed)
|
|
||||||
|
|
||||||
err = s.repos.Chunks.DeleteOrphaned(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("removing orphaned chunks: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// summarizeScanPhase calculates total size to process, updates progress tracking,
|
// summarizeScanPhase calculates total size to process, updates progress tracking,
|
||||||
// and prints the scan phase summary with file counts and sizes
|
// and prints the scan phase summary with file counts and sizes
|
||||||
func (s *Scanner) summarizeScanPhase(
|
func (s *Scanner) summarizeScanPhase(
|
||||||
@@ -435,14 +392,11 @@ func (s *Scanner) loadKnownFiles(
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadKnownChunks loads the chunk hashes safe to deduplicate against into
|
// loadKnownChunks loads all known chunk hashes from the database into a
|
||||||
// an in-memory map for fast lookup, avoiding per-chunk database queries
|
// map for fast lookup. This avoids per-chunk database queries during file
|
||||||
// during file processing. Only chunks held by a blob whose upload
|
// processing.
|
||||||
// completed are loaded: a chunk left behind by an interrupted upload
|
|
||||||
// refers to data that never reached remote storage, and deduplicating
|
|
||||||
// against it would silently produce an unrestorable snapshot.
|
|
||||||
func (s *Scanner) loadKnownChunks(ctx context.Context) error {
|
func (s *Scanner) loadKnownChunks(ctx context.Context) error {
|
||||||
chunks, err := s.repos.Chunks.ListInUploadedBlobs(ctx)
|
chunks, err := s.repos.Chunks.List(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("listing chunks: %w", err)
|
return fmt.Errorf("listing chunks: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1340,15 +1294,6 @@ func (s *Scanner) processFileWithErrorHandling(
|
|||||||
) (bool, error) {
|
) (bool, error) {
|
||||||
err := s.processFileStreaming(ctx, fileToProcess, result)
|
err := s.processFileStreaming(ctx, fileToProcess, result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// A packer/database/encryption/upload failure means the chunk's data
|
|
||||||
// may not have been stored. Skipping the file would let the snapshot
|
|
||||||
// record a file whose chunk is in no blob and cannot be restored, so
|
|
||||||
// abort the run even under --skip-errors. Only open and read errors
|
|
||||||
// are skipped below.
|
|
||||||
var pErr *packerError
|
|
||||||
if errors.As(err, &pErr) {
|
|
||||||
return false, fmt.Errorf("processing file %s: %w", fileToProcess.Path, err)
|
|
||||||
}
|
|
||||||
// Handle files that were deleted between scan and process phases
|
// Handle files that were deleted between scan and process phases
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
log.Warn("File was deleted during backup, skipping",
|
log.Warn("File was deleted during backup, skipping",
|
||||||
@@ -1358,7 +1303,7 @@ func (s *Scanner) processFileWithErrorHandling(
|
|||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
// Skip open/read errors if --skip-errors is enabled
|
// Skip file read errors if --skip-errors is enabled
|
||||||
if s.skipErrors {
|
if s.skipErrors {
|
||||||
log.Error("Failed to process file (skipping due to --skip-errors)",
|
log.Error("Failed to process file (skipping due to --skip-errors)",
|
||||||
"path", fileToProcess.Path, "error", err)
|
"path", fileToProcess.Path, "error", err)
|
||||||
@@ -1456,17 +1401,7 @@ func (s *Scanner) finalizeProcessPhase(ctx context.Context, result *ScanResult)
|
|||||||
return fmt.Errorf("parsing blob ID: %w", err)
|
return fmt.Errorf("parsing blob ID: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// With no remote backend the blob's lifecycle ends here, so
|
|
||||||
// mark it uploaded in the same transaction that attaches it to
|
|
||||||
// the snapshot. This keeps the invariant that any blob a
|
|
||||||
// snapshot references has uploaded_ts set, so deduplication and
|
|
||||||
// interrupted-run repair treat these blobs as trustworthy.
|
|
||||||
err = s.repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
err = s.repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
||||||
err := s.repos.Blobs.UpdateUploaded(ctx, tx, b.ID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("marking blob uploaded: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.repos.Snapshots.AddBlob(ctx, tx, s.snapshotID, blobID,
|
return s.repos.Snapshots.AddBlob(ctx, tx, s.snapshotID, blobID,
|
||||||
types.BlobHash(b.Hash))
|
types.BlobHash(b.Hash))
|
||||||
})
|
})
|
||||||
@@ -1725,20 +1660,6 @@ type streamingChunkInfo struct {
|
|||||||
size int64
|
size int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// packerError marks an error that came from adding a chunk to the packer
|
|
||||||
// (packing, database, encryption, or upload). Such an error means the chunk's
|
|
||||||
// data may not have been stored, so the run must abort even under --skip-errors:
|
|
||||||
// skipping the file would leave the chunk recorded as backed up while it lives
|
|
||||||
// in no blob, and a later snapshot could record a file that cannot be restored.
|
|
||||||
// Only open and read errors are safe to skip.
|
|
||||||
type packerError struct {
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *packerError) Error() string { return e.err.Error() }
|
|
||||||
|
|
||||||
func (e *packerError) Unwrap() error { return e.err }
|
|
||||||
|
|
||||||
// processFileStreaming processes a file by streaming chunks directly to the packer
|
// processFileStreaming processes a file by streaming chunks directly to the packer
|
||||||
func (s *Scanner) processFileStreaming(
|
func (s *Scanner) processFileStreaming(
|
||||||
ctx context.Context, fileToProcess *FileToProcess, result *ScanResult,
|
ctx context.Context, fileToProcess *FileToProcess, result *ScanResult,
|
||||||
@@ -1789,11 +1710,7 @@ func (s *Scanner) processFileStreaming(
|
|||||||
if !chunkExists {
|
if !chunkExists {
|
||||||
err := s.addChunkToPacker(ctx, chunk)
|
err := s.addChunkToPacker(ctx, chunk)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Mark as a packer error so --skip-errors cannot swallow it:
|
return err
|
||||||
// the chunk was registered as pending before packing, so a
|
|
||||||
// skipped file here would be recorded as backed up while its
|
|
||||||
// data was never stored.
|
|
||||||
return &packerError{err: err}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,216 +0,0 @@
|
|||||||
package snapshot_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"sneak.berlin/go/vaultik/internal/database"
|
|
||||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
|
||||||
)
|
|
||||||
|
|
||||||
// errSimTempFail is the one-time temp-file creation failure blobTempFailFs
|
|
||||||
// injects, mirroring a full temp filesystem.
|
|
||||||
var errSimTempFail = errors.New("simulated temp-file creation failure")
|
|
||||||
|
|
||||||
// errSimRead is the read failure readFailFile injects for a file that opens
|
|
||||||
// but cannot be read.
|
|
||||||
var errSimRead = errors.New("simulated read failure")
|
|
||||||
|
|
||||||
// blobTempFailFs fails the first temp-file creation for a packer blob, then
|
|
||||||
// behaves normally, simulating a one-time failure to start a new blob.
|
|
||||||
type blobTempFailFs struct {
|
|
||||||
afero.Fs
|
|
||||||
|
|
||||||
mu sync.Mutex
|
|
||||||
failed bool
|
|
||||||
}
|
|
||||||
|
|
||||||
//nolint:ireturn // afero.Fs.OpenFile is defined to return the interface.
|
|
||||||
func (f *blobTempFailFs) OpenFile(
|
|
||||||
name string, flag int, perm os.FileMode,
|
|
||||||
) (afero.File, error) {
|
|
||||||
if strings.Contains(name, "vaultik-blob-") {
|
|
||||||
f.mu.Lock()
|
|
||||||
firstTime := !f.failed
|
|
||||||
f.failed = true
|
|
||||||
f.mu.Unlock()
|
|
||||||
|
|
||||||
if firstTime {
|
|
||||||
return nil, errSimTempFail
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return f.Fs.OpenFile(name, flag, perm)
|
|
||||||
}
|
|
||||||
|
|
||||||
// readFailFile wraps an afero.File whose Read always fails.
|
|
||||||
type readFailFile struct {
|
|
||||||
afero.File
|
|
||||||
}
|
|
||||||
|
|
||||||
func (readFailFile) Read([]byte) (int, error) {
|
|
||||||
return 0, errSimRead
|
|
||||||
}
|
|
||||||
|
|
||||||
// readFailFs fails reads of one target path after a successful open.
|
|
||||||
type readFailFs struct {
|
|
||||||
afero.Fs
|
|
||||||
|
|
||||||
target string
|
|
||||||
}
|
|
||||||
|
|
||||||
//nolint:ireturn // afero.Fs.Open is defined to return the interface.
|
|
||||||
func (f *readFailFs) Open(name string) (afero.File, error) {
|
|
||||||
file, err := f.Fs.Open(name)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if name == f.target {
|
|
||||||
return readFailFile{File: file}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return file, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeSkipErrorTestFile writes one file into fs with a fixed mtime.
|
|
||||||
func writeSkipErrorTestFile(t *testing.T, fs afero.Fs, path, content string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
err := fs.MkdirAll(filepath.Dir(path), 0755)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("mkdir: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = afero.WriteFile(fs, path, []byte(content), 0644)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("write %s: %v", path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
when := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
|
||||||
|
|
||||||
err = fs.Chtimes(path, when, when)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("chtimes %s: %v", path, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// runSkipErrorScan scans /source on fs with the given skip-errors setting and
|
|
||||||
// returns the repositories (for inspection) and the scan error.
|
|
||||||
func runSkipErrorScan(
|
|
||||||
t *testing.T, fs afero.Fs, skipErrors bool,
|
|
||||||
) (*database.Repositories, error) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
db, err := database.NewTestDB()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create test db: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
cerr := db.Close()
|
|
||||||
if cerr != nil {
|
|
||||||
t.Errorf("close db: %v", cerr)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
repos := database.NewRepositories(db)
|
|
||||||
|
|
||||||
scanner := snapshot.NewScanner(snapshot.ScannerConfig{
|
|
||||||
FS: fs,
|
|
||||||
ChunkSize: int64(1024 * 16),
|
|
||||||
Repositories: repos,
|
|
||||||
MaxBlobSize: int64(1024 * 1024),
|
|
||||||
CompressionLevel: 3,
|
|
||||||
AgeRecipients: []string{testAgePublicKey},
|
|
||||||
SkipErrors: skipErrors,
|
|
||||||
})
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
snapshotID := "test-snapshot-skip-errors"
|
|
||||||
createTestSnapshotRecord(ctx, t, repos, snapshotID)
|
|
||||||
|
|
||||||
_, err = scanner.Scan(ctx, "/source", snapshotID)
|
|
||||||
|
|
||||||
return repos, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestScannerPackingFailureAbortsUnderSkipErrors checks that a failure to start
|
|
||||||
// a new blob aborts the run even with --skip-errors. Otherwise the file would
|
|
||||||
// be skipped while its chunk had already been registered as pending, letting a
|
|
||||||
// later blob record that chunk in the chunks table with no blob to back it —
|
|
||||||
// a snapshot that completes with a file that cannot be restored.
|
|
||||||
func TestScannerPackingFailureAbortsUnderSkipErrors(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Two files with distinct content so each yields a distinct chunk: the
|
|
||||||
// first fails to start a blob, and without the fix the second's blob would
|
|
||||||
// commit the first's orphaned chunk row.
|
|
||||||
fs := &blobTempFailFs{Fs: afero.NewMemMapFs()}
|
|
||||||
writeSkipErrorTestFile(t, fs, "/source/file1.txt", "first file content")
|
|
||||||
writeSkipErrorTestFile(t, fs, "/source/file2.txt", "second file content")
|
|
||||||
|
|
||||||
repos, err := runSkipErrorScan(t, fs, true)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected scan to abort on the packer error, got nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListUnpacked returns chunks recorded with no blob_chunks row: exactly the
|
|
||||||
// unrestorable state this fix prevents.
|
|
||||||
unpacked, err := repos.Chunks.ListUnpacked(context.Background(), 10)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("listing unpacked chunks: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(unpacked) != 0 {
|
|
||||||
t.Fatalf("expected no chunk recorded without a blob, got %d", len(unpacked))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestScannerReadErrorAbortsWithoutSkipErrors checks that a file read error
|
|
||||||
// aborts the run when --skip-errors is not set.
|
|
||||||
func TestScannerReadErrorAbortsWithoutSkipErrors(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const target = "/source/unreadable.txt"
|
|
||||||
|
|
||||||
fs := &readFailFs{Fs: afero.NewMemMapFs(), target: target}
|
|
||||||
writeSkipErrorTestFile(t, fs, target, "content that cannot be read")
|
|
||||||
|
|
||||||
_, err := runSkipErrorScan(t, fs, false)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected scan to fail on the read error, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestScannerReadErrorSkippedWithSkipErrors checks that a file read error is
|
|
||||||
// skipped and the run completes when --skip-errors is set.
|
|
||||||
func TestScannerReadErrorSkippedWithSkipErrors(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const target = "/source/unreadable.txt"
|
|
||||||
|
|
||||||
fs := &readFailFs{Fs: afero.NewMemMapFs(), target: target}
|
|
||||||
writeSkipErrorTestFile(t, fs, target, "content that cannot be read")
|
|
||||||
|
|
||||||
repos, err := runSkipErrorScan(t, fs, true)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected scan to complete with --skip-errors, got %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
chunks, err := repos.FileChunks.GetByFile(context.Background(), target)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("getting file chunks: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(chunks) != 0 {
|
|
||||||
t.Fatalf("expected unreadable file skipped, got %d chunks", len(chunks))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+135
-111
@@ -24,7 +24,7 @@ package snapshot
|
|||||||
// 7. Close the temporary database
|
// 7. Close the temporary database
|
||||||
// 8. VACUUM the database to remove deleted data and compact (security critical)
|
// 8. VACUUM the database to remove deleted data and compact (security critical)
|
||||||
// 9. Compress the binary database with zstd
|
// 9. Compress the binary database with zstd
|
||||||
// 10. Encrypt the compressed database with age (always; recipients are required)
|
// 10. Encrypt the compressed database with age (if encryption is enabled)
|
||||||
// 11. Upload to S3 as: metadata/{snapshot-id}/db.zst.age
|
// 11. Upload to S3 as: metadata/{snapshot-id}/db.zst.age
|
||||||
// 12. Reopen the main database
|
// 12. Reopen the main database
|
||||||
//
|
//
|
||||||
@@ -44,7 +44,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -201,14 +201,11 @@ func (sm *SnapshotManager) UpdateSnapshotStatsExtended(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// PopulateSnapshotBlobs ensures snapshot_blobs holds an entry for every
|
// CompleteSnapshot marks a snapshot as completed and ensures snapshot_blobs
|
||||||
// blob that stores a chunk referenced by the snapshot's files, including
|
// is populated with every blob holding any chunk referenced by the
|
||||||
// blobs deduplicated from earlier snapshots. Without it, a fully
|
// snapshot's files (including deduplicated blobs uploaded by prior
|
||||||
// deduplicated snapshot would record no blobs and be unrestorable.
|
// snapshots). Without this, fully-deduplicated snapshots are unrestorable.
|
||||||
//
|
func (sm *SnapshotManager) CompleteSnapshot(
|
||||||
// This must run before ExportSnapshotMetadata: the blob manifest and the
|
|
||||||
// trimmed metadata database are both built from snapshot_blobs.
|
|
||||||
func (sm *SnapshotManager) PopulateSnapshotBlobs(
|
|
||||||
ctx context.Context, snapshotID string,
|
ctx context.Context, snapshotID string,
|
||||||
) error {
|
) error {
|
||||||
err := sm.repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
err := sm.repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
||||||
@@ -222,25 +219,6 @@ func (sm *SnapshotManager) PopulateSnapshotBlobs(
|
|||||||
"snapshot_id", snapshotID, "added", added)
|
"snapshot_id", snapshotID, "added", added)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("populating snapshot blobs: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarkSnapshotComplete records the snapshot's completion timestamp. On the
|
|
||||||
// backup path this runs only after ExportSnapshotMetadata has succeeded, so
|
|
||||||
// the local index never marks a snapshot complete while the destination
|
|
||||||
// holds no manifest or database for it. A crash before this point leaves the
|
|
||||||
// snapshot incomplete, and the next run's PruneDatabase drops it. See
|
|
||||||
// https://git.eeqj.de/sneak/vaultik/issues/177.
|
|
||||||
func (sm *SnapshotManager) MarkSnapshotComplete(
|
|
||||||
ctx context.Context, snapshotID string,
|
|
||||||
) error {
|
|
||||||
err := sm.repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
|
||||||
return sm.repos.Snapshots.MarkComplete(ctx, tx, snapshotID)
|
return sm.repos.Snapshots.MarkComplete(ctx, tx, snapshotID)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -252,22 +230,6 @@ func (sm *SnapshotManager) MarkSnapshotComplete(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CompleteSnapshot populates snapshot_blobs and then marks the snapshot
|
|
||||||
// complete. The backup path (finalizeSnapshotMetadata) instead calls the two
|
|
||||||
// halves separately, with the metadata export between them, so completion is
|
|
||||||
// recorded only after a successful export. This convenience is for callers
|
|
||||||
// that do not interleave an export.
|
|
||||||
func (sm *SnapshotManager) CompleteSnapshot(
|
|
||||||
ctx context.Context, snapshotID string,
|
|
||||||
) error {
|
|
||||||
err := sm.PopulateSnapshotBlobs(ctx, snapshotID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return sm.MarkSnapshotComplete(ctx, snapshotID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExportSnapshotMetadata exports snapshot metadata to S3
|
// ExportSnapshotMetadata exports snapshot metadata to S3
|
||||||
//
|
//
|
||||||
// This method executes the complete snapshot metadata export process:
|
// This method executes the complete snapshot metadata export process:
|
||||||
@@ -276,12 +238,14 @@ func (sm *SnapshotManager) CompleteSnapshot(
|
|||||||
// 3. Cleans the copy to contain only current snapshot data
|
// 3. Cleans the copy to contain only current snapshot data
|
||||||
// 4. Dumps the cleaned database to SQL
|
// 4. Dumps the cleaned database to SQL
|
||||||
// 5. Compresses the SQL dump with zstd
|
// 5. Compresses the SQL dump with zstd
|
||||||
// 6. Encrypts the compressed data with age (always; recipients are required)
|
// 6. Encrypts the compressed data (if encryption is enabled)
|
||||||
// 7. Uploads to S3 at: snapshots/{snapshot-id}.sql.zst[.age]
|
// 7. Uploads to S3 at: snapshots/{snapshot-id}.sql.zst[.age]
|
||||||
//
|
//
|
||||||
// The only caller (finalizeSnapshotMetadata) does not close the main database
|
// The caller is responsible for:
|
||||||
// before calling this method: the index is copied at dbPath while it is still
|
// - Ensuring the main database is closed before calling this method
|
||||||
// open, and every step here operates on that copy, never on the live index.
|
// - Reopening the main database after this method returns
|
||||||
|
//
|
||||||
|
// This ensures database consistency during the copy operation.
|
||||||
func (sm *SnapshotManager) ExportSnapshotMetadata(
|
func (sm *SnapshotManager) ExportSnapshotMetadata(
|
||||||
ctx context.Context, dbPath string, snapshotID string,
|
ctx context.Context, dbPath string, snapshotID string,
|
||||||
) error {
|
) error {
|
||||||
@@ -331,6 +295,68 @@ func (sm *SnapshotManager) ExportSnapshotMetadata(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CleanupIncompleteSnapshots removes incomplete snapshots that don't have
|
||||||
|
// metadata in S3. This is critical for data safety: incomplete snapshots
|
||||||
|
// can cause deduplication to skip files that were never successfully
|
||||||
|
// backed up, resulting in data loss.
|
||||||
|
func (sm *SnapshotManager) CleanupIncompleteSnapshots(
|
||||||
|
ctx context.Context, hostname string,
|
||||||
|
) error {
|
||||||
|
log.Info("Checking for incomplete snapshots", "hostname", hostname)
|
||||||
|
|
||||||
|
// Get all incomplete snapshots for this hostname
|
||||||
|
incompleteSnapshots, err := sm.repos.Snapshots.GetIncompleteByHostname(ctx, hostname)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("getting incomplete snapshots: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(incompleteSnapshots) == 0 {
|
||||||
|
log.Debug("No incomplete snapshots found")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("Found incomplete snapshots", "count", len(incompleteSnapshots))
|
||||||
|
|
||||||
|
// Check each incomplete snapshot for metadata in storage
|
||||||
|
for _, snapshot := range incompleteSnapshots {
|
||||||
|
// Check if metadata exists in storage (paths use the hashed
|
||||||
|
// remote key so we don't leak host info to the listing).
|
||||||
|
metadataKey := fmt.Sprintf("metadata/%s/db.zst",
|
||||||
|
RemoteSnapshotKey(snapshot.ID.String()))
|
||||||
|
|
||||||
|
_, err := sm.storage.Stat(ctx, metadataKey)
|
||||||
|
if err != nil {
|
||||||
|
// Metadata doesn't exist in S3 - this is an incomplete snapshot
|
||||||
|
log.Info("Cleaning up incomplete snapshot record",
|
||||||
|
"snapshot_id", snapshot.ID, "started_at", snapshot.StartedAt)
|
||||||
|
|
||||||
|
// Delete the snapshot and all its associations
|
||||||
|
err := sm.deleteSnapshot(ctx, snapshot.ID.String())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("deleting incomplete snapshot %s: %w",
|
||||||
|
snapshot.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("Deleted incomplete snapshot record and associated data",
|
||||||
|
"snapshot_id", snapshot.ID)
|
||||||
|
} else {
|
||||||
|
// Metadata exists - this snapshot was completed but database wasn't updated
|
||||||
|
// This shouldn't happen in normal operation, but mark it complete
|
||||||
|
log.Warn("Found snapshot with remote metadata but incomplete in database",
|
||||||
|
"snapshot_id", snapshot.ID)
|
||||||
|
|
||||||
|
err := sm.repos.Snapshots.MarkComplete(ctx, nil, snapshot.ID.String())
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to mark snapshot as complete in database",
|
||||||
|
"snapshot_id", snapshot.ID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// CleanupOrphanedData removes files, chunks, and blobs that are no longer
|
// CleanupOrphanedData removes files, chunks, and blobs that are no longer
|
||||||
// referenced by any snapshot. This should be called periodically to clean
|
// referenced by any snapshot. This should be called periodically to clean
|
||||||
// up data from deleted or incomplete snapshots.
|
// up data from deleted or incomplete snapshots.
|
||||||
@@ -451,11 +477,9 @@ func (sm *SnapshotManager) prepareExportDB(
|
|||||||
// uploadSnapshotArtifacts uploads the database backup and blob manifest
|
// uploadSnapshotArtifacts uploads the database backup and blob manifest
|
||||||
// to remote storage at metadata/<remote-key>/, where remote-key is the
|
// to remote storage at metadata/<remote-key>/, where remote-key is the
|
||||||
// double-SHA256 derivation of the snapshot ID (see RemoteSnapshotKey).
|
// double-SHA256 derivation of the snapshot ID (see RemoteSnapshotKey).
|
||||||
// The human-readable snapshot ID is never written into an unencrypted part
|
// We never write the human-readable snapshot ID into any unencrypted
|
||||||
// of remote storage, so a plain listing shows only the hashed key, not the
|
// part of remote storage so a listing of the destination bucket leaks
|
||||||
// hostname or snapshot name. The hash uses no secret, so a guessed hostname
|
// no host, configuration, or scheduling information.
|
||||||
// and snapshot name can still be confirmed against a listing, and the backup
|
|
||||||
// time is public: the manifest carries a plaintext timestamp.
|
|
||||||
func (sm *SnapshotManager) uploadSnapshotArtifacts(
|
func (sm *SnapshotManager) uploadSnapshotArtifacts(
|
||||||
ctx context.Context, snapshotID string, dbData, manifestData []byte,
|
ctx context.Context, snapshotID string, dbData, manifestData []byte,
|
||||||
) error {
|
) error {
|
||||||
@@ -645,31 +669,14 @@ func (sm *SnapshotManager) collectCleanupStats(
|
|||||||
|
|
||||||
// vacuumDatabase runs VACUUM on the database to remove deleted data and compact
|
// vacuumDatabase runs VACUUM on the database to remove deleted data and compact
|
||||||
// This is critical for security - ensures no stale/deleted data pages are uploaded
|
// This is critical for security - ensures no stale/deleted data pages are uploaded
|
||||||
//
|
|
||||||
// VACUUM runs through the modernc.org/sqlite driver, on a freshly opened
|
|
||||||
// connection with no transaction in flight (VACUUM cannot run inside one).
|
|
||||||
// The database opens in WAL mode, so VACUUM's rewrite lands in the WAL; the
|
|
||||||
// checkpoint on Close flushes it into the main file, which is the file we
|
|
||||||
// then compress and upload.
|
|
||||||
func (sm *SnapshotManager) vacuumDatabase(ctx context.Context, dbPath string) error {
|
func (sm *SnapshotManager) vacuumDatabase(ctx context.Context, dbPath string) error {
|
||||||
log.Debug("Running VACUUM on database", "path", dbPath)
|
log.Debug("Running VACUUM on database", "path", dbPath)
|
||||||
|
//nolint:gosec // G204: fixed argv; dbPath is our own temp file path
|
||||||
|
cmd := exec.CommandContext(ctx, "sqlite3", dbPath, "VACUUM;")
|
||||||
|
|
||||||
db, err := database.New(ctx, dbPath)
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("opening database for VACUUM: %w", err)
|
return fmt.Errorf("running VACUUM: %w (output: %s)", err, string(output))
|
||||||
}
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
cerr := db.Close()
|
|
||||||
if cerr != nil {
|
|
||||||
log.Debug("Failed to close database after VACUUM",
|
|
||||||
"path", dbPath, "error", cerr)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
_, err = db.ExecWithLog(ctx, "VACUUM")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("running VACUUM: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -735,18 +742,12 @@ func (sm *SnapshotManager) compressFile(inputPath, outputPath string) error {
|
|||||||
|
|
||||||
writerClosed = true
|
writerClosed = true
|
||||||
|
|
||||||
log.Debug("Compression complete", "hash", hex.EncodeToString(writer.ContentID()))
|
log.Debug("Compression complete", "hash", hex.EncodeToString(writer.Sum256()))
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// exportCopyPerm restricts the exported snapshot database copy to the owning
|
// copyFile copies a file from src to dst
|
||||||
// user; it holds the same private index data as the local index file.
|
|
||||||
const exportCopyPerm = 0o600
|
|
||||||
|
|
||||||
// copyFile copies a file from src to dst. The destination is the exported
|
|
||||||
// snapshot database, so it is created owner-only rather than with the
|
|
||||||
// umask-dependent default.
|
|
||||||
func (sm *SnapshotManager) copyFile(src, dst string) error {
|
func (sm *SnapshotManager) copyFile(src, dst string) error {
|
||||||
log.Debug("Opening source file for copy", "path", src)
|
log.Debug("Opening source file for copy", "path", src)
|
||||||
|
|
||||||
@@ -766,9 +767,7 @@ func (sm *SnapshotManager) copyFile(src, dst string) error {
|
|||||||
|
|
||||||
log.Debug("Creating destination file", "path", dst)
|
log.Debug("Creating destination file", "path", dst)
|
||||||
|
|
||||||
destFile, err := sm.fs.OpenFile(
|
destFile, err := sm.fs.Create(dst)
|
||||||
dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, exportCopyPerm,
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -794,11 +793,6 @@ func (sm *SnapshotManager) copyFile(src, dst string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// errBlobMissingFromDatabase means a snapshot references a blob that is
|
|
||||||
// absent from the blobs table, so a complete manifest cannot be built.
|
|
||||||
var errBlobMissingFromDatabase = errors.New(
|
|
||||||
"blob referenced by snapshot is not in the database")
|
|
||||||
|
|
||||||
// generateBlobManifest creates a compressed JSON list of all blobs in the snapshot
|
// generateBlobManifest creates a compressed JSON list of all blobs in the snapshot
|
||||||
func (sm *SnapshotManager) generateBlobManifest(
|
func (sm *SnapshotManager) generateBlobManifest(
|
||||||
ctx context.Context, dbPath string, snapshotID string,
|
ctx context.Context, dbPath string, snapshotID string,
|
||||||
@@ -829,34 +823,25 @@ func (sm *SnapshotManager) generateBlobManifest(
|
|||||||
totalCompressedSize := int64(0)
|
totalCompressedSize := int64(0)
|
||||||
|
|
||||||
for _, hash := range blobHashes {
|
for _, hash := range blobHashes {
|
||||||
// Every blob the snapshot references must appear in the manifest.
|
|
||||||
// Prune consults only the manifest to decide what is still in use,
|
|
||||||
// so silently dropping a blob here would let a later prune delete
|
|
||||||
// it while this snapshot still needs it. A lookup failure or a
|
|
||||||
// missing blob row therefore fails manifest generation.
|
|
||||||
blob, err := repos.Blobs.GetByHash(ctx, hash)
|
blob, err := repos.Blobs.GetByHash(ctx, hash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("getting blob details for %s: %w", hash, err)
|
log.Warn("Failed to get blob details", "hash", hash, "error", err)
|
||||||
|
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if blob == nil {
|
if blob != nil {
|
||||||
return nil, fmt.Errorf("%w: blob %s, snapshot %s",
|
blobs = append(blobs, BlobInfo{
|
||||||
errBlobMissingFromDatabase, hash, snapshotID)
|
Hash: hash,
|
||||||
|
CompressedSize: blob.CompressedSize,
|
||||||
|
})
|
||||||
|
totalCompressedSize += blob.CompressedSize
|
||||||
}
|
}
|
||||||
|
|
||||||
blobs = append(blobs, BlobInfo{
|
|
||||||
Hash: hash,
|
|
||||||
CompressedSize: blob.CompressedSize,
|
|
||||||
})
|
|
||||||
totalCompressedSize += blob.CompressedSize
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create manifest. SnapshotID in the unencrypted manifest is the
|
// Create manifest. SnapshotID in the unencrypted manifest is the
|
||||||
// double-SHA256 remote key (see RemoteSnapshotKey), not the human ID, so
|
// double-SHA256 remote key, not the human ID, so the public bytes
|
||||||
// neither this field nor the directory name spells out the hostname or
|
// don't reveal hostname/snapshot-name/timestamp metadata.
|
||||||
// snapshot name — but the key uses no secret, so a guessed hostname and
|
|
||||||
// snapshot name can be confirmed. Timestamp below is written in the clear,
|
|
||||||
// so the backup time is observable to anyone who can read the manifest.
|
|
||||||
manifest := &Manifest{
|
manifest := &Manifest{
|
||||||
SnapshotID: RemoteSnapshotKey(snapshotID),
|
SnapshotID: RemoteSnapshotKey(snapshotID),
|
||||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||||
@@ -910,6 +895,45 @@ type ExtendedBackupStats struct {
|
|||||||
UploadDurationMs int64 // Total milliseconds spent uploading to S3
|
UploadDurationMs int64 // Total milliseconds spent uploading to S3
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deleteSnapshot removes a snapshot and all its associations from the database
|
||||||
|
func (sm *SnapshotManager) deleteSnapshot(
|
||||||
|
ctx context.Context, snapshotID string,
|
||||||
|
) error {
|
||||||
|
// Delete snapshot_files entries
|
||||||
|
err := sm.repos.Snapshots.DeleteSnapshotFiles(ctx, snapshotID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("deleting snapshot files: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete snapshot_blobs entries
|
||||||
|
err = sm.repos.Snapshots.DeleteSnapshotBlobs(ctx, snapshotID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("deleting snapshot blobs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete uploads entries (has foreign key to snapshots without CASCADE)
|
||||||
|
err = sm.repos.Snapshots.DeleteSnapshotUploads(ctx, snapshotID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("deleting snapshot uploads: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the snapshot itself
|
||||||
|
err = sm.repos.Snapshots.Delete(ctx, snapshotID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("deleting snapshot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up orphaned data
|
||||||
|
log.Debug("Cleaning up orphaned records in main database")
|
||||||
|
|
||||||
|
err = sm.CleanupOrphanedData(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cleaning up orphaned data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// deleteOtherSnapshots deletes all snapshots except the current one
|
// deleteOtherSnapshots deletes all snapshots except the current one
|
||||||
func (sm *SnapshotManager) deleteOtherSnapshots(
|
func (sm *SnapshotManager) deleteOtherSnapshots(
|
||||||
ctx context.Context, tx *sql.Tx, currentSnapshotID string,
|
ctx context.Context, tx *sql.Tx, currentSnapshotID string,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
package snapshot
|
package snapshot
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"io"
|
"io"
|
||||||
@@ -97,97 +96,6 @@ func verifyCleanedDB(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestVacuumDatabaseRemovesDeletedData proves the export path uploads a
|
|
||||||
// compacted database: after rows carrying a recognizable marker are deleted
|
|
||||||
// and vacuumDatabase runs, no page holding that marker survives in the file
|
|
||||||
// on disk (the file compressFile later reads for upload).
|
|
||||||
func TestVacuumDatabaseRemovesDeletedData(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
fs := afero.NewOsFs()
|
|
||||||
|
|
||||||
tempDir := t.TempDir()
|
|
||||||
dbPath := filepath.Join(tempDir, "snapshot.db")
|
|
||||||
|
|
||||||
db, err := database.New(ctx, dbPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create database: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A marker distinctive enough that its presence in the raw file can only
|
|
||||||
// come from the rows inserted below.
|
|
||||||
marker := []byte("VACUUM_PROBE_DEADBEEF_DELETED_ROW")
|
|
||||||
payload := bytes.Repeat(marker, 128) // ~4 KiB per row
|
|
||||||
|
|
||||||
_, err = db.Conn().ExecContext(ctx,
|
|
||||||
"CREATE TABLE vacuum_probe (id INTEGER PRIMARY KEY, payload BLOB)")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create probe table: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for range 512 {
|
|
||||||
_, err = db.Conn().ExecContext(ctx,
|
|
||||||
"INSERT INTO vacuum_probe (payload) VALUES (?)", payload)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to insert probe row: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = db.Conn().ExecContext(ctx, "DELETE FROM vacuum_probe")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to delete probe rows: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close so the deletes reach the main file, mirroring the state
|
|
||||||
// prepareExportDB hands to vacuumDatabase.
|
|
||||||
err = db.Close()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to close database: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeInfo, err := fs.Stat(dbPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to stat database before vacuum: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeBytes, err := afero.ReadFile(fs, dbPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to read database before vacuum: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !bytes.Contains(beforeBytes, marker) {
|
|
||||||
t.Fatalf("expected deleted-row data to linger before vacuum")
|
|
||||||
}
|
|
||||||
|
|
||||||
sm := &SnapshotManager{fs: fs}
|
|
||||||
|
|
||||||
err = sm.vacuumDatabase(ctx, dbPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("vacuumDatabase failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
afterBytes, err := afero.ReadFile(fs, dbPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to read database after vacuum: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if bytes.Contains(afterBytes, marker) {
|
|
||||||
t.Fatalf("deleted-row data survived vacuum in the uploaded file")
|
|
||||||
}
|
|
||||||
|
|
||||||
afterInfo, err := fs.Stat(dbPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to stat database after vacuum: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if afterInfo.Size() >= beforeInfo.Size() {
|
|
||||||
t.Fatalf("expected vacuum to shrink the file: before=%d after=%d",
|
|
||||||
beforeInfo.Size(), afterInfo.Size())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCleanSnapshotDBEmptySnapshot(t *testing.T) {
|
func TestCleanSnapshotDBEmptySnapshot(t *testing.T) {
|
||||||
// Initialize logger
|
// Initialize logger
|
||||||
log.Initialize(log.Config{})
|
log.Initialize(log.Config{})
|
||||||
|
|||||||
@@ -1,198 +0,0 @@
|
|||||||
package storage_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"reflect"
|
|
||||||
"sort"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"sneak.berlin/go/vaultik/internal/storage"
|
|
||||||
)
|
|
||||||
|
|
||||||
// runStorerConformance is the shared Storer contract. Every backend that
|
|
||||||
// can run in-process is expected to pass it: TestFileStorer runs it against
|
|
||||||
// file://, TestS3Storer against s3://. A new backend inherits this coverage
|
|
||||||
// by passing its own constructor, so the contract is defined once.
|
|
||||||
//
|
|
||||||
// It exercises the public Storer interface: round-trip, stat, list with
|
|
||||||
// prefix filtering, overwrite, delete, delete-of-missing, and not-found on
|
|
||||||
// Get and Stat. Each section takes its own fresh backend instance, so the
|
|
||||||
// order of sections never matters and no section sees another's objects.
|
|
||||||
func runStorerConformance(t *testing.T, newStorer func(*testing.T) storage.Storer) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
conformanceRoundTrip(t, newStorer(t))
|
|
||||||
conformanceOverwrite(t, newStorer(t))
|
|
||||||
conformanceList(t, newStorer(t))
|
|
||||||
conformanceDelete(t, newStorer(t))
|
|
||||||
conformanceNotFound(t, newStorer(t))
|
|
||||||
}
|
|
||||||
|
|
||||||
// conformanceRoundTrip stores a nested key, then reads it back and stats it.
|
|
||||||
func conformanceRoundTrip(t *testing.T, s storage.Storer) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
key := "blobs/aa/bb/object.bin"
|
|
||||||
want := []byte("round-trip payload")
|
|
||||||
|
|
||||||
err := s.Put(ctx, key, bytes.NewReader(want))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Put: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
got := getBytes(t, s, key)
|
|
||||||
if !bytes.Equal(got, want) {
|
|
||||||
t.Errorf("Get returned %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := s.Stat(ctx, key)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Stat: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if info.Key != key {
|
|
||||||
t.Errorf("Stat key = %q, want %q", info.Key, key)
|
|
||||||
}
|
|
||||||
|
|
||||||
if info.Size != int64(len(want)) {
|
|
||||||
t.Errorf("Stat size = %d, want %d", info.Size, len(want))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// conformanceOverwrite checks that a second Put replaces the first.
|
|
||||||
func conformanceOverwrite(t *testing.T, s storage.Storer) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
key := "meta/snapshot.json"
|
|
||||||
|
|
||||||
err := s.Put(ctx, key, bytes.NewReader([]byte("first")))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("first Put: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
want := []byte("second and longer payload")
|
|
||||||
|
|
||||||
err = s.Put(ctx, key, bytes.NewReader(want))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("second Put: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
got := getBytes(t, s, key)
|
|
||||||
if !bytes.Equal(got, want) {
|
|
||||||
t.Errorf("after overwrite Get returned %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// conformanceList checks prefix filtering and the empty result for a
|
|
||||||
// prefix that matches nothing.
|
|
||||||
func conformanceList(t *testing.T, s storage.Storer) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
keys := []string{"blobs/aa/one", "blobs/bb/two", "meta/three"}
|
|
||||||
|
|
||||||
for _, k := range keys {
|
|
||||||
err := s.Put(ctx, k, bytes.NewReader([]byte("data")))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Put %q: %v", k, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if got := listSorted(t, s, ""); !reflect.DeepEqual(got, keys) {
|
|
||||||
t.Errorf("List(\"\") = %v, want %v", got, keys)
|
|
||||||
}
|
|
||||||
|
|
||||||
wantBlobs := []string{"blobs/aa/one", "blobs/bb/two"}
|
|
||||||
if got := listSorted(t, s, "blobs/"); !reflect.DeepEqual(got, wantBlobs) {
|
|
||||||
t.Errorf("List(\"blobs/\") = %v, want %v", got, wantBlobs)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got := listSorted(t, s, "absent/"); len(got) != 0 {
|
|
||||||
t.Errorf("List(\"absent/\") = %v, want empty", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// conformanceDelete checks that Delete removes an object and that deleting
|
|
||||||
// a missing key is not an error.
|
|
||||||
func conformanceDelete(t *testing.T, s storage.Storer) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
key := "blobs/cc/gone.bin"
|
|
||||||
|
|
||||||
err := s.Put(ctx, key, bytes.NewReader([]byte("temporary")))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Put: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = s.Delete(ctx, key)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Delete: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = s.Get(ctx, key)
|
|
||||||
if !errors.Is(err, storage.ErrNotFound) {
|
|
||||||
t.Errorf("Get after Delete error = %v, want ErrNotFound", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = s.Delete(ctx, key)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Delete of missing key = %v, want nil", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// conformanceNotFound checks Get and Stat on an absent key.
|
|
||||||
func conformanceNotFound(t *testing.T, s storage.Storer) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
key := "never/written"
|
|
||||||
|
|
||||||
_, err := s.Get(ctx, key)
|
|
||||||
if !errors.Is(err, storage.ErrNotFound) {
|
|
||||||
t.Errorf("Get error = %v, want ErrNotFound", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = s.Stat(ctx, key)
|
|
||||||
if !errors.Is(err, storage.ErrNotFound) {
|
|
||||||
t.Errorf("Stat error = %v, want ErrNotFound", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// getBytes reads a key fully and closes the reader.
|
|
||||||
func getBytes(t *testing.T, s storage.Storer, key string) []byte {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
rc, err := s.Get(context.Background(), key)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Get %q: %v", key, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = rc.Close() }()
|
|
||||||
|
|
||||||
data, err := io.ReadAll(rc)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read %q: %v", key, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
// listSorted returns the keys under a prefix in a stable order.
|
|
||||||
func listSorted(t *testing.T, s storage.Storer, prefix string) []string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
keys, err := s.List(context.Background(), prefix)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("List %q: %v", prefix, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Strings(keys)
|
|
||||||
|
|
||||||
return keys
|
|
||||||
}
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
// Package faultstore provides a storage.Storer wrapper that injects
|
|
||||||
// faults on demand, so tests can reproduce the failure modes a real
|
|
||||||
// backend exhibits: an upload that fails partway, a backend that reports
|
|
||||||
// success while storing nothing, and reads that return corrupt or
|
|
||||||
// truncated bytes. It is the seam called for by the fault-injection
|
|
||||||
// tests (sneak/vaultik issue 72) and is meant to be reused by future
|
|
||||||
// tests rather than re-implemented per case.
|
|
||||||
//
|
|
||||||
// The wrapper delegates every method to the inner Storer. Two hooks
|
|
||||||
// change that: OnPut decides the fate of each write, and OnGet decides
|
|
||||||
// how each read's bytes are returned. Both are keyed by the object key,
|
|
||||||
// so a test can fault only blobs, only metadata, or a single object.
|
|
||||||
package faultstore
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
|
|
||||||
"sneak.berlin/go/vaultik/internal/storage"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ErrInjectedUpload is returned by a Put the OnPut hook chose to fail.
|
|
||||||
var ErrInjectedUpload = errors.New("faultstore: injected upload failure")
|
|
||||||
|
|
||||||
// PutAction is the disposition OnPut assigns to a write.
|
|
||||||
type PutAction int
|
|
||||||
|
|
||||||
const (
|
|
||||||
// PutNormal writes through to the inner Storer.
|
|
||||||
PutNormal PutAction = iota
|
|
||||||
// PutFail reads part of the stream, then fails without storing the
|
|
||||||
// object — a network upload that dies partway through.
|
|
||||||
PutFail
|
|
||||||
// PutSwallow reports success but stores nothing — a backend that
|
|
||||||
// lies about durability.
|
|
||||||
PutSwallow
|
|
||||||
)
|
|
||||||
|
|
||||||
// GetFault is how OnGet chooses to damage a read.
|
|
||||||
type GetFault int
|
|
||||||
|
|
||||||
const (
|
|
||||||
// GetNormal returns the stored bytes unchanged.
|
|
||||||
GetNormal GetFault = iota
|
|
||||||
// GetCorrupt flips a byte so the returned object no longer matches
|
|
||||||
// what was stored.
|
|
||||||
GetCorrupt
|
|
||||||
// GetTruncate returns a short read: the object's bytes cut off
|
|
||||||
// before the end.
|
|
||||||
GetTruncate
|
|
||||||
)
|
|
||||||
|
|
||||||
// Storer wraps an inner storage.Storer with fault-injection hooks. A
|
|
||||||
// zero-valued hook means "no fault": construct with New and set only the
|
|
||||||
// hook a test needs.
|
|
||||||
type Storer struct {
|
|
||||||
inner storage.Storer
|
|
||||||
|
|
||||||
// OnPut, when set, is consulted before every Put and
|
|
||||||
// PutWithProgress with the object key.
|
|
||||||
OnPut func(key string) PutAction
|
|
||||||
|
|
||||||
// OnGet, when set, is consulted for every Get with the object key
|
|
||||||
// and damages the returned bytes accordingly.
|
|
||||||
OnGet func(key string) GetFault
|
|
||||||
}
|
|
||||||
|
|
||||||
// New wraps inner. inner must be non-nil.
|
|
||||||
func New(inner storage.Storer) *Storer {
|
|
||||||
return &Storer{inner: inner}
|
|
||||||
}
|
|
||||||
|
|
||||||
// midStreamBytes is how far a PutFail reads before failing, enough to be
|
|
||||||
// past the start of any real blob without depending on the blob's size.
|
|
||||||
const midStreamBytes = 512
|
|
||||||
|
|
||||||
// Put stores data unless OnPut faults the write.
|
|
||||||
func (f *Storer) Put(ctx context.Context, key string, data io.Reader) error {
|
|
||||||
handled, err := f.injectPut(key, data)
|
|
||||||
if handled {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return f.inner.Put(ctx, key, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// PutWithProgress stores data unless OnPut faults the write.
|
|
||||||
func (f *Storer) PutWithProgress(
|
|
||||||
ctx context.Context, key string, data io.Reader,
|
|
||||||
size int64, progress storage.ProgressCallback,
|
|
||||||
) error {
|
|
||||||
handled, err := f.injectPut(key, data)
|
|
||||||
if handled {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return f.inner.PutWithProgress(ctx, key, data, size, progress)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get retrieves data, damaging it if OnGet faults the read.
|
|
||||||
func (f *Storer) Get(ctx context.Context, key string) (io.ReadCloser, error) {
|
|
||||||
rc, err := f.inner.Get(ctx, key)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
fault := GetNormal
|
|
||||||
if f.OnGet != nil {
|
|
||||||
fault = f.OnGet(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
if fault == GetNormal {
|
|
||||||
return rc, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := io.ReadAll(rc)
|
|
||||||
_ = rc.Close()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return io.NopCloser(bytes.NewReader(damage(fault, data))), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// damage returns a faulted copy of the stored bytes. GetCorrupt flips a
|
|
||||||
// byte in the middle so decryption authentication fails; GetTruncate
|
|
||||||
// drops the final byte so the read ends short. Both are no-ops on empty
|
|
||||||
// input, which cannot be damaged into something distinguishable.
|
|
||||||
func damage(fault GetFault, data []byte) []byte {
|
|
||||||
out := make([]byte, len(data))
|
|
||||||
copy(out, data)
|
|
||||||
|
|
||||||
if len(out) == 0 {
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
switch fault {
|
|
||||||
case GetCorrupt:
|
|
||||||
out[len(out)/2] ^= 0xff
|
|
||||||
case GetTruncate:
|
|
||||||
out = out[:len(out)-1]
|
|
||||||
case GetNormal:
|
|
||||||
}
|
|
||||||
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stat delegates unchanged.
|
|
||||||
func (f *Storer) Stat(ctx context.Context, key string) (*storage.ObjectInfo, error) {
|
|
||||||
return f.inner.Stat(ctx, key)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete delegates unchanged.
|
|
||||||
func (f *Storer) Delete(ctx context.Context, key string) error {
|
|
||||||
return f.inner.Delete(ctx, key)
|
|
||||||
}
|
|
||||||
|
|
||||||
// List delegates unchanged.
|
|
||||||
func (f *Storer) List(ctx context.Context, prefix string) ([]string, error) {
|
|
||||||
return f.inner.List(ctx, prefix)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListStream delegates unchanged.
|
|
||||||
func (f *Storer) ListStream(
|
|
||||||
ctx context.Context, prefix string,
|
|
||||||
) <-chan storage.ObjectInfo {
|
|
||||||
return f.inner.ListStream(ctx, prefix)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Info delegates unchanged.
|
|
||||||
func (f *Storer) Info() storage.Info {
|
|
||||||
return f.inner.Info()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *Storer) putAction(key string) PutAction {
|
|
||||||
if f.OnPut == nil {
|
|
||||||
return PutNormal
|
|
||||||
}
|
|
||||||
|
|
||||||
return f.OnPut(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
// injectPut handles the non-normal write dispositions. It reports
|
|
||||||
// whether it handled the write and, if so, with what error.
|
|
||||||
func (f *Storer) injectPut(key string, data io.Reader) (bool, error) {
|
|
||||||
switch f.putAction(key) {
|
|
||||||
case PutFail:
|
|
||||||
// Consume part of the stream so the failure lands mid-transfer,
|
|
||||||
// the way a dropped connection would, then error without
|
|
||||||
// storing anything.
|
|
||||||
_, _ = io.CopyN(io.Discard, data, midStreamBytes)
|
|
||||||
|
|
||||||
return true, fmt.Errorf("%w for %q", ErrInjectedUpload, key)
|
|
||||||
case PutSwallow:
|
|
||||||
// A lying backend still drains the request body, then keeps
|
|
||||||
// nothing.
|
|
||||||
_, _ = io.Copy(io.Discard, data)
|
|
||||||
|
|
||||||
return true, nil
|
|
||||||
case PutNormal:
|
|
||||||
return false, nil
|
|
||||||
default:
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+54
-79
@@ -46,18 +46,31 @@ func (f *FileStorer) SetFilesystem(fs afero.Fs) {
|
|||||||
// storage base path.
|
// storage base path.
|
||||||
const storageDirPerm = 0o755
|
const storageDirPerm = 0o755
|
||||||
|
|
||||||
// tempSuffix marks a partially written object. writeAtomic streams into a
|
|
||||||
// temp file carrying this suffix and only renames it onto the real key once
|
|
||||||
// the whole object is on disk, so an interrupted write can never leave a
|
|
||||||
// truncated object at the key a later run would Stat and trust as a complete
|
|
||||||
// blob. List and ListStream skip these files, so a leftover from an
|
|
||||||
// interrupted write is never listed or trusted as a blob; it is otherwise
|
|
||||||
// harmless and is overwritten when the same key is written again.
|
|
||||||
const tempSuffix = ".partial"
|
|
||||||
|
|
||||||
// Put stores data at the specified key.
|
// Put stores data at the specified key.
|
||||||
func (f *FileStorer) Put(_ context.Context, key string, data io.Reader) error {
|
func (f *FileStorer) Put(_ context.Context, key string, data io.Reader) error {
|
||||||
return f.writeAtomic(key, data, nil)
|
path := f.fullPath(key)
|
||||||
|
|
||||||
|
// Create parent directories
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
|
||||||
|
err := f.fs.MkdirAll(dir, storageDirPerm)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating directories: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := f.fs.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = file.Close() }()
|
||||||
|
|
||||||
|
_, err = io.Copy(file, data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("writing file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// PutWithProgress stores data with progress reporting.
|
// PutWithProgress stores data with progress reporting.
|
||||||
@@ -65,7 +78,35 @@ func (f *FileStorer) PutWithProgress(
|
|||||||
_ context.Context, key string, data io.Reader,
|
_ context.Context, key string, data io.Reader,
|
||||||
_ int64, progress ProgressCallback,
|
_ int64, progress ProgressCallback,
|
||||||
) error {
|
) error {
|
||||||
return f.writeAtomic(key, data, progress)
|
path := f.fullPath(key)
|
||||||
|
|
||||||
|
// Create parent directories
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
|
||||||
|
err := f.fs.MkdirAll(dir, storageDirPerm)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating directories: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := f.fs.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = file.Close() }()
|
||||||
|
|
||||||
|
// Wrap with progress tracking
|
||||||
|
pw := &progressWriter{
|
||||||
|
writer: file,
|
||||||
|
callback: progress,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = io.Copy(pw, data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("writing file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get retrieves data from the specified key.
|
// Get retrieves data from the specified key.
|
||||||
@@ -147,7 +188,7 @@ func (f *FileStorer) List(ctx context.Context, prefix string) ([]string, error)
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
if !info.IsDir() && !strings.HasSuffix(info.Name(), tempSuffix) {
|
if !info.IsDir() {
|
||||||
// Convert back to key (relative path from basePath)
|
// Convert back to key (relative path from basePath)
|
||||||
relPath, err := filepath.Rel(f.basePath, path)
|
relPath, err := filepath.Rel(f.basePath, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -204,7 +245,7 @@ func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan Objec
|
|||||||
return nil //nolint:nilerr // continue walking despite errors
|
return nil //nolint:nilerr // continue walking despite errors
|
||||||
}
|
}
|
||||||
|
|
||||||
if !info.IsDir() && !strings.HasSuffix(info.Name(), tempSuffix) {
|
if !info.IsDir() {
|
||||||
relPath, err := filepath.Rel(f.basePath, path)
|
relPath, err := filepath.Rel(f.basePath, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ch <- ObjectInfo{Err: fmt.Errorf("computing relative path: %w", err)}
|
ch <- ObjectInfo{Err: fmt.Errorf("computing relative path: %w", err)}
|
||||||
@@ -234,72 +275,6 @@ func (f *FileStorer) Info() Info {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeAtomic streams data into a temp file in the destination directory,
|
|
||||||
// fsyncs it, and renames it onto the final key. The key therefore appears
|
|
||||||
// only once the whole object has been durably written; a failure part-way
|
|
||||||
// leaves a temp file (removed here on the failing path) rather than a
|
|
||||||
// truncated object at the key.
|
|
||||||
func (f *FileStorer) writeAtomic(
|
|
||||||
key string, data io.Reader, progress ProgressCallback,
|
|
||||||
) error {
|
|
||||||
path := f.fullPath(key)
|
|
||||||
dir := filepath.Dir(path)
|
|
||||||
|
|
||||||
err := f.fs.MkdirAll(dir, storageDirPerm)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("creating directories: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tmp, err := afero.TempFile(f.fs, dir, filepath.Base(path)+"-*"+tempSuffix)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("creating temp file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tmpPath := tmp.Name()
|
|
||||||
|
|
||||||
// Remove the temp file unless the rename below claims it. On the success
|
|
||||||
// path renamed is true, so the deferred Close and Remove are harmless
|
|
||||||
// no-ops on a name that no longer exists.
|
|
||||||
renamed := false
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
_ = tmp.Close()
|
|
||||||
|
|
||||||
if !renamed {
|
|
||||||
_ = f.fs.Remove(tmpPath)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
var w io.Writer = tmp
|
|
||||||
if progress != nil {
|
|
||||||
w = &progressWriter{writer: tmp, callback: progress}
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = io.Copy(w, data)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("writing file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = tmp.Sync()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("syncing temp file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = tmp.Close()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("closing temp file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = f.fs.Rename(tmpPath, path)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("renaming temp file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
renamed = true
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// fullPath returns the full filesystem path for a key.
|
// fullPath returns the full filesystem path for a key.
|
||||||
func (f *FileStorer) fullPath(key string) string {
|
func (f *FileStorer) fullPath(key string) string {
|
||||||
return filepath.Join(f.basePath, key)
|
return filepath.Join(f.basePath, key)
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
package storage_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"sneak.berlin/go/vaultik/internal/storage"
|
|
||||||
)
|
|
||||||
|
|
||||||
// errStreamInterrupted stands in for an upload cut off mid-stream.
|
|
||||||
var errStreamInterrupted = errors.New("connection reset mid-upload")
|
|
||||||
|
|
||||||
// failingReader yields its data once, then fails.
|
|
||||||
type failingReader struct {
|
|
||||||
data []byte
|
|
||||||
done bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *failingReader) Read(p []byte) (int, error) {
|
|
||||||
if r.done {
|
|
||||||
return 0, errStreamInterrupted
|
|
||||||
}
|
|
||||||
|
|
||||||
n := copy(p, r.data)
|
|
||||||
r.done = true
|
|
||||||
|
|
||||||
return n, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFileStorer_InterruptedWriteLeavesNoTrustedObject checks that a write
|
|
||||||
// cut off mid-stream leaves nothing at the destination key, so a later run
|
|
||||||
// cannot Stat a truncated object and trust it as a complete blob.
|
|
||||||
func TestFileStorer_InterruptedWriteLeavesNoTrustedObject(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
f, err := storage.NewFileStorer(t.TempDir())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewFileStorer: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
key := "blobs/aa/bb/aabbccddeeff"
|
|
||||||
|
|
||||||
err = f.PutWithProgress(ctx, key, &failingReader{data: []byte("partial")}, 4096, nil)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected the interrupted write to fail, got nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = f.Stat(ctx, key)
|
|
||||||
if !errors.Is(err, storage.ErrNotFound) {
|
|
||||||
t.Fatalf("expected key absent after interrupted write, got Stat err %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
keys, err := f.List(ctx, "blobs/")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("List: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(keys) != 0 {
|
|
||||||
t.Fatalf("expected no keys listed after interrupted write, got %v", keys)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFileStorer_ListSkipsPartialFiles checks that a leftover temp file (the
|
|
||||||
// storage layer names them with a ".partial" suffix) is never surfaced as a
|
|
||||||
// key by List or ListStream.
|
|
||||||
func TestFileStorer_ListSkipsPartialFiles(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
base := t.TempDir()
|
|
||||||
|
|
||||||
f, err := storage.NewFileStorer(base)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewFileStorer: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
realKey := "blobs/aa/bb/aabbccddeeff"
|
|
||||||
|
|
||||||
err = f.Put(ctx, realKey, strings.NewReader("blob-bytes"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Put: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A stray temp file, as an interrupted write would leave behind.
|
|
||||||
leftover := filepath.Join(base, "blobs/aa/bb/aabbccddeeff-123456.partial")
|
|
||||||
|
|
||||||
err = os.WriteFile(leftover, []byte("half"), 0o600)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("writing leftover temp file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
keys, err := f.List(ctx, "blobs/")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("List: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(keys) != 1 || keys[0] != realKey {
|
|
||||||
t.Fatalf("List should return only the real key, got %v", keys)
|
|
||||||
}
|
|
||||||
|
|
||||||
var streamed []string
|
|
||||||
|
|
||||||
for obj := range f.ListStream(ctx, "blobs/") {
|
|
||||||
if obj.Err != nil {
|
|
||||||
t.Fatalf("ListStream: %v", obj.Err)
|
|
||||||
}
|
|
||||||
|
|
||||||
streamed = append(streamed, obj.Key)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(streamed) != 1 || streamed[0] != realKey {
|
|
||||||
t.Fatalf("ListStream should return only the real key, got %v", streamed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
package storage_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"sneak.berlin/go/vaultik/internal/storage"
|
|
||||||
)
|
|
||||||
|
|
||||||
// newFileStorer builds a file:// backend rooted at a fresh temp directory.
|
|
||||||
//
|
|
||||||
//nolint:ireturn // conformance runs against the Storer interface by design
|
|
||||||
func newFileStorer(t *testing.T) storage.Storer {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
s, err := storage.NewFileStorer(t.TempDir())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewFileStorer: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFileStorer runs the shared Storer contract against the file:// backend.
|
|
||||||
func TestFileStorer(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
runStorerConformance(t, newFileStorer)
|
|
||||||
}
|
|
||||||
@@ -111,11 +111,10 @@ func storerFromParsedS3URL(parsed *URL, cfg *config.Config) (Storer, error) {
|
|||||||
func storerFromLegacyS3Config(cfg *config.Config) (Storer, error) {
|
func storerFromLegacyS3Config(cfg *config.Config) (Storer, error) {
|
||||||
endpoint := cfg.S3.Endpoint
|
endpoint := cfg.S3.Endpoint
|
||||||
|
|
||||||
// Ensure protocol is present. Absent an explicit use_ssl, default to TLS;
|
// Ensure protocol is present
|
||||||
// plain HTTP only when use_ssl is written as false.
|
|
||||||
if !strings.HasPrefix(endpoint, "http://") &&
|
if !strings.HasPrefix(endpoint, "http://") &&
|
||||||
!strings.HasPrefix(endpoint, "https://") {
|
!strings.HasPrefix(endpoint, "https://") {
|
||||||
if cfg.S3.UseSSL == nil || *cfg.S3.UseSSL {
|
if cfg.S3.UseSSL {
|
||||||
endpoint = "https://" + endpoint
|
endpoint = "https://" + endpoint
|
||||||
} else {
|
} else {
|
||||||
endpoint = "http://" + endpoint
|
endpoint = "http://" + endpoint
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
package storage_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"sneak.berlin/go/vaultik/internal/config"
|
|
||||||
"sneak.berlin/go/vaultik/internal/storage"
|
|
||||||
)
|
|
||||||
|
|
||||||
// legacyS3Config returns a minimal s3.* (no storage_url) configuration with a
|
|
||||||
// scheme-less endpoint. useSSL mirrors the config file: nil means the key is
|
|
||||||
// omitted, a pointer means it was written explicitly.
|
|
||||||
func legacyS3Config(useSSL *bool) *config.Config {
|
|
||||||
return &config.Config{
|
|
||||||
S3: config.S3Config{
|
|
||||||
Endpoint: "s3.example.com",
|
|
||||||
Bucket: "bucket",
|
|
||||||
AccessKeyID: "key",
|
|
||||||
SecretAccessKey: "secret",
|
|
||||||
Region: "us-east-1",
|
|
||||||
UseSSL: useSSL,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// endpointScheme builds the storer from cfg and returns the scheme its
|
|
||||||
// resolved endpoint carries (Info().Location is "endpoint/bucket").
|
|
||||||
func endpointScheme(t *testing.T, cfg *config.Config) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
storer, err := storage.NewStorer(cfg)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewStorer: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
location := storer.Info().Location
|
|
||||||
switch {
|
|
||||||
case strings.HasPrefix(location, "https://"):
|
|
||||||
return "https"
|
|
||||||
case strings.HasPrefix(location, "http://"):
|
|
||||||
return "http"
|
|
||||||
default:
|
|
||||||
t.Fatalf("endpoint has no http(s) scheme: %q", location)
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLegacyS3SchemelessEndpointDefaultsToTLS(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
if got := endpointScheme(t, legacyS3Config(nil)); got != "https" {
|
|
||||||
t.Errorf("use_ssl omitted: got %q scheme, want https", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
no := false
|
|
||||||
if got := endpointScheme(t, legacyS3Config(&no)); got != "http" {
|
|
||||||
t.Errorf("use_ssl: false: got %q scheme, want http", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
package storage_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"sneak.berlin/go/vaultik/internal/storage"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The rclone backend is a thin adapter over the rclone library: it turns a
|
|
||||||
// (remote, path) pair into rclone's "remote:path" string, hands it to
|
|
||||||
// rclone, and maps rclone's own results back to the Storer interface. What
|
|
||||||
// can be tested in-process, without a configured remote or network, is that
|
|
||||||
// adapter layer — how the arguments are shaped and how construction errors
|
|
||||||
// are reported. The data-plane operations (Put/Get/List/Delete) are rclone's
|
|
||||||
// own, exercised against a real provider (drive, s3-via-rclone, ...), which
|
|
||||||
// needs a configured remote with credentials and network access and so is
|
|
||||||
// out of reach of a unit test. The shared Storer conformance suite therefore
|
|
||||||
// runs against the in-process file and s3 backends; the rclone backend
|
|
||||||
// inherits that contract once a remote is configured.
|
|
||||||
//
|
|
||||||
// These tests use rclone's ":local:" on-the-fly backend, which addresses the
|
|
||||||
// local filesystem directly without any configured remote, so construction
|
|
||||||
// runs entirely in-process.
|
|
||||||
|
|
||||||
// TestNewRcloneStorerConstruction checks that a valid remote constructs a
|
|
||||||
// backend and that Info() reports the shaped "remote:path" location.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // NewRcloneStorer installs the process-global rclone config
|
|
||||||
func TestNewRcloneStorerConstruction(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
|
|
||||||
s, err := storage.NewRcloneStorer(context.Background(), ":local", dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewRcloneStorer: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Info().Location is the "remote:path" string the adapter builds from
|
|
||||||
// its two arguments, so asserting it confirms the argument shaping.
|
|
||||||
want := ":local:" + dir
|
|
||||||
if got := s.Info().Location; got != want {
|
|
||||||
t.Errorf("Info().Location = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNewRcloneStorerUnknownRemote checks that a remote that is not in the
|
|
||||||
// rclone config fails construction with the ErrRemoteNotFound sentinel,
|
|
||||||
// rather than silently returning a backend pointed nowhere.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // NewRcloneStorer installs the process-global rclone config
|
|
||||||
func TestNewRcloneStorerUnknownRemote(t *testing.T) {
|
|
||||||
_, err := storage.NewRcloneStorer(
|
|
||||||
context.Background(), "vaultik-no-such-remote", "path")
|
|
||||||
if !errors.Is(err, storage.ErrRemoteNotFound) {
|
|
||||||
t.Errorf("NewRcloneStorer error = %v, want ErrRemoteNotFound", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-16
@@ -38,29 +38,14 @@ func (s *S3Storer) PutWithProgress(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get retrieves data from the specified key.
|
// Get retrieves data from the specified key.
|
||||||
// Returns ErrNotFound if the object does not exist.
|
|
||||||
func (s *S3Storer) Get(ctx context.Context, key string) (io.ReadCloser, error) {
|
func (s *S3Storer) Get(ctx context.Context, key string) (io.ReadCloser, error) {
|
||||||
rc, err := s.client.GetObject(ctx, key)
|
return s.client.GetObject(ctx, key)
|
||||||
if err != nil {
|
|
||||||
if s3.IsNotFound(err) {
|
|
||||||
return nil, fmt.Errorf("get %q: %w", key, ErrNotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return rc, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stat returns metadata about an object without retrieving its contents.
|
// Stat returns metadata about an object without retrieving its contents.
|
||||||
// Returns ErrNotFound if the object does not exist.
|
|
||||||
func (s *S3Storer) Stat(ctx context.Context, key string) (*ObjectInfo, error) {
|
func (s *S3Storer) Stat(ctx context.Context, key string) (*ObjectInfo, error) {
|
||||||
info, err := s.client.StatObject(ctx, key)
|
info, err := s.client.StatObject(ctx, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if s3.IsNotFound(err) {
|
|
||||||
return nil, fmt.Errorf("stat %q: %w", key, ErrNotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
package storage_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/johannesboyne/gofakes3"
|
|
||||||
"github.com/johannesboyne/gofakes3/backend/s3mem"
|
|
||||||
|
|
||||||
"sneak.berlin/go/vaultik/internal/s3"
|
|
||||||
"sneak.berlin/go/vaultik/internal/storage"
|
|
||||||
)
|
|
||||||
|
|
||||||
// s3TestBucket is the bucket created for each in-process S3 server.
|
|
||||||
const s3TestBucket = "test-bucket"
|
|
||||||
|
|
||||||
// newS3Storer builds an s3:// backend backed by a fresh in-process
|
|
||||||
// S3 server. It reuses the same in-memory S3 harness (gofakes3 + s3mem
|
|
||||||
// over httptest) that internal/s3 and the not-found regression test use,
|
|
||||||
// so no new mock or dependency is introduced. Each call gets its own
|
|
||||||
// server, bucket, and client, so the conformance suite's per-section
|
|
||||||
// instances stay isolated.
|
|
||||||
//
|
|
||||||
//nolint:ireturn // conformance runs against the Storer interface by design
|
|
||||||
func newS3Storer(t *testing.T) storage.Storer {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
backend := s3mem.New()
|
|
||||||
|
|
||||||
err := backend.CreateBucket(s3TestBucket)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create bucket: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
srv := httptest.NewServer(gofakes3.New(backend).Server())
|
|
||||||
t.Cleanup(srv.Close)
|
|
||||||
|
|
||||||
client, err := s3.NewClient(context.Background(), s3.Config{
|
|
||||||
Endpoint: srv.URL,
|
|
||||||
Bucket: s3TestBucket,
|
|
||||||
AccessKeyID: "test",
|
|
||||||
SecretAccessKey: "test",
|
|
||||||
Region: "us-east-1",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("new client: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return storage.NewS3Storer(client)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestS3Storer runs the shared Storer contract against the s3:// backend,
|
|
||||||
// so it is held to the same round-trip, list, delete, and not-found
|
|
||||||
// behaviour as the file:// backend.
|
|
||||||
func TestS3Storer(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
runStorerConformance(t, newS3Storer)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestS3StorerMissingKeyMapsToErrNotFound pins the specific contract that a
|
|
||||||
// missing object surfaces as storage.ErrNotFound rather than the raw AWS SDK
|
|
||||||
// error. Without the mapping, errors.Is(err, storage.ErrNotFound) is false on
|
|
||||||
// s3 and callers would branch differently per backend.
|
|
||||||
func TestS3StorerMissingKeyMapsToErrNotFound(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
storer := newS3Storer(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
_, err := storer.Get(ctx, "does-not-exist")
|
|
||||||
if !errors.Is(err, storage.ErrNotFound) {
|
|
||||||
t.Errorf("Get on missing key: got %v, want ErrNotFound", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = storer.Stat(ctx, "does-not-exist")
|
|
||||||
if !errors.Is(err, storage.ErrNotFound) {
|
|
||||||
t.Errorf("Stat on missing key: got %v, want ErrNotFound", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+46
-101
@@ -4,7 +4,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,10 +23,6 @@ var (
|
|||||||
ErrUnsupportedScheme = errors.New(
|
ErrUnsupportedScheme = errors.New(
|
||||||
"unsupported URL scheme: must start with s3://, file://, or rclone://")
|
"unsupported URL scheme: must start with s3://, file://, or rclone://")
|
||||||
ErrUnsupportedStorage = errors.New("unsupported storage scheme")
|
ErrUnsupportedStorage = errors.New("unsupported storage scheme")
|
||||||
ErrURLCredentials = errors.New(
|
|
||||||
"storage URL must not carry credentials; " +
|
|
||||||
"set s3.access_key_id and s3.secret_access_key in the config instead")
|
|
||||||
ErrURLUnknownParam = errors.New("unknown query parameter in storage URL")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// URL represents a parsed storage URL.
|
// URL represents a parsed storage URL.
|
||||||
@@ -64,111 +59,61 @@ func ParseStorageURL(rawURL string) (*URL, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle s3:// URLs
|
||||||
if strings.HasPrefix(rawURL, "s3://") {
|
if strings.HasPrefix(rawURL, "s3://") {
|
||||||
return parseS3URL(rawURL)
|
u, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bucket := u.Host
|
||||||
|
if bucket == "" {
|
||||||
|
return nil, ErrMissingBucket
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := strings.TrimPrefix(u.Path, "/")
|
||||||
|
|
||||||
|
query := u.Query()
|
||||||
|
|
||||||
|
useSSL := true
|
||||||
|
if query.Get("ssl") == "false" {
|
||||||
|
useSSL = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return &URL{
|
||||||
|
Scheme: schemeS3,
|
||||||
|
Bucket: bucket,
|
||||||
|
Prefix: prefix,
|
||||||
|
Endpoint: query.Get("endpoint"),
|
||||||
|
Region: query.Get("region"),
|
||||||
|
UseSSL: useSSL,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle rclone:// URLs
|
||||||
if strings.HasPrefix(rawURL, "rclone://") {
|
if strings.HasPrefix(rawURL, "rclone://") {
|
||||||
return parseRcloneURL(rawURL)
|
u, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
remote := u.Host
|
||||||
|
if remote == "" {
|
||||||
|
return nil, ErrMissingRemote
|
||||||
|
}
|
||||||
|
|
||||||
|
path := strings.TrimPrefix(u.Path, "/")
|
||||||
|
|
||||||
|
return &URL{
|
||||||
|
Scheme: schemeRclone,
|
||||||
|
Prefix: path,
|
||||||
|
RcloneRemote: remote,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, ErrUnsupportedScheme
|
return nil, ErrUnsupportedScheme
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseS3URL parses an s3://bucket/prefix URL. It rejects credentials in
|
|
||||||
// the userinfo and any query parameter other than endpoint, region and
|
|
||||||
// ssl, so a credential-bearing URL is never stored or echoed.
|
|
||||||
func parseS3URL(rawURL string) (*URL, error) {
|
|
||||||
u, err := url.Parse(rawURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, wrapParseError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if u.User != nil {
|
|
||||||
return nil, ErrURLCredentials
|
|
||||||
}
|
|
||||||
|
|
||||||
bucket := u.Host
|
|
||||||
if bucket == "" {
|
|
||||||
return nil, ErrMissingBucket
|
|
||||||
}
|
|
||||||
|
|
||||||
query := u.Query()
|
|
||||||
|
|
||||||
err = rejectUnknownParams(query, "endpoint", "region", "ssl")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &URL{
|
|
||||||
Scheme: schemeS3,
|
|
||||||
Bucket: bucket,
|
|
||||||
Prefix: strings.TrimPrefix(u.Path, "/"),
|
|
||||||
Endpoint: query.Get("endpoint"),
|
|
||||||
Region: query.Get("region"),
|
|
||||||
UseSSL: query.Get("ssl") != "false",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseRcloneURL parses an rclone://remote/path URL. rclone:// takes no
|
|
||||||
// query parameters, so credentials in the userinfo and any parameter at
|
|
||||||
// all are rejected rather than silently ignored.
|
|
||||||
func parseRcloneURL(rawURL string) (*URL, error) {
|
|
||||||
u, err := url.Parse(rawURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, wrapParseError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if u.User != nil {
|
|
||||||
return nil, ErrURLCredentials
|
|
||||||
}
|
|
||||||
|
|
||||||
remote := u.Host
|
|
||||||
if remote == "" {
|
|
||||||
return nil, ErrMissingRemote
|
|
||||||
}
|
|
||||||
|
|
||||||
err = rejectUnknownParams(u.Query())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &URL{
|
|
||||||
Scheme: schemeRclone,
|
|
||||||
Prefix: strings.TrimPrefix(u.Path, "/"),
|
|
||||||
RcloneRemote: remote,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// rejectUnknownParams returns an error naming the first query parameter
|
|
||||||
// not in allowed. The parameter's name is included (so a misspelt
|
|
||||||
// endpoint= is caught), but never its value, which could be a secret,
|
|
||||||
// and never the whole URL.
|
|
||||||
func rejectUnknownParams(query url.Values, allowed ...string) error {
|
|
||||||
for name := range query {
|
|
||||||
if !slices.Contains(allowed, name) {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"%w: %q; put credentials in s3.access_key_id and "+
|
|
||||||
"s3.secret_access_key, not the URL",
|
|
||||||
ErrURLUnknownParam, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// wrapParseError wraps only the inner cause of a url.Parse failure. The
|
|
||||||
// *url.Error that url.Parse returns embeds the raw URL in its message, so
|
|
||||||
// wrapping it directly would echo a credential-bearing URL into logs.
|
|
||||||
func wrapParseError(err error) error {
|
|
||||||
var uerr *url.Error
|
|
||||||
if errors.As(err, &uerr) {
|
|
||||||
return fmt.Errorf("invalid URL: %w", uerr.Err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Errorf("invalid URL: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// String returns a human-readable representation of the storage URL.
|
// String returns a human-readable representation of the storage URL.
|
||||||
func (u *URL) String() string {
|
func (u *URL) String() string {
|
||||||
switch u.Scheme {
|
switch u.Scheme {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user