1 Commits
Author SHA1 Message Date
sneak 289a9aea90 Correct the security claims in docs and comments, and record the accepted risks (closes #171)
check / check (pull_request) Successful in 2m51s
Docs and comments only; no behaviour change. Corrects ten overclaims the
security review found: snapshot names are hashed but the hash uses no
secret, so a guessed hostname and name can be confirmed; a blob is named
by hex(SHA256(SHA256(uncompressed contents))), stated once in
docs/REPOSTRUCTURE.md and referenced elsewhere; double hashing does not
hide known content (blob packing does); age uses ChaCha20-Poly1305, not
XChaCha20; encryption is required, not optional; a snapshot is marked
complete before its metadata is uploaded; the export comment now matches
its only caller; deep verify detects corruption, not authorship; adding a
recipient does not reach existing data; restore examples target a
user-owned directory.

Adds an Accepted Risks subsection under Security Considerations with the
seven documented risks, cross-referenced from the README.

Model: opus-4-8
2026-09-22 16:33:27 +00:00
22 changed files with 93 additions and 766 deletions
+2 -70
View File
@@ -10,20 +10,14 @@ run:
linters: linters:
default: all default: all
enable:
# Successor to the deprecated gomodguard. Named explicitly, rather than
# left to `default: all`, because it carries the module policy below.
- gomodguard_v2
disable: disable:
# Genuinely incompatible with project patterns # Genuinely incompatible with project patterns
- exhaustruct # Requires all struct fields - exhaustruct # Requires all struct fields
- depguard # Dependency allow/block lists
- godot # Requires comments to end with periods - godot # Requires comments to end with periods
- wsl # Deprecated, replaced by wsl_v5
- wrapcheck # Too verbose for internal packages - wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go - varnamelen # Short names like db, id are idiomatic Go
# Deprecated: the warning is attached to the old name, so it is
# silenced by disabling that name, not by enabling the successor.
- wsl # Deprecated, replaced by wsl_v5
- gomodguard # Deprecated, replaced by gomodguard_v2
settings: settings:
lll: lll:
line-length: 88 line-length: 88
@@ -34,68 +28,6 @@ linters:
max-complexity: 15 max-complexity: 15
dupl: dupl:
threshold: 100 threshold: 100
depguard:
# Test-support code must not be compiled into the shipped binary. A
# test-support package exists to hand a test privileges the program
# itself must never have, so a file that is not a test must not import
# one. Test files, and the files inside a package whose directory name
# ends in `test`, are where that code belongs, and are exempt.
#
# The deny list below is the one part of this file a repository is
# expected to extend, and the only part it may. depguard matches an
# import path against a list of prefixes, so it cannot be told "any path
# whose last segment ends in test"; a repository's own test-support
# packages have to be named here one at a time, by full import path,
# under a module path that differs from repository to repository. Add
# them; change nothing else.
rules:
test-support:
list-mode: lax
files:
- "$all"
- "!$test"
- "!**/*test/**"
deny:
- pkg: net/http/httptest
desc: >-
Test-support code belongs in test files and in packages whose
directory name ends in test, not in the shipped binary.
- pkg: sneak.berlin/go/vaultik/internal/storage/faultstore
desc: >-
Test-support code belongs in test files and in packages whose
directory name ends in test, not in the shipped binary.
# Only decisions already recorded in the Go package defaults are
# listed here. Every entry matches the module path exactly.
gomodguard_v2:
blocked:
- module: github.com/rs/zerolog
recommendations:
- log/slog
reason: "Structured logging is stdlib log/slog."
# One entry per pre-fork module path, because the later releases
# are separate paths. A prefix match would be shorter but would
# also reach github.com/go-redis/redismock, the test double for
# the successor these entries recommend.
- module: github.com/go-redis/redis
recommendations:
- github.com/redis/go-redis/v9
reason: "Pre-fork module; use the maintained go-redis v9."
- module: github.com/go-redis/redis/v7
recommendations:
- github.com/redis/go-redis/v9
reason: "Pre-fork module; use the maintained go-redis v9."
- module: github.com/go-redis/redis/v8
recommendations:
- github.com/redis/go-redis/v9
reason: "Pre-fork module; use the maintained go-redis v9."
- module: github.com/sergi/go-diff
recommendations:
- github.com/aymanbagabas/go-udiff
reason: "No unified diff output; use go-udiff."
- module: github.com/hexops/gotextdiff
recommendations:
- github.com/aymanbagabas/go-udiff
reason: "Unmaintained fork; use go-udiff."
issues: issues:
max-issues-per-linter: 0 max-issues-per-linter: 0
+10 -14
View File
@@ -284,10 +284,8 @@ 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
- `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.
@@ -337,18 +335,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
+11 -20
View File
@@ -645,26 +645,17 @@ priority.
## output style ## output style
Every command's user-facing output is governed by `internal/ui`, in one The operational narration of the long-running commands — the Begin,
of two ways. Color is enabled when stdout is a TTY and the `NO_COLOR` Complete, Progress, and status lines of `snapshot create`, `prune`,
environment variable is unset (https://no-color.org/). `snapshot restore`, and the like — goes through helpers in `internal/ui`
and conforms to the uniform style below. Some commands instead write
* **Status, progress, warnings, and errors** go through the `internal/ui` plain text straight to stdout (`version`, `info`, `config`, the
message methods below: marker-prefixed, colored on a TTY, and — except `database delete` prompt, and the `snapshot list` table); that output is
warnings and errors — silenced by `--quiet`. This is the operational unstyled and does not honor `--quiet`. Routing it through `internal/ui`
narration of the long-running commands (`snapshot create`, `prune`, is tracked in
`snapshot restore`, and the like) and the confirmations of [issue #149](https://git.eeqj.de/sneak/vaultik/issues/149). Color is
`config init`, `config set`, and `database delete`. enabled when stdout is a TTY and the `NO_COLOR` environment variable is
* **The data a command exists to produce** is written plain, with no unset (https://no-color.org/).
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. `internal/ui` writes to stdout; it is the output the user asked for.
Structured log records are a different thing and go through Structured log records are a different thing and go through
-15
View File
@@ -25,21 +25,6 @@ release" is exactly the contradiction
# 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 - 2026-09-22: Validated blob hashes, offsets and lengths read back from
the destination before using them the destination before using them
([issue #155](https://git.eeqj.de/sneak/vaultik/issues/155)). A blob ([issue #155](https://git.eeqj.de/sneak/vaultik/issues/155)). A blob
+1 -1
View File
@@ -153,7 +153,7 @@ These are known, deliberate properties of the format and the tooling, recorded s
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. **A snapshot is marked complete in the local DB before its metadata is uploaded, not after** - `CompleteSnapshot` runs first, then `ExportSnapshotMetadata` (see the backup data flow in [ARCHITECTURE.md](../ARCHITECTURE.md)). A crash between the two leaves a completed-looking row in the local index with no matching metadata on the destination store. `vaultik prune` reconciles this away: it drops any local snapshot whose remote metadata is missing.
## Pruning Safety ## Pruning Safety
+17 -25
View File
@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"errors" "errors"
"fmt" "fmt"
"io"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -12,7 +13,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;
@@ -265,7 +265,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 +285,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 +328,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 +344,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 +355,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,23 +377,23 @@ 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]) return writeConfigSet(os.Stdout, path, args[0], args[1])
}, },
} }
} }
// writeConfigSet applies key=value to the config at path, writes it back // 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 // owner-only, and confirms the write by printing just the key name to w.
// UI writer (styled, and silenced by --quiet). The value is never // The value is never echoed: it may be a secret such as
// echoed: it may be a secret such as s3.secret_access_key, and captured // s3.secret_access_key, and captured stdout or a pasted terminal would
// stdout or a pasted terminal would then leak it. // then leak it.
func writeConfigSet(out *ui.Writer, path, key, value string) error { func writeConfigSet(w io.Writer, path, key, value string) error {
root, err := loadYAMLFile(path) root, err := loadYAMLFile(path)
if err != nil { if err != nil {
return err return err
@@ -412,12 +404,12 @@ func writeConfigSet(out *ui.Writer, path, key, value string) error {
return err return err
} }
data, err := marshalConfigYAML(root) out, err := marshalConfigYAML(root)
if err != nil { if err != nil {
return fmt.Errorf("marshaling config: %w", err) return fmt.Errorf("marshaling config: %w", err)
} }
err = os.WriteFile(path, data, configFileMode) err = os.WriteFile(path, out, configFileMode)
if err != nil { if err != nil {
return fmt.Errorf("writing config file: %w", err) return fmt.Errorf("writing config file: %w", err)
} }
@@ -433,7 +425,7 @@ func writeConfigSet(out *ui.Writer, path, key, value string) error {
} }
} }
out.Infof("Set %s.", key) _, _ = fmt.Fprintln(w, key)
return nil return nil
} }
+8 -11
View File
@@ -9,7 +9,6 @@ import (
"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
@@ -247,20 +246,19 @@ func TestWriteConfigSetHidesSecret(t *testing.T) {
t.Fatalf("seed config: %v", err) t.Fatalf("seed config: %v", err)
} }
var buf bytes.Buffer var out bytes.Buffer
err = writeConfigSet(ui.NewWithColor(&buf, false), path, err = writeConfigSet(&out, path, "s3.secret_access_key", secret)
"s3.secret_access_key", secret)
if err != nil { if err != nil {
t.Fatalf("writeConfigSet: %v", err) t.Fatalf("writeConfigSet: %v", err)
} }
if strings.Contains(buf.String(), secret) { if strings.Contains(out.String(), secret) {
t.Errorf("output echoed the secret value: %q", buf.String()) t.Errorf("output echoed the secret value: %q", out.String())
} }
if !strings.Contains(buf.String(), "s3.secret_access_key") { if !strings.Contains(out.String(), "s3.secret_access_key") {
t.Errorf("output did not confirm the key name: %q", buf.String()) t.Errorf("output did not confirm the key name: %q", out.String())
} }
} }
@@ -279,10 +277,9 @@ func TestWriteConfigSetTightensMode(t *testing.T) {
t.Fatalf("seed config: %v", err) t.Fatalf("seed config: %v", err)
} }
var buf bytes.Buffer var out bytes.Buffer
err = writeConfigSet(ui.NewWithColor(&buf, false), path, err = writeConfigSet(&out, path, "compression_level", "9")
"compression_level", "9")
if err != nil { if err != nil {
t.Fatalf("writeConfigSet: %v", err) t.Fatalf("writeConfigSet: %v", err)
} }
+11 -12
View File
@@ -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
-206
View File
@@ -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)
}
}
-15
View File
@@ -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.
@@ -82,20 +81,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 -2
View File
@@ -192,8 +192,7 @@ func newSnapshotVerifyCommand() *cobra.Command {
Long: "Checks that every blob the snapshot's manifest lists is present\n" + Long: "Checks that every blob the snapshot's manifest lists is present\n" +
"in storage with the size the manifest records, and that the\n" + "in storage with the size the manifest records, and that the\n" +
"snapshot's encrypted database is present. It does not read blob\n" + "snapshot's encrypted database is present. It does not read blob\n" +
"contents; use --deep to download, decrypt, and re-hash every blob\n" + "contents; use --deep to download and cryptographically verify them.\n\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" + "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" + "index, by the remote key that 'snapshot list' prints for a\n" +
"remote-only snapshot (an unambiguous leading part is enough).", "remote-only snapshot (an unambiguous leading part is enough).",
+5 -14
View File
@@ -2,11 +2,11 @@ package cli
import ( import (
"fmt" "fmt"
"io"
"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
@@ -17,25 +17,16 @@ func NewVersionCommand() *cobra.Command {
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(cmd *cobra.Command, _ []string) {
writeVersion(commandUI(cmd)) writeVersion(cmd.OutOrStdout())
}, },
} }
return cmd return cmd
} }
// writeVersion prints the version report through the UI writer. The // writeVersion prints the version report. It takes a writer rather than
// report is the output this command exists to produce, so it is written // using os.Stdout directly so the output can be asserted on in tests.
// plain (markers would corrupt the aligned report) via the writer's func writeVersion(w io.Writer) {
// 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, "vaultik %s\n", globals.Version)
_, _ = fmt.Fprintf(w, " commit: %s\n", globals.Commit) _, _ = fmt.Fprintf(w, " commit: %s\n", globals.Commit)
_, _ = fmt.Fprintf(w, " build date: %s\n", globals.CommitDate) _, _ = fmt.Fprintf(w, " build date: %s\n", globals.CommitDate)
+4 -4
View File
@@ -34,9 +34,9 @@ func runVersionCommand(t *testing.T) string {
// the report is the version the binary was actually built with. The // the report is the version the binary was actually built with. The
// test binary carries no -ldflags, so that is the "dev" default -- the // test binary carries no -ldflags, so that is the "dev" default -- the
// same string an untagged `make vaultik` build stamps a prefix of. // 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) { func TestVersionCommandReportsBuildVersion(t *testing.T) {
t.Parallel()
out := runVersionCommand(t) out := runVersionCommand(t)
wantFirst := "vaultik " + globals.Version wantFirst := "vaultik " + globals.Version
@@ -55,9 +55,9 @@ func TestVersionCommandReportsBuildVersion(t *testing.T) {
// being exactly "dev", so once untagged builds started carrying their // being exactly "dev", so once untagged builds started carrying their
// commit sha it would have gone silent and an unreleased binary would // commit sha it would have gone silent and an unreleased binary would
// have looked like a release. // have looked like a release.
//
//nolint:paralleltest // executes a command that reads the global rootFlags
func TestVersionCommandFlagsDevelopmentBuild(t *testing.T) { func TestVersionCommandFlagsDevelopmentBuild(t *testing.T) {
t.Parallel()
if !globals.IsDevVersion(globals.Version) { if !globals.IsDevVersion(globals.Version) {
t.Skipf("test binary was stamped with release version %q", t.Skipf("test binary was stamped with release version %q",
globals.Version) globals.Version)
+2 -4
View File
@@ -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
+5 -43
View File
@@ -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:
+3 -185
View File
@@ -14,7 +14,6 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/config" "sneak.berlin/go/vaultik/internal/config"
"sneak.berlin/go/vaultik/internal/database" "sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/globals"
"sneak.berlin/go/vaultik/internal/log" "sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/snapshot" "sneak.berlin/go/vaultik/internal/snapshot"
"sneak.berlin/go/vaultik/internal/storage" "sneak.berlin/go/vaultik/internal/storage"
@@ -418,10 +417,9 @@ func TestBackupRetryAfterInterruptedUploadIsRestorable(t *testing.T) {
// database is uploaded but before the manifest. The destination is left // database is uploaded but before the manifest. The destination is left
// with blobs and a database but no manifest. verify and snapshot list // with blobs and a database but no manifest. verify and snapshot list
// must report the damage honestly rather than crashing or passing. // must report the damage honestly rather than crashing or passing.
// Automatic detection and repair of this partial state on the next run is // Automatic detection and repair of this partial state on the next run
// covered by TestBackupCompletesOnlyAfterMetadataExport // is tracked in https://git.eeqj.de/sneak/vaultik/issues/177 and is not
// (https://git.eeqj.de/sneak/vaultik/issues/177); this test exercises the // asserted here.
// lower-level export path in isolation.
// //
//nolint:paralleltest // installs the global logger via log.Initialize //nolint:paralleltest // installs the global logger via log.Initialize
func TestBackupSurvivesMetadataExportInterruption(t *testing.T) { func TestBackupSurvivesMetadataExportInterruption(t *testing.T) {
@@ -498,186 +496,6 @@ func TestBackupSurvivesMetadataExportInterruption(t *testing.T) {
"snapshot list must tolerate a partially-exported snapshot") "snapshot list must tolerate a partially-exported snapshot")
} }
// Scenario 2, repair: the process dies during the metadata export of a
// full backup run. Because completion is recorded only after the export
// succeeds (finalizeSnapshotMetadata), the interrupted snapshot is left
// incomplete rather than silently marked complete without metadata at the
// destination. Rerunning the backup must then prune the incomplete
// snapshot, produce a snapshot whose destination metadata and local index
// agree, and restore. See https://git.eeqj.de/sneak/vaultik/issues/177.
//
//nolint:paralleltest // installs the global logger via log.Initialize
func TestBackupCompletesOnlyAfterMetadataExport(t *testing.T) {
log.Initialize(log.Config{})
fs := afero.NewOsFs()
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "src")
storeDir := filepath.Join(tempDir, "remote")
restoreDir := filepath.Join(tempDir, "restored")
dbPath := filepath.Join(tempDir, "index.sqlite")
ctx := context.Background()
testFiles := writeFaultSourceTree(t, fs, dataDir)
// A full-backup config: the fault-test defaults plus the fields the
// production create path reads (index location, chunk size, and the
// named snapshot to back up).
cfg := faultTestConfig()
cfg.IndexPath = dbPath
cfg.ChunkSize = config.Size(faultChunkSize)
cfg.Snapshots = map[string]config.SnapshotConfig{
"data": {Paths: []string{dataDir}},
}
inner, err := storage.NewFileStorer(storeDir)
require.NoError(t, err)
db, err := database.New(ctx, dbPath)
require.NoError(t, err)
repos := database.NewRepositories(db)
// failManifest is on for the first backup and off for the retry, so the
// manifest upload fails once — interrupting the export mid-way — then
// succeeds.
failManifest := true
store := faultstore.New(inner)
store.OnPut = func(key string) faultstore.PutAction {
if failManifest && strings.HasSuffix(key, "manifest.json.zst") {
return faultstore.PutFail
}
return faultstore.PutNormal
}
v := newBackupVaultik(ctx, cfg, store, repos, db, fs)
opts := &vaultik.SnapshotCreateOptions{Cron: true}
// First run: the export fails at the manifest upload, so the whole
// create fails and the snapshot is left incomplete.
require.Error(t, v.CreateSnapshot(opts),
"backup must fail when the metadata export is interrupted")
incompletes, err := repos.Snapshots.GetIncompleteSnapshots(ctx)
require.NoError(t, err)
require.Len(t, incompletes, 1,
"an interrupted export must leave exactly one incomplete snapshot")
afterFirst, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
require.NoError(t, err)
for _, s := range afterFirst {
require.Nil(t, s.CompletedAt,
"no snapshot may be marked complete before its metadata is exported")
}
// Second run on the same index and destination: the retry succeeds.
failManifest = false
require.NoError(t, v.CreateSnapshot(opts),
"a retry after an interrupted export must succeed")
assertRetryConsistentAndRestorable(
ctx, t, cfg, inner, repos, db, fs, restoreDir, testFiles)
}
// assertRetryConsistentAndRestorable checks the end state after the retry
// backup in TestBackupCompletesOnlyAfterMetadataExport: the interrupted
// snapshot is pruned, exactly one completed snapshot remains, its metadata
// is at the destination, and it restores to the original tree.
func assertRetryConsistentAndRestorable(
ctx context.Context, t *testing.T, cfg *config.Config,
inner storage.Storer, repos *database.Repositories, db *database.DB,
fs afero.Fs, restoreDir string, testFiles map[string][]byte,
) {
t.Helper()
incompletes, err := repos.Snapshots.GetIncompleteSnapshots(ctx)
require.NoError(t, err)
assert.Empty(t, incompletes,
"the next run's prune must drop the interrupted snapshot")
local, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
require.NoError(t, err)
require.Len(t, local, 1, "exactly one snapshot must remain after the retry")
final := local[0]
require.NotNil(t, final.CompletedAt, "the retry's snapshot must be complete")
// The destination and the local index agree: the completed snapshot has
// both its metadata objects at the destination.
key := snapshot.RemoteSnapshotKey(final.ID.String())
_, err = inner.Stat(ctx, "metadata/"+key+"/manifest.json.zst")
require.NoError(t, err, "the completed snapshot's manifest must be at the destination")
_, err = inner.Stat(ctx, "metadata/"+key+"/db.zst.age")
require.NoError(t, err, "the completed snapshot's database must be at the destination")
require.NoError(t, db.Close())
// The snapshot restores from the destination alone.
reader := newReaderVaultik(ctx, cfg, inner, nil, fs)
require.NoError(t, reader.Restore(&vaultik.RestoreOptions{
SnapshotID: final.ID.String(),
TargetDir: restoreDir,
Verify: true,
}), "the retry's snapshot must be restorable")
assertRestoredTree(t, fs, restoreDir, testFiles)
}
// listRecentTestLimit is a generous cap for the handful of snapshots these
// tests create when reading the local index directly.
const listRecentTestLimit = 100
// newBackupVaultik builds a Vaultik that runs the full create path
// (CreateSnapshot) writing through storer, wiring the same scanner factory
// and snapshot manager the production dependency graph provides.
func newBackupVaultik(
ctx context.Context, cfg *config.Config, storer storage.Storer,
repos *database.Repositories, db *database.DB, fs afero.Fs,
) *vaultik.Vaultik {
v := &vaultik.Vaultik{
Globals: &globals.Globals{Version: "v", Commit: "g"},
Config: cfg,
DB: db,
Repositories: repos,
Storage: storer,
SnapshotManager: newFaultSnapshotManager(fs, storer, cfg, repos),
ScannerFactory: faultScannerFactory(cfg, repos, storer),
Fs: fs,
Stdout: io.Discard,
Stderr: io.Discard,
UI: ui.NewWithColor(io.Discard, false),
}
v.SetContext(ctx)
return v
}
// faultScannerFactory mirrors the production provideScannerFactory, binding
// the scanner to the given store, repositories, and config so a full
// create-path backup writes through the fault-injecting store.
func faultScannerFactory(
cfg *config.Config, repos *database.Repositories, storer storage.Storer,
) snapshot.ScannerFactory {
return func(params snapshot.ScannerParams) *snapshot.Scanner {
return snapshot.NewScanner(snapshot.ScannerConfig{
FS: params.Fs,
Storage: storer,
ChunkSize: faultChunkSize,
MaxBlobSize: faultMaxBlobSize,
CompressionLevel: cfg.CompressionLevel,
AgeRecipients: cfg.AgeRecipients,
Repositories: repos,
EnableProgress: params.EnableProgress,
UI: params.UI,
Exclude: params.Exclude,
SkipErrors: params.SkipErrors,
})
}
}
// Scenario 5: the restore target runs out of space mid-file. Restore // Scenario 5: the restore target runs out of space mid-file. Restore
// must fail with an out-of-space error, and must not leave a truncated // must fail with an out-of-space error, and must not leave a truncated
// file at the target path presenting as a complete restore. Restore // file at the target path presenting as a complete restore. Restore
+7 -24
View File
@@ -13,14 +13,6 @@ import (
// ShowInfo displays system and configuration information // ShowInfo displays system and configuration information
func (v *Vaultik) ShowInfo() error { func (v *Vaultik) ShowInfo() error {
// The info report is the output this command exists to produce, so it
// is written plain (markers would corrupt the aligned report) through
// the UI writer's stdout; --quiet silences it like any other
// non-error output.
if v.UI.Quiet() {
return nil
}
// System Information // System Information
v.stdoutf("=== System Information ===\n") v.stdoutf("=== System Information ===\n")
v.stdoutf("OS/Architecture: %s/%s\n", runtime.GOOS, runtime.GOARCH) v.stdoutf("OS/Architecture: %s/%s\n", runtime.GOOS, runtime.GOARCH)
@@ -221,12 +213,7 @@ func (v *Vaultik) RemoteInfo(jsonOutput bool) error {
result.StorageType = storageInfo.Type result.StorageType = storageInfo.Type
result.StorageLocation = storageInfo.Location result.StorageLocation = storageInfo.Location
// The human report is written only when it is neither the --json if !jsonOutput {
// document (which needs stdout to itself) nor silenced by --quiet. The
// scan still runs in both cases so --json still gets a full result.
showText := !jsonOutput && !v.UI.Quiet()
if showText {
v.stdoutf("=== Remote Storage ===\n") v.stdoutf("=== Remote Storage ===\n")
v.stdoutf("Type: %s\n", storageInfo.Type) v.stdoutf("Type: %s\n", storageInfo.Type)
v.stdoutf("Location: %s\n", storageInfo.Location) v.stdoutf("Location: %s\n", storageInfo.Location)
@@ -239,7 +226,7 @@ func (v *Vaultik) RemoteInfo(jsonOutput bool) error {
return err return err
} }
if showText { if !jsonOutput {
v.stdoutf("Downloading %d manifest(s)...\n", len(snapshotIDs)) v.stdoutf("Downloading %d manifest(s)...\n", len(snapshotIDs))
} }
@@ -247,7 +234,7 @@ func (v *Vaultik) RemoteInfo(jsonOutput bool) error {
v.populateRemoteInfoResult(result, snapshotMetadata, snapshotIDs, referencedBlobs) v.populateRemoteInfoResult(result, snapshotMetadata, snapshotIDs, referencedBlobs)
err = v.scanRemoteBlobStorage(result, referencedBlobs, showText) err = v.scanRemoteBlobStorage(result, referencedBlobs, jsonOutput)
if err != nil { if err != nil {
return err return err
} }
@@ -265,9 +252,7 @@ func (v *Vaultik) RemoteInfo(jsonOutput bool) error {
return enc.Encode(result) return enc.Encode(result)
} }
if showText { v.printRemoteInfoTable(result)
v.printRemoteInfoTable(result)
}
return nil return nil
} }
@@ -377,13 +362,11 @@ func (v *Vaultik) populateRemoteInfoResult(
} }
} }
// scanRemoteBlobStorage lists all blobs on remote and computes orphan // scanRemoteBlobStorage lists all blobs on remote and computes orphan stats
// stats. showText is true only when the human report is being printed
// (not --json, not --quiet), gating the progress line.
func (v *Vaultik) scanRemoteBlobStorage( func (v *Vaultik) scanRemoteBlobStorage(
result *RemoteInfoResult, referencedBlobs map[string]int64, showText bool, result *RemoteInfoResult, referencedBlobs map[string]int64, jsonOutput bool,
) error { ) error {
if showText { if !jsonOutput {
v.stdoutf("Scanning blobs...\n") v.stdoutf("Scanning blobs...\n")
} }
-30
View File
@@ -1,30 +0,0 @@
package vaultik_test
import (
"bytes"
"testing"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/ui"
"sneak.berlin/go/vaultik/internal/vaultik"
)
// TestShowInfo_QuietSuppressesReport checks that --quiet silences the
// whole info report. The report is human-facing status, not a scriptable
// value, so under --quiet the command produces nothing (and touches none
// of its dependencies, which is why this minimal instance suffices).
func TestShowInfo_QuietSuppressesReport(t *testing.T) {
t.Parallel()
var out bytes.Buffer
v := &vaultik.Vaultik{
Stdout: &out,
UI: ui.NewWithColor(&out, false),
}
v.UI.SetQuiet(true)
require.NoError(t, v.ShowInfo())
require.Empty(t, out.String(),
"the info report must be suppressed under --quiet")
}
+3 -20
View File
@@ -69,9 +69,6 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
// Prune the database before starting: delete incomplete snapshots and orphaned data. // Prune the database before starting: delete incomplete snapshots and orphaned data.
// This ensures the database is consistent before we start a new snapshot. // This ensures the database is consistent before we start a new snapshot.
// Since we use locking, only one vaultik instance accesses the DB at a time. // Since we use locking, only one vaultik instance accesses the DB at a time.
// A snapshot whose metadata export was interrupted is left incomplete by
// finalizeSnapshotMetadata, so it is among the incomplete snapshots dropped
// here (https://git.eeqj.de/sneak/vaultik/issues/177).
_, err = v.PruneDatabase() _, err = v.PruneDatabase()
if err != nil { if err != nil {
return fmt.Errorf("prune database: %w", err) return fmt.Errorf("prune database: %w", err)
@@ -327,12 +324,7 @@ func (v *Vaultik) collectUploadStats(scanner *snapshot.Scanner, stats *snapshotS
} }
} }
// finalizeSnapshotMetadata updates stats, exports metadata, and only then // finalizeSnapshotMetadata updates stats, marks complete, and exports metadata
// marks the snapshot complete. Recording completion last is deliberate: an
// export interrupted by a crash leaves the snapshot incomplete rather than
// looking complete with no manifest or database at the destination. The next
// run's PruneDatabase drops the incomplete snapshot and re-backs-up its data.
// See https://git.eeqj.de/sneak/vaultik/issues/177.
func (v *Vaultik) finalizeSnapshotMetadata( func (v *Vaultik) finalizeSnapshotMetadata(
snapshotID string, stats *snapshotStats, snapshotID string, stats *snapshotStats,
) error { ) error {
@@ -354,11 +346,9 @@ func (v *Vaultik) finalizeSnapshotMetadata(
return fmt.Errorf("updating snapshot stats: %w", err) return fmt.Errorf("updating snapshot stats: %w", err)
} }
// snapshot_blobs must be populated before the export, which builds the err = v.SnapshotManager.CompleteSnapshot(v.ctx, snapshotID)
// manifest and the trimmed metadata database from it.
err = v.SnapshotManager.PopulateSnapshotBlobs(v.ctx, snapshotID)
if err != nil { if err != nil {
return fmt.Errorf("populating snapshot blobs: %w", err) return fmt.Errorf("completing snapshot: %w", err)
} }
err = v.SnapshotManager.ExportSnapshotMetadata( err = v.SnapshotManager.ExportSnapshotMetadata(
@@ -367,13 +357,6 @@ func (v *Vaultik) finalizeSnapshotMetadata(
return fmt.Errorf("exporting snapshot metadata: %w", err) return fmt.Errorf("exporting snapshot metadata: %w", err)
} }
// Record completion last, so an interrupted export never leaves a
// snapshot marked complete without its metadata at the destination.
err = v.SnapshotManager.MarkSnapshotComplete(v.ctx, snapshotID)
if err != nil {
return fmt.Errorf("marking snapshot complete: %w", err)
}
return nil return nil
} }
+3 -9
View File
@@ -114,15 +114,9 @@ func (v *Vaultik) ListSnapshots(jsonOutput bool) error {
return encoder.Encode(snapshots) return encoder.Encode(snapshots)
} }
// The table is the output this command exists to produce, so it is err = v.printSnapshotTable(snapshots)
// written plain (markers would corrupt the columns) to the UI writer's if err != nil {
// stdout; --quiet silences it. Reconciliation notes below go through return err
// the UI methods, so their warnings still emit under --quiet.
if !v.UI.Quiet() {
err = v.printSnapshotTable(snapshots)
if err != nil {
return err
}
} }
if remoteErr == nil { if remoteErr == nil {
@@ -1,41 +0,0 @@
package vaultik_test
import (
"testing"
"time"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/log"
)
// TestListSnapshots_QuietSuppressesTableNotJSON is the --quiet contract
// for `snapshot list`: the human table is silenced, but the --json
// document a script depends on still emits. A local snapshot with its
// remote counterpart present is used so there are no drift notes, whose
// warnings would emit even under --quiet.
func TestListSnapshots_QuietSuppressesTableNotJSON(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
env := newListEnv(t)
ts := time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC)
env.addLocal(t, listLocalID, ts)
env.addRemote(t, listLocalID, ts)
env.v.UI.SetQuiet(true)
// Table mode: nothing on stdout.
require.NoError(t, env.v.ListSnapshots(false))
require.Empty(t, env.stdout.String(),
"the table must be suppressed under --quiet")
// JSON mode: the document is still written despite the quiet UI.
env.stdout.Reset()
require.NoError(t, env.v.ListSnapshots(true))
rows := decodeListJSON(t, env.stdout.String())
require.Len(t, rows, 1)
require.Equal(t, listLocalID, rows[0].ID,
"the --json document must still emit under --quiet")
}
-1
View File
@@ -215,7 +215,6 @@ func NewForTesting(storage storage.Storer) *TestVaultik {
Stdout: stdout, Stdout: stdout,
Stderr: stderr, Stderr: stderr,
Stdin: stdin, Stdin: stdin,
UI: ui.NewWithColor(stdout, false),
}, },
Stdout: stdout, Stdout: stdout,
Stderr: stderr, Stderr: stderr,