Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
061ecc69dc | ||
|
|
8c54e0ff75 |
@@ -251,6 +251,12 @@ func NewConfigCommand() *cobra.Command {
|
|||||||
Use: "config",
|
Use: "config",
|
||||||
Short: "Manage the configuration file",
|
Short: "Manage the configuration file",
|
||||||
Long: "Commands for creating, editing, and querying the vaultik config 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())
|
cmd.AddCommand(newConfigInitCommand())
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ func NewDatabaseCommand() *cobra.Command {
|
|||||||
Use: "database",
|
Use: "database",
|
||||||
Short: "Manage the local state database",
|
Short: "Manage the local state database",
|
||||||
Long: `Commands for managing the local SQLite 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(
|
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",
|
Use: "remote",
|
||||||
Short: "Remote storage management commands",
|
Short: "Remote storage management commands",
|
||||||
Long: "Commands for inspecting and managing remote storage",
|
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
|
// Add subcommands
|
||||||
|
|||||||
@@ -74,6 +74,21 @@ on the source system.`,
|
|||||||
NewDatabaseCommand(),
|
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
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ func NewSnapshotCommand() *cobra.Command {
|
|||||||
Use: "snapshot",
|
Use: "snapshot",
|
||||||
Short: "Snapshot management commands",
|
Short: "Snapshot management commands",
|
||||||
Long: "Commands for creating, listing, and managing snapshots",
|
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
|
// Add subcommands
|
||||||
|
|||||||
+14
-7
@@ -141,6 +141,19 @@ func parseS3URL(rawURL string) (*URL, error) {
|
|||||||
// remote name and any query parameter at all are rejected rather than
|
// remote name and any query parameter at all are rejected rather than
|
||||||
// silently ignored.
|
// silently ignored.
|
||||||
func parseRcloneURL(rawURL string) (*URL, error) {
|
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)
|
u, err := url.Parse(rawURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, wrapParseError(err)
|
return nil, wrapParseError(err)
|
||||||
@@ -150,17 +163,11 @@ func parseRcloneURL(rawURL string) (*URL, error) {
|
|||||||
return nil, ErrURLCredentials
|
return nil, ErrURLCredentials
|
||||||
}
|
}
|
||||||
|
|
||||||
remote := u.Host
|
remote = u.Host
|
||||||
if remote == "" {
|
if remote == "" {
|
||||||
return nil, ErrMissingRemote
|
return nil, ErrMissingRemote
|
||||||
}
|
}
|
||||||
|
|
||||||
// An rclone remote name never contains a comma; one starts rclone's
|
|
||||||
// remote,param=value form, whose values can be credentials.
|
|
||||||
if strings.Contains(remote, ",") {
|
|
||||||
return nil, ErrRcloneRemoteParams
|
|
||||||
}
|
|
||||||
|
|
||||||
err = rejectUnknownParams(u.Query())
|
err = rejectUnknownParams(u.Query())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -218,22 +218,49 @@ func TestParseStorageURLRejectsCredentials(t *testing.T) {
|
|||||||
|
|
||||||
// TestParseStorageURLRejectsRcloneRemoteParams checks that an rclone
|
// TestParseStorageURLRejectsRcloneRemoteParams checks that an rclone
|
||||||
// remote name carrying parameters (rclone's "remote,param=value" form,
|
// remote name carrying parameters (rclone's "remote,param=value" form,
|
||||||
// which can hold a credential) is rejected without echoing the value.
|
// which can hold a credential) is rejected without echoing the value,
|
||||||
|
// including the URLs that url.Parse itself refuses.
|
||||||
func TestParseStorageURLRejectsRcloneRemoteParams(t *testing.T) {
|
func TestParseStorageURLRejectsRcloneRemoteParams(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const secret = "topsecret"
|
cases := []struct {
|
||||||
|
name string
|
||||||
raw := "rclone://gdrive,client_secret=" + secret + "/backups"
|
raw string
|
||||||
|
secrets []string // must not appear in the error message
|
||||||
_, err := storage.ParseStorageURL(raw)
|
}{
|
||||||
if !errors.Is(err, storage.ErrRcloneRemoteParams) {
|
{
|
||||||
t.Fatalf("ParseStorageURL(%q) error = %v, want %v",
|
name: "configured remote",
|
||||||
raw, err, storage.ErrRcloneRemoteParams)
|
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"},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.Contains(err.Error(), secret) {
|
for _, tc := range cases {
|
||||||
t.Errorf("error message leaked %q: %v", secret, err.Error())
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user