Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
061ecc69dc | ||
|
|
8c54e0ff75 | ||
|
|
d1213ecb34 |
@@ -251,6 +251,12 @@ func NewConfigCommand() *cobra.Command {
|
||||
Use: "config",
|
||||
Short: "Manage the configuration file",
|
||||
Long: "Commands for creating, editing, and querying the vaultik config file.",
|
||||
// cobra checks Args only on a command that has a Run: without
|
||||
// one, a mistyped subcommand printed help and exited 0.
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(cmd *cobra.Command, _ []string) {
|
||||
_ = cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.AddCommand(newConfigInitCommand())
|
||||
|
||||
@@ -15,6 +15,12 @@ func NewDatabaseCommand() *cobra.Command {
|
||||
Use: "database",
|
||||
Short: "Manage the local state database",
|
||||
Long: `Commands for managing the local SQLite state database.`,
|
||||
// cobra checks Args only on a command that has a Run: without
|
||||
// one, a mistyped subcommand printed help and exited 0.
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(cmd *cobra.Command, _ []string) {
|
||||
_ = cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.AddCommand(
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package cli //nolint:testpackage // shares runEntry and the command-word constants
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestEntryGroupRejectsUnknownSubcommand runs each group command with a
|
||||
// mistyped subcommand, as a crontab line might carry one. It must fail
|
||||
// with exit 1 and name the word, as `vaultik bogus` does at the top
|
||||
// level, instead of printing the group's help and exiting 0 having done
|
||||
// nothing. The group name alone still prints its help.
|
||||
//
|
||||
//nolint:paralleltest // replaces os.Args, os.Stdout and os.Stderr
|
||||
func TestEntryGroupRejectsUnknownSubcommand(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
group string
|
||||
mistyped string
|
||||
}{
|
||||
{group: cmdSnapshot, mistyped: "craete"},
|
||||
{group: "config", mistyped: "inti"},
|
||||
{group: cmdRemote, mistyped: "ifno"},
|
||||
{group: "database", mistyped: "delte"},
|
||||
{group: "completion", mistyped: "nosuchshell"},
|
||||
} {
|
||||
t.Run(testCase.group, func(t *testing.T) {
|
||||
code, stdout, stderr := runEntry(t,
|
||||
testCase.group, testCase.mistyped)
|
||||
|
||||
assert.Equal(t, 1, code)
|
||||
assert.Empty(t, stdout)
|
||||
assert.Contains(t, stderr,
|
||||
`unknown command "`+testCase.mistyped+`"`)
|
||||
|
||||
code, stdout, _ = runEntry(t, testCase.group)
|
||||
|
||||
assert.Equal(t, 0, code)
|
||||
assert.Contains(t, stdout,
|
||||
programName+" "+testCase.group+" [command]")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,12 @@ func NewRemoteCommand() *cobra.Command {
|
||||
Use: "remote",
|
||||
Short: "Remote storage management commands",
|
||||
Long: "Commands for inspecting and managing remote storage",
|
||||
// cobra checks Args only on a command that has a Run: without
|
||||
// one, a mistyped subcommand printed help and exited 0.
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(cmd *cobra.Command, _ []string) {
|
||||
_ = cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
// Add subcommands
|
||||
|
||||
@@ -74,6 +74,21 @@ on the source system.`,
|
||||
NewDatabaseCommand(),
|
||||
)
|
||||
|
||||
// cobra adds the completion command only when Execute runs, and with
|
||||
// no Run, so it skipped its own cobra.NoArgs and a mistyped shell name
|
||||
// printed help and exited 0. Adding it here lets it have a Run. Its
|
||||
// scripts go to os.Stdout as it is now: a later SetOut does not
|
||||
// redirect them.
|
||||
cmd.InitDefaultCompletionCmd()
|
||||
|
||||
for _, sub := range cmd.Commands() {
|
||||
if sub.Name() == "completion" {
|
||||
sub.Run = func(completion *cobra.Command, _ []string) {
|
||||
_ = completion.Help()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,12 @@ func NewSnapshotCommand() *cobra.Command {
|
||||
Use: "snapshot",
|
||||
Short: "Snapshot management commands",
|
||||
Long: "Commands for creating, listing, and managing snapshots",
|
||||
// cobra checks Args only on a command that has a Run: without
|
||||
// one, a mistyped subcommand printed help and exited 0.
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(cmd *cobra.Command, _ []string) {
|
||||
_ = cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
// Add subcommands
|
||||
|
||||
@@ -531,3 +531,50 @@ func TestExcludePatterns_UnanchoredPatternFile(t *testing.T) {
|
||||
require.Equal(t, 1, result.FilesScanned,
|
||||
"Unanchored pattern for file should match anywhere")
|
||||
}
|
||||
|
||||
func TestExcludePatterns_DoubleStarDirectoryAtEveryDepth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// As in gitignore, a leading **/ also matches directly under the source dir
|
||||
fs := setupAnchoredTestFS(t)
|
||||
|
||||
scanner, repos, cleanup := createTestScanner(t, fs, []string{"**/projectname"})
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
createSnapshotRecord(ctx, t, repos, "test-snapshot")
|
||||
|
||||
result, err := scanner.Scan(ctx, "/backup", "test-snapshot")
|
||||
require.NoError(t, err)
|
||||
|
||||
// **/projectname should exclude BOTH:
|
||||
// - /backup/projectname/file.txt
|
||||
// - /backup/otherproject/projectname/file.txt
|
||||
// Total files: 4, excluded: 2
|
||||
require.Equal(t, 2, result.FilesScanned,
|
||||
"**/ directory pattern should match at the top level and nested")
|
||||
}
|
||||
|
||||
func TestExcludePatterns_DoubleStarFileAtEveryDepth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// As in gitignore, a leading **/ also matches directly under the source dir
|
||||
fs := setupAnchoredTestFS(t)
|
||||
|
||||
scanner, repos, cleanup := createTestScanner(t, fs, []string{"**/*.txt"})
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
createSnapshotRecord(ctx, t, repos, "test-snapshot")
|
||||
|
||||
result, err := scanner.Scan(ctx, "/backup", "test-snapshot")
|
||||
require.NoError(t, err)
|
||||
|
||||
// **/*.txt should exclude ALL .txt files:
|
||||
// - /backup/file.txt
|
||||
// - /backup/projectname/file.txt
|
||||
// - /backup/otherproject/projectname/file.txt
|
||||
// Total files: 4, excluded: 3
|
||||
require.Equal(t, 1, result.FilesScanned,
|
||||
"**/ file pattern should match at the top level and nested")
|
||||
}
|
||||
|
||||
@@ -2018,6 +2018,14 @@ func compileExcludePatterns(patterns []string) []compiledPattern {
|
||||
// Remove trailing slash if present (directory indicator)
|
||||
pattern = strings.TrimSuffix(pattern, "/")
|
||||
|
||||
// A leading "**/" demands a "/" before the name and so misses an
|
||||
// entry directly under the source path. An unanchored pattern
|
||||
// already matches at every depth, so drop the prefix: as in
|
||||
// gitignore, "**/x" then also matches a top-level "x".
|
||||
if !anchored {
|
||||
pattern = strings.TrimPrefix(pattern, "**/")
|
||||
}
|
||||
|
||||
// Compile the glob pattern
|
||||
// For patterns without path separators, we need to match them as components
|
||||
// e.g., ".git" should match ".git" anywhere in the path
|
||||
|
||||
+32
-6
@@ -29,7 +29,10 @@ var (
|
||||
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")
|
||||
ErrURLUnknownParam = errors.New("unknown query parameter in storage URL")
|
||||
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 +93,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, 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 {
|
||||
@@ -114,6 +118,14 @@ func parseS3URL(rawURL string) (*URL, error) {
|
||||
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 +137,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 +163,7 @@ func parseRcloneURL(rawURL string) (*URL, error) {
|
||||
return nil, ErrURLCredentials
|
||||
}
|
||||
|
||||
remote := u.Host
|
||||
remote = u.Host
|
||||
if remote == "" {
|
||||
return nil, ErrMissingRemote
|
||||
}
|
||||
|
||||
@@ -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,54 @@ 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())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -103,8 +103,6 @@ func (v *Vaultik) FetchAndDecryptBlob(
|
||||
}
|
||||
|
||||
// FetchBlob downloads a blob and returns a reader for the encrypted data.
|
||||
// The reader is closed as soon as ctx is cancelled, which ends a read
|
||||
// blocked inside it.
|
||||
func (v *Vaultik) FetchBlob(
|
||||
ctx context.Context, blobHash string,
|
||||
) (io.ReadCloser, error) {
|
||||
@@ -122,47 +120,5 @@ func (v *Vaultik) FetchBlob(
|
||||
return nil, fmt.Errorf("downloading blob %s: %w", shortHash(blobHash), err)
|
||||
}
|
||||
|
||||
return newCloseOnCancelReader(ctx, rc), nil
|
||||
}
|
||||
|
||||
// closeOnCancelReader closes the reader it wraps when ctx is cancelled.
|
||||
// Not every backend's reader stops on cancel by itself (the file
|
||||
// backend's does not), and a read stalled on a slow or hung source would
|
||||
// otherwise outlast the shutdown timeout, so the process would exit
|
||||
// before restore or deep verify removed its decrypted temporary files.
|
||||
type closeOnCancelReader struct {
|
||||
io.ReadCloser
|
||||
|
||||
ctx context.Context //nolint:containedctx // Read reports its error
|
||||
stop func() bool
|
||||
}
|
||||
|
||||
func newCloseOnCancelReader(
|
||||
ctx context.Context, rc io.ReadCloser,
|
||||
) *closeOnCancelReader {
|
||||
return &closeOnCancelReader{
|
||||
ReadCloser: rc,
|
||||
ctx: ctx,
|
||||
stop: context.AfterFunc(ctx, func() { _ = rc.Close() }),
|
||||
}
|
||||
}
|
||||
|
||||
// Read returns the context's error, not the closed reader's, once ctx
|
||||
// has been cancelled.
|
||||
func (r *closeOnCancelReader) Read(p []byte) (int, error) {
|
||||
n, err := r.ReadCloser.Read(p)
|
||||
if err != nil && r.ctx.Err() != nil {
|
||||
return n, r.ctx.Err()
|
||||
}
|
||||
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Close closes the wrapped reader, unless the cancel already closed it.
|
||||
func (r *closeOnCancelReader) Close() error {
|
||||
if !r.stop() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return r.ReadCloser.Close()
|
||||
return rc, nil
|
||||
}
|
||||
|
||||
@@ -142,14 +142,14 @@ func TestRestoreCleansTempDirOnInterrupt(t *testing.T) {
|
||||
}
|
||||
|
||||
// scratchEntries returns the vaultik blob-cache and snapshot-database
|
||||
// scratch entries (restore's and deep verify's) currently present in dir.
|
||||
// scratch entries currently present in dir.
|
||||
func scratchEntries(t *testing.T, dir string) []string {
|
||||
t.Helper()
|
||||
|
||||
var matches []string
|
||||
|
||||
for _, pattern := range []string{
|
||||
"vaultik-blobcache-*", "vaultik-restore-*", "vaultik-verify-*",
|
||||
"vaultik-blobcache-*", "vaultik-restore-*",
|
||||
} {
|
||||
found, err := filepath.Glob(filepath.Join(dir, pattern))
|
||||
require.NoError(t, err)
|
||||
@@ -206,119 +206,3 @@ func TestRestoreSkipErrorsCancelDuringBlobDownload(t *testing.T) {
|
||||
assert.NotContains(t, out.String(), srcPath,
|
||||
"the cancel was reported as a failed file")
|
||||
}
|
||||
|
||||
// stallingBlobStorer wraps a Storer so that every blob download hands
|
||||
// over the first half of the blob and then stalls, ignoring the context,
|
||||
// as a file:// blob on a hung USB stick or network share does. Only
|
||||
// closing the returned reader ends the stall.
|
||||
type stallingBlobStorer struct {
|
||||
storage.Storer
|
||||
|
||||
once sync.Once
|
||||
stalled chan struct{}
|
||||
}
|
||||
|
||||
func newStallingBlobStorer(inner storage.Storer) *stallingBlobStorer {
|
||||
return &stallingBlobStorer{Storer: inner, stalled: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (s *stallingBlobStorer) Get(
|
||||
ctx context.Context, key string,
|
||||
) (io.ReadCloser, error) {
|
||||
rc, err := s.Storer.Get(ctx, key)
|
||||
if err != nil || !strings.HasPrefix(key, "blobs/") {
|
||||
return rc, err
|
||||
}
|
||||
|
||||
blob, err := io.ReadAll(rc)
|
||||
_ = rc.Close()
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The writer is never closed, so once the first half has been read
|
||||
// the next Read blocks until the reader is closed.
|
||||
pr, pw := io.Pipe()
|
||||
|
||||
go func() {
|
||||
_, _ = pw.Write(blob[:len(blob)/2])
|
||||
|
||||
s.once.Do(func() { close(s.stalled) })
|
||||
}()
|
||||
|
||||
return pr, nil
|
||||
}
|
||||
|
||||
// TestInterruptDuringStalledBlobReadCleansTempDir stops a restore and a
|
||||
// deep verify while a blob read is stalled on a source that ignores the
|
||||
// context. Stopping must end that read, so the operation unwinds and
|
||||
// removes its decrypted scratch files well within the shutdown timeout
|
||||
// (issue #325).
|
||||
//
|
||||
// Not parallel: it points TMPDIR at a private directory (via t.Setenv)
|
||||
// so it can assert on exactly the scratch files the operation created.
|
||||
func TestInterruptDuringStalledBlobReadCleansTempDir(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
|
||||
operations := map[string]func(v *Vaultik, snapshotID, targetDir string) error{
|
||||
"restore": func(v *Vaultik, snapshotID, targetDir string) error {
|
||||
return v.Restore(&RestoreOptions{
|
||||
SnapshotID: snapshotID,
|
||||
TargetDir: targetDir,
|
||||
})
|
||||
},
|
||||
"deep verify": func(v *Vaultik, snapshotID, _ string) error {
|
||||
return v.RunDeepVerify(snapshotID, &VerifyOptions{Deep: true})
|
||||
},
|
||||
}
|
||||
|
||||
for name, op := range operations {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
fs := afero.NewOsFs()
|
||||
root := t.TempDir()
|
||||
|
||||
// Random content, so the blob stays large enough to stall
|
||||
// part-way through its compressed, encrypted body.
|
||||
cfg, storer, snapshotID, _ := backupOneFile(context.Background(),
|
||||
t, fs, root, "a.bin", randomBytes(t, 256*1024), 0o644)
|
||||
|
||||
scratch := filepath.Join(root, "scratch")
|
||||
require.NoError(t, fs.MkdirAll(scratch, 0o755))
|
||||
t.Setenv("TMPDIR", scratch)
|
||||
|
||||
stalling := newStallingBlobStorer(storer)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
v := newRestoreVaultik(ctx, cfg, stalling, fs)
|
||||
v.cancel = cancel
|
||||
|
||||
var opErr error
|
||||
|
||||
stop := v.StartOperation(func() {
|
||||
opErr = op(v, snapshotID, filepath.Join(root, "restored"))
|
||||
})
|
||||
|
||||
select {
|
||||
case <-stalling.stalled:
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("the operation never reached a blob read")
|
||||
}
|
||||
|
||||
require.NotEmpty(t, scratchEntries(t, scratch),
|
||||
"expected decrypted scratch files to exist mid-read")
|
||||
|
||||
stopCtx, stopCancel := context.WithTimeout(
|
||||
context.Background(), 10*time.Second)
|
||||
defer stopCancel()
|
||||
|
||||
require.True(t, stop(stopCtx),
|
||||
"stop timed out; the stalled blob read was not interrupted")
|
||||
require.ErrorIs(t, opErr, context.Canceled)
|
||||
require.Empty(t, scratchEntries(t, scratch),
|
||||
"decrypted scratch files remained after the interrupt")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user