Author SHA1 Message Date
sneak 353c2471ad Refuse credentials in a storage URL's endpoint or rclone remote name (closes #322)
check / check (push) Waiting to run
storage_url refused only the outer URL's userinfo and unknown query
parameters. Userinfo inside the s3 endpoint= value, and rclone's
remote,param=value parameters in the remote name, were accepted and
then printed by info, remote info and snapshot create. Both are now
refused without echoing the value: a host name never contains "@", and
an rclone remote name never contains a comma. An s3 query that cannot
be decoded (a ";" or a bad "%" escape) is refused too, because decoding
drops that part, endpoint included, before it can be checked. The comma
is checked before url.Parse, whose error for such a name prints part of
it. The rclone case has its own error pointing at rclone config.

Model: opus-5-5
2026-10-13 21:41:34 +00:00
14 changed files with 198 additions and 427 deletions
+2 -4
View File
@@ -712,10 +712,8 @@ the `NO_COLOR` environment variable is unset (https://no-color.org/).
reports and tables (`version`, `info`, `remote info`, `snapshot
verify`, `snapshot list`) but never the machine-consumed `config get`
value or the `--json` documents, which a script depends on. The
confirmation prompts of `database delete`, `prune`, `snapshot purge`
and `snapshot remove`, each with what it asks to delete, are also
written this way and always shown: they are an interactive exchange
the operator must see.
`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. The
exceptions are the startup banner and the error a failed command ends
+1 -2
View File
@@ -302,8 +302,7 @@ storage_url: "rclone://myremote/path/to/backups"
# Average chunk size for content-defined chunking
# Smaller chunks = better deduplication but more metadata
# Minimum 1MB
# Supports: 10MB, 5M, 1GB, 64MiB, etc.
# Supports: 10MB, 5M, 1GB, 500KB, 64MiB, etc.
# Default: 10MB
#chunk_size: 10MB
+1 -1
View File
@@ -224,7 +224,7 @@ storage_url: ""
# Average chunk size for content-defined chunking (FastCDC).
# Smaller = better deduplication but more metadata overhead.
# Minimum 1MB. Accepts: 1MB, 10M, 64MiB, etc.
# Accepts: 1MB, 10M, 64KB, etc.
# Default: 10MB
# chunk_size: 10MB
-123
View File
@@ -1,123 +0,0 @@
package cli //nolint:testpackage // shares runEntry and the hermetic config helpers
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// flagCron is used to build the argument vectors below.
const flagCron = "--cron"
// plantUnreferencedBlob writes a blob that no snapshot references to the
// destination store at storeDir, so that a prune deletes it. Returns its
// path.
func plantUnreferencedBlob(t *testing.T, storeDir string) string {
t.Helper()
hash := strings.Repeat("b", 64)
path := filepath.Join(storeDir, "blobs", hash[:2], hash[2:4], hash)
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o750))
require.NoError(t, os.WriteFile(path, []byte("unreferenced"), 0o600))
return path
}
// TestEntrySnapshotCreatePruneIsSilent checks that a successful
// `snapshot create --prune` under --cron or --quiet prints nothing on
// stdout or stderr, whether or not the prune finds a blob to delete.
//
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
func TestEntrySnapshotCreatePruneIsSilent(t *testing.T) {
for _, testCase := range []struct {
name string
args []string
plantBlob bool
}{
{
name: "cron, no blob to delete",
args: []string{cmdSnapshot, cmdCreate, flagCron, flagPrune},
},
{
name: "cron, blob to delete",
args: []string{cmdSnapshot, cmdCreate, flagCron, flagPrune},
plantBlob: true,
},
{
name: "cron with keep-newer-than",
args: []string{
cmdSnapshot, cmdCreate, flagCron, flagPrune,
flagKeepNewerThan, "4w",
},
plantBlob: true,
},
{
name: "quiet",
args: []string{flagQuiet, cmdSnapshot, cmdCreate, flagPrune},
plantBlob: true,
},
} {
t.Run(testCase.name, func(t *testing.T) {
configPath, storeDir := writeBackupConfig(t)
var blob string
if testCase.plantBlob {
blob = plantUnreferencedBlob(t, storeDir)
}
code, stdout, stderr := runEntry(t,
append([]string{flagConfig, configPath}, testCase.args...)...)
assert.Equal(t, 0, code)
assert.Empty(t, stdout)
assert.Empty(t, stderr)
if testCase.plantBlob {
assert.NoFileExists(t, blob, "the prune must delete the blob")
}
})
}
}
// TestEntryPruneQuietIsSilent checks that a successful `prune --force`
// under --quiet prints nothing on stdout or stderr, both when there is
// nothing to remove and when it removes a stale local snapshot record
// and an unreferenced blob.
//
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
func TestEntryPruneQuietIsSilent(t *testing.T) {
for _, testCase := range []struct {
name string
staleRecordAndBlob bool
}{
{name: "nothing to remove"},
{name: "stale record and blob to remove", staleRecordAndBlob: true},
} {
t.Run(testCase.name, func(t *testing.T) {
configPath := writeHermeticPruneConfig(t, testCase.staleRecordAndBlob)
var blob string
if testCase.staleRecordAndBlob {
// writeHermeticPruneConfig puts the store beside the config.
blob = plantUnreferencedBlob(t,
filepath.Join(filepath.Dir(configPath), "store"))
}
code, stdout, stderr := runEntry(t,
flagConfig, configPath, flagQuiet, cmdPrune, flagForce)
assert.Equal(t, 0, code)
assert.Empty(t, stdout)
assert.Empty(t, stderr)
if testCase.staleRecordAndBlob {
assert.NoFileExists(t, blob, "the prune must delete the blob")
}
})
}
}
+4 -6
View File
@@ -30,15 +30,13 @@ const appName = "vaultik"
//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. MB and GB are
// 1000-based, as the size parser reads them; the S3 part sizes are S3's own
// limits, which are 1024-based (MiB, GiB).
// Defaults and validation bounds for tunable settings.
const (
defaultBlobSizeLimit = Size(10 * 1000 * 1000 * 1000) // 10GB
defaultChunkSize = Size(10 * 1000 * 1000) // 10MB
defaultBlobSizeLimit = Size(10 * 1024 * 1024 * 1024) // 10GB
defaultChunkSize = Size(10 * 1024 * 1024) // 10MB
defaultS3PartSize = Size(5 * 1024 * 1024) // 5MiB
defaultCompressionLevel = 3
minChunkSize = 1000 * 1000 // 1MB
minChunkSize = 1024 * 1024 // 1MB
minCompressionLevel = 1
maxCompressionLevel = 19
// S3 accepts a multipart upload part from 5MiB to 5GiB.
-75
View File
@@ -337,81 +337,6 @@ func TestLoadS3PartSize(t *testing.T) {
})
}
// TestDocumentedSizeDefaults checks that the defaults are the documented
// chunk_size 10MB and blob_size_limit 10GB as the parser reads those values
// (1000-based), so writing a default out in the config file changes nothing.
func TestDocumentedSizeDefaults(t *testing.T) {
t.Parallel()
cfg, err := Parse([]byte("chunk_size: 10MB\nblob_size_limit: 10GB\n"))
if err != nil {
t.Fatalf("Parse() unexpected error: %v", err)
}
if cfg.ChunkSize != defaultChunkSize {
t.Errorf("chunk_size: 10MB is %d bytes, the default is %d",
cfg.ChunkSize, defaultChunkSize)
}
if cfg.BlobSizeLimit != defaultBlobSizeLimit {
t.Errorf("blob_size_limit: 10GB is %d bytes, the default is %d",
cfg.BlobSizeLimit, defaultBlobSizeLimit)
}
}
// TestDocumentedSizeExamples checks the 1MB minimum chunk_size and that
// every size the README, config.example.yml and the config init template
// list as an example is accepted.
func TestDocumentedSizeExamples(t *testing.T) {
t.Parallel()
const withoutSizes = "snapshots:\n" +
" test:\n" +
" paths: [/tmp/vaultik-test-source]\n" +
"storage_url: file:///tmp/vaultik-test-storage\n"
tests := []struct {
setting string
wantErr error
}{
{"chunk_size: 1MB", nil},
{"chunk_size: 999999", errChunkSizeTooSmall},
{"chunk_size: 5M", nil},
{"chunk_size: 10M", nil},
{"chunk_size: 10MB", nil},
{"chunk_size: 64MiB", nil},
{"chunk_size: 1GB", nil},
// Four times the default chunk_size, the documented minimum.
{"blob_size_limit: 40MB", nil},
{"blob_size_limit: 500MB", nil},
{"blob_size_limit: 1GB", nil},
{"blob_size_limit: 1GiB", nil},
{"blob_size_limit: 10G", nil},
{"blob_size_limit: 10GB", nil},
{"s3: {part_size: 5MiB}", nil},
{"s3: {part_size: 10MB}", nil},
{"s3: {part_size: 16MiB}", nil},
{"s3: {part_size: 100MiB}", nil},
{"s3: {part_size: 5GiB}", nil},
}
for _, tt := range tests {
t.Run(tt.setting, func(t *testing.T) {
t.Parallel()
cfg, err := Parse([]byte(withoutSizes + tt.setting + "\n"))
if err != nil {
t.Fatalf("Parse() unexpected error: %v", err)
}
err = cfg.Validate()
if !errors.Is(err, tt.wantErr) {
t.Fatalf("Validate() error = %v, want %v", err, tt.wantErr)
}
})
}
}
// TestLoadRejectsRelativeFileURL checks that a file:// storage_url with a
// relative path fails at load. Accepted, it would resolve against the
// working directory, so a backup run from another directory would write
+40 -6
View File
@@ -30,6 +30,12 @@ var (
"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")
ErrURLBadQuery = errors.New(
"storage URL query cannot be decoded; " +
"check it for a ; or a % not followed by two hex digits")
ErrRcloneRemoteParams = errors.New(
"rclone URL must not carry parameters in the remote name; " +
"set them on the remote with rclone config instead")
)
// URL represents a parsed storage URL.
@@ -90,8 +96,9 @@ func parseFileURL(rawPath string) (*URL, error) {
}
// 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.
// the userinfo or in the endpoint parameter's userinfo, a query that
// cannot be decoded, 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 {
@@ -107,13 +114,26 @@ func parseS3URL(rawURL string) (*URL, error) {
return nil, ErrMissingBucket
}
query := u.Query()
// u.Query() would silently drop a part it cannot decode, endpoint
// included. The decode error is not wrapped: it can quote the value.
query, err := url.ParseQuery(u.RawQuery)
if err != nil {
return nil, ErrURLBadQuery
}
err = rejectUnknownParams(query, "endpoint", "region", "ssl")
if err != nil {
return nil, err
}
// A host name never contains "@", so one in an endpoint, with or
// without a scheme, can only introduce userinfo.
for _, endpoint := range query["endpoint"] {
if strings.Contains(endpoint, "@") {
return nil, ErrURLCredentials
}
}
return &URL{
Scheme: schemeS3,
Bucket: bucket,
@@ -125,9 +145,23 @@ func parseS3URL(rawURL string) (*URL, error) {
}
// 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.
// query parameters, so credentials in the userinfo, parameters in the
// remote name and any query parameter at all are rejected rather than
// silently ignored.
func parseRcloneURL(rawURL string) (*URL, error) {
// The remote name runs up to the first "/", "?" or "#". It never
// contains a comma; one starts rclone's remote,param=value form,
// whose values can be credentials. This is checked before url.Parse,
// whose error for such a name can print part of it.
remote := strings.TrimPrefix(rawURL, "rclone://")
if end := strings.IndexAny(remote, "/?#"); end >= 0 {
remote = remote[:end]
}
if strings.Contains(remote, ",") {
return nil, ErrRcloneRemoteParams
}
u, err := url.Parse(rawURL)
if err != nil {
return nil, wrapParseError(err)
@@ -137,7 +171,7 @@ func parseRcloneURL(rawURL string) (*URL, error) {
return nil, ErrURLCredentials
}
remote := u.Host
remote = u.Host
if remote == "" {
return nil, ErrMissingRemote
}
+111
View File
@@ -150,6 +150,19 @@ func TestParseStorageURLRejectsCredentials(t *testing.T) {
wantErr: storage.ErrURLCredentials,
secrets: []string{key, secret, "mybucket"},
},
{
name: "s3 userinfo in endpoint",
raw: "s3://mybucket?endpoint=https://" +
key + ":" + secret + "@minio.example.com",
wantErr: storage.ErrURLCredentials,
secrets: []string{key, secret},
},
{
name: "s3 userinfo in endpoint without scheme",
raw: "s3://mybucket?endpoint=" + key + ":" + secret + "@minio.example.com",
wantErr: storage.ErrURLCredentials,
secrets: []string{key, secret},
},
{
name: "s3 unknown query param",
raw: "s3://mybucket?access_key=" + key + "&secret=" + secret,
@@ -203,6 +216,104 @@ func TestParseStorageURLRejectsCredentials(t *testing.T) {
}
}
// TestParseStorageURLRejectsRcloneRemoteParams checks that an rclone
// remote name carrying parameters (rclone's "remote,param=value" form,
// which can hold a credential) is rejected without echoing the value,
// including the URLs that url.Parse itself refuses.
func TestParseStorageURLRejectsRcloneRemoteParams(t *testing.T) {
t.Parallel()
cases := []struct {
name string
raw string
secrets []string // must not appear in the error message
}{
{
name: "configured remote",
raw: "rclone://gdrive,client_secret=topsecret/backups",
secrets: []string{"topsecret"},
},
{
name: "remote defined inline",
raw: "rclone://:s3,access_key_id=KEY,secret_access_key=SECRET/bucket",
secrets: []string{"KEY", "SECRET"},
},
{
name: "colon in a parameter value",
raw: "rclone://gdrive,client_secret=ab:cd/backups",
secrets: []string{"cd"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, err := storage.ParseStorageURL(tc.raw)
if !errors.Is(err, storage.ErrRcloneRemoteParams) {
t.Fatalf("ParseStorageURL(%q) error = %v, want %v",
tc.raw, err, storage.ErrRcloneRemoteParams)
}
for _, secret := range tc.secrets {
if strings.Contains(err.Error(), secret) {
t.Errorf("error message leaked %q: %v", secret, err.Error())
}
}
})
}
}
// TestParseStorageURLRejectsUndecodableQuery checks that an s3 URL whose
// query cannot be decoded is rejected, not read with that part of the
// query left out, and that the error does not echo it.
func TestParseStorageURLRejectsUndecodableQuery(t *testing.T) {
t.Parallel()
const (
key = "AKIAKEY"
secret = "topsecret"
)
cases := []struct {
name string
raw string
}{
{
name: "semicolon",
raw: "s3://mybucket/prefix?endpoint=https://" + key + ":" + secret +
"@minio.example.com:9000;region=us-east-1",
},
{
name: "bad percent escape",
raw: "s3://mybucket/prefix?endpoint=https://" + key + ":" + secret +
"%zz@minio.example.com:9000",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, err := storage.ParseStorageURL(tc.raw)
if err == nil {
t.Fatalf("ParseStorageURL(%q) returned no error", tc.raw)
}
if !errors.Is(err, storage.ErrURLBadQuery) {
t.Errorf("ParseStorageURL(%q) error = %v, want %v",
tc.raw, err, storage.ErrURLBadQuery)
}
for _, s := range []string{key, secret, "minio.example.com"} {
if strings.Contains(err.Error(), s) {
t.Errorf("error message leaked %q: %v", s, err.Error())
}
}
})
}
}
// TestParseStorageURLParseFailureHidesURL checks that when url.Parse
// itself fails, the wrapped error carries only the inner cause, not the
// *url.Error whose text embeds the raw (possibly credential-bearing) URL.
+4 -9
View File
@@ -204,7 +204,7 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
return v.outputPruneBlobsJSON(result)
}
v.UI.Infof("No unreferenced blobs to remove.")
v.printlnStdout("No unreferenced blobs to remove.")
return nil
}
@@ -212,17 +212,12 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
log.Info("Found unreferenced blobs",
"count", len(unreferencedBlobs), "total_size", ubytes(totalSize))
if opts.Force && !opts.JSON {
v.UI.Infof("Found %d unreferenced blob(s) totaling %s.",
if !opts.JSON {
v.stdoutf("Found %d unreferenced blob(s) totaling %s\n",
len(unreferencedBlobs), ubytes(totalSize))
}
if !opts.Force && !opts.JSON {
// The count, the prompt and its answer bypass the UI: they are an
// interactive exchange the operator must see, so --quiet does not
// silence them.
v.stdoutf("Found %d unreferenced blob(s) totaling %s.\n",
len(unreferencedBlobs), ubytes(totalSize))
v.stdoutf("\nDelete %d unreferenced blob(s)? [y/N] ", len(unreferencedBlobs))
var confirm string
@@ -249,7 +244,7 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
return err
}
} else {
v.UI.Infof("Deleted %d blob(s) totaling %s.",
v.stdoutf("\nDeleted %d blob(s) totaling %s\n",
result.BlobsDeleted, ubytes(result.BytesFreed))
}
@@ -14,7 +14,6 @@ import (
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/snapshot"
"sneak.berlin/go/vaultik/internal/types"
"sneak.berlin/go/vaultik/internal/ui"
"sneak.berlin/go/vaultik/internal/vaultik"
)
@@ -59,16 +58,13 @@ func setupConsistencyTest(
require.NoError(t, err)
}
stdout := &bytes.Buffer{}
v := &vaultik.Vaultik{
Storage: mockStorage,
Repositories: repos,
DB: db,
Stdout: stdout,
Stdout: &bytes.Buffer{},
Stderr: &bytes.Buffer{},
Stdin: &bytes.Buffer{},
UI: ui.NewWithColor(stdout, false),
}
v.SetContext(ctx)
-2
View File
@@ -14,7 +14,6 @@ import (
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/snapshot"
"sneak.berlin/go/vaultik/internal/types"
"sneak.berlin/go/vaultik/internal/ui"
"sneak.berlin/go/vaultik/internal/vaultik"
)
@@ -87,7 +86,6 @@ func setupPurgeTest(
Stdout: stdout,
Stderr: stderr,
Stdin: stdin,
UI: ui.NewWithColor(stdout, false),
}
v.SetContext(ctx)
-148
View File
@@ -1,148 +0,0 @@
package vaultik_test
import (
"bytes"
"context"
"encoding/json"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/ui"
"sneak.berlin/go/vaultik/internal/vaultik"
)
// TestPurgeSnapshots_QuietSuppressesReport checks that `snapshot purge
// --keep-latest --force` under --quiet deletes the older snapshot and
// prints nothing.
func TestPurgeSnapshots_QuietSuppressesReport(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
v := setupPurgeTest(t, snapHostname, []string{snapHomeT0, snapHomeT1})
var out bytes.Buffer
v.Stdout = &out
v.UI = ui.NewWithColor(&out, false)
v.UI.SetQuiet(true)
require.NoError(t, v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
KeepLatest: true,
Force: true,
}))
assert.Equal(t, []string{snapHomeT1}, listRemainingSnapshots(t, v))
assert.Empty(t, out.String())
}
// TestPurgeSnapshots_QuietPromptListsSnapshots checks that `snapshot
// purge --keep-latest` without --force still lists the snapshots its
// prompt asks to delete under --quiet.
func TestPurgeSnapshots_QuietPromptListsSnapshots(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
v := setupPurgeTest(t, snapHostname, []string{snapHomeT0, snapHomeT1})
var out bytes.Buffer
v.Stdout = &out
v.UI = ui.NewWithColor(&out, false)
v.UI.SetQuiet(true)
// Stdin is empty, so the prompt reads no answer and cancels.
require.NoError(t, v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
KeepLatest: true,
}))
assert.Contains(t, out.String(), snapHomeT0)
assert.Contains(t, out.String(), "Delete 1 snapshot(s)? [y/N]")
assert.Len(t, listRemainingSnapshots(t, v), 2)
}
// TestPruneBlobs_QuietPromptShowsBlobTotal checks that `prune` without
// --force still states the count and total size of the blobs its prompt
// asks to delete under --quiet.
func TestPruneBlobs_QuietPromptShowsBlobTotal(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
env := newListEnv(t)
env.v.UI.SetQuiet(true)
// The store holds no manifest, so nothing references this blob.
addBlob(t, env.store.testStorer, testBlobHashA)
// Stdin is empty, so the prompt reads no answer and cancels.
require.NoError(t, env.v.PruneBlobs(&vaultik.PruneOptions{}))
assert.Contains(t, env.stdout.String(),
"Found 1 unreferenced blob(s) totaling")
assert.Contains(t, env.stdout.String(),
"Delete 1 unreferenced blob(s)? [y/N]")
}
// TestRemoveSnapshot_QuietSuppressesReport checks that `snapshot remove`
// under --quiet prints nothing with --dry-run or with --force, and that
// the --json document still prints.
func TestRemoveSnapshot_QuietSuppressesReport(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
newQuietRemover := func() (*vaultik.TestVaultik, *testStorer) {
store := newTestStorer()
addManifest(t, store, "snapshot-001", nil)
tv := vaultik.NewForTesting(store)
tv.Repositories = newLocalIndex(t, "snapshot-001")
tv.UI.SetQuiet(true)
return tv, store
}
tv, store := newQuietRemover()
_, err := tv.RemoveSnapshot("snapshot-001",
&vaultik.RemoveOptions{DryRun: true})
require.NoError(t, err)
assert.Empty(t, tv.Stdout.String(),
"--dry-run must print nothing under --quiet")
_, err = tv.RemoveSnapshot("snapshot-001",
&vaultik.RemoveOptions{Force: true})
require.NoError(t, err)
assert.False(t, store.hasKey(remoteKeyPath("snapshot-001")))
assert.Empty(t, tv.Stdout.String(),
"--force must print nothing under --quiet")
tv, _ = newQuietRemover()
_, err = tv.RemoveSnapshot("snapshot-001",
&vaultik.RemoveOptions{JSON: true})
require.NoError(t, err)
var result vaultik.RemoveResult
require.NoError(t, json.Unmarshal(tv.Stdout.Bytes(), &result),
"the --json document must still print under --quiet")
assert.True(t, result.RemoteRemoved)
}
// TestNukeRemote_QuietSuppressesReport checks that `remote nuke --force`
// under --quiet prints nothing while it removes a snapshot's metadata
// and its blobs.
func TestNukeRemote_QuietSuppressesReport(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
storeDir := filepath.Join(t.TempDir(), "store")
v, _, out := backUpToFileDestination(ctx, t, storeDir)
v.UI.SetQuiet(true)
require.NoError(t, v.NukeRemote(true))
assert.Empty(t, out.String())
}
+2 -2
View File
@@ -350,7 +350,7 @@ func TestRemoveSnapshot_DryRun(t *testing.T) {
assert.True(t, store.hasKey("blobs/aa/aa/"+blobA))
assert.True(t, store.hasKey(remoteKeyPath("snapshot-001")))
assert.Contains(t, tv.Stdout.String(), "Dry run: no changes made.")
assert.Contains(t, tv.Stdout.String(), "[Dry run - no changes made]")
}
// TestRemoveSnapshot_UnknownIDIsError checks that an ID naming no
@@ -504,7 +504,7 @@ func TestRemoveAllSnapshots_DryRun(t *testing.T) {
assert.Equal(t, initialCount, store.keyCount())
assert.Contains(t, tv.Stdout.String(), "Dry run: no changes made.")
assert.Contains(t, tv.Stdout.String(), "[Dry run - no changes made]")
}
func TestRemoveAllSnapshots_NoSnapshots(t *testing.T) {
+32 -44
View File
@@ -574,7 +574,7 @@ func (v *Vaultik) PurgeSnapshotsWithOptions(opts *SnapshotPurgeOptions) error {
if len(toDelete) == 0 {
if !opts.Quiet {
v.UI.Infof("No snapshots to delete.")
v.printlnStdout("No snapshots to delete")
}
return nil
@@ -631,19 +631,19 @@ func selectSnapshotsToPurge(
func (v *Vaultik) confirmAndExecutePurge(
toDelete []SnapshotInfo, force, quiet bool,
) error {
// Confirm unless --force is used. The list, the prompt and its answer
// bypass the UI: they are an interactive exchange the operator must
// see, so --quiet does not silence them.
if !force {
if !quiet {
v.stdoutf("The following snapshots will be deleted:\n\n")
for _, snap := range toDelete {
v.stdoutf(" %s (%s, %s)\n",
snap.ID,
snap.Timestamp.Format(time.DateTime),
snap.Timestamp.Format("2006-01-02 15:04:05"),
ubytes(snap.CompressedSize))
}
}
// Confirm unless --force is used
if !force {
v.stdoutf("\nDelete %d snapshot(s)? [y/N] ", len(toDelete))
var confirm string
@@ -661,16 +661,7 @@ func (v *Vaultik) confirmAndExecutePurge(
return nil
}
} else if !quiet {
v.UI.Infof("The following snapshots will be deleted:")
for _, snap := range toDelete {
v.UI.Detailf("%s (%s, %s)",
v.UI.Snapshot(snap.ID.String()),
snap.Timestamp.Format(time.DateTime),
ubytes(snap.CompressedSize))
}
v.UI.Infof("Deleting %d snapshot(s) (--force specified).", len(toDelete))
v.stdoutf("\nDeleting %d snapshot(s) (--force specified)\n", len(toDelete))
}
// Delete snapshots (both local and remote)
@@ -703,9 +694,9 @@ func (v *Vaultik) confirmAndExecutePurge(
}
if !quiet {
v.UI.Infof("Deleted %d snapshot(s).", len(toDelete))
v.UI.Noticef("Run '%s' to clean up unreferenced remote blobs.",
pruneCommandHint)
v.stdoutf("Deleted %d snapshot(s)\n", len(toDelete))
v.printlnStdout(
"\nNote: Run 'vaultik prune' to clean up unreferenced remote blobs.")
}
return nil
@@ -1078,7 +1069,7 @@ func (v *Vaultik) CleanupLocalSnapshots(opts *PruneOptions) error {
log.Info("Removing stale local snapshot record", "snapshot_id", id)
if !opts.JSON {
v.UI.Infof("Removing stale local record: %s.", v.UI.Snapshot(id))
v.stdoutf("Removing stale local record: %s\n", id)
}
err = v.deleteSnapshotFromLocalDB(id)
@@ -1101,9 +1092,9 @@ func (v *Vaultik) CleanupLocalSnapshots(opts *PruneOptions) error {
}
if removed == 0 {
v.UI.Infof("No stale local snapshots found.")
v.printlnStdout("No stale local snapshots found.")
} else {
v.UI.Infof("Removed %d stale local snapshot record(s).", removed)
v.stdoutf("Removed %d stale local snapshot record(s).\n", removed)
}
return nil
@@ -1282,15 +1273,14 @@ func (v *Vaultik) RemoveSnapshot(
}
if inLocalIndex {
v.UI.Infof("Removed snapshot '%s' from local database.",
v.UI.Snapshot(snapshotID))
v.stdoutf("Removed snapshot '%s' from local database\n", snapshotID)
}
if result.RemoteRemoved {
v.UI.Infof("Removed snapshot metadata from remote storage.")
v.UI.Noticef("The removed snapshot's blobs remain on the remote. "+
v.printlnStdout("Removed snapshot metadata from remote storage")
v.stdoutf("\nNote: The removed snapshot's blobs remain on the remote. "+
"Run '%s' to delete any blobs no longer referenced by any "+
"remaining remote snapshot.", pruneCommandHint)
"remaining remote snapshot.\n", pruneCommandHint)
}
return result, nil
@@ -1346,23 +1336,21 @@ func (v *Vaultik) removeSnapshotDryRun(
return result, v.outputRemoveJSON(result)
}
v.UI.Infof("Would remove snapshot: %s.", v.UI.Snapshot(snapshotID))
v.stdoutf("Would remove snapshot: %s\n", snapshotID)
if !opts.LocalOnly {
v.UI.Infof("Would also remove snapshot metadata from " +
"remote storage (blobs untouched).")
v.printlnStdout("Would also remove snapshot metadata from " +
"remote storage (blobs untouched)")
}
v.UI.Noticef("Dry run: no changes made.")
v.printlnStdout("[Dry run - no changes made]")
return result, nil
}
// confirmRemoveSnapshot prompts for confirmation before removing a
// snapshot. Returns false (treating EOF/read errors as "no") unless the
// user answers "y". The prompt and its answer bypass the UI: they are an
// interactive exchange the operator must see, so --quiet does not
// silence them.
// user answers "y".
func (v *Vaultik) confirmRemoveSnapshot(snapshotID string, opts *RemoveOptions) bool {
if opts.LocalOnly {
v.stdoutf("Remove snapshot '%s' from local database "+
@@ -1468,7 +1456,7 @@ func (v *Vaultik) RemoveAllSnapshots(opts *RemoveOptions) (*RemoveResult, error)
if len(localSnaps) == 0 && len(orphanRemoteKeys) == 0 {
if !opts.JSON {
v.UI.Infof("No snapshots found.")
v.printlnStdout("No snapshots found")
}
return &RemoveResult{}, nil
@@ -1573,27 +1561,27 @@ func (v *Vaultik) handleRemoveAllDryRun(
}
if !opts.JSON {
v.UI.Infof("Would remove %d local snapshot(s):", len(localSnaps))
v.stdoutf("Would remove %d local snapshot(s):\n", len(localSnaps))
for _, id := range localSnaps {
v.UI.Detailf("%s", v.UI.Snapshot(id))
v.stdoutf(" %s\n", id)
}
if !opts.LocalOnly {
if len(orphanRemoteKeys) > 0 {
v.UI.Infof("Would also remove %d orphan remote snapshot key(s):",
v.stdoutf("Would also remove %d orphan remote snapshot key(s):\n",
len(orphanRemoteKeys))
for _, key := range orphanRemoteKeys {
v.UI.Detailf("%s", key)
v.stdoutf(" %s\n", key)
}
} else {
v.UI.Infof("Would also remove snapshot metadata from " +
"remote storage (blobs untouched).")
v.printlnStdout("Would also remove snapshot metadata from " +
"remote storage (blobs untouched)")
}
}
v.UI.Noticef("Dry run: no changes made.")
v.printlnStdout("[Dry run - no changes made]")
}
if opts.JSON {
@@ -1642,10 +1630,10 @@ func (v *Vaultik) executeRemoveAll(
return result, v.outputRemoveJSON(result)
}
v.UI.Infof("Removed %d snapshot(s).", len(result.SnapshotsRemoved))
v.stdoutf("Removed %d snapshot(s)\n", len(result.SnapshotsRemoved))
if !opts.LocalOnly && result.RemoteRemoved {
v.UI.Infof("Removed snapshot metadata from remote storage.")
v.printlnStdout("Removed snapshot metadata from remote storage")
}
return result, nil