check / check (push) Waiting to run
`secret rm ..` resolved to the vault directory and deleted the whole vault; `secret rm .` and `secret rm ""` deleted every secret. rm, mv, the version commands, encrypt and decrypt built paths from the name without checking it; import checked it only after reading the source file. vault.ValidateSecretName wraps the existing name rule; its error and README.md state the rule. Each of those commands calls it on the name as given, before building any path; MoveSecret checks both names once, for every form of the move, before switching the current vault. AddSecret, GetSecretVersion and GetSecretObject use it too. The regression test copies two in-memory vaults for each rejected command and requires the exact error and an unchanged state directory. Model: opus-5-5
233 lines
7.1 KiB
Go
233 lines
7.1 KiB
Go
package cli_test
|
|
|
|
import (
|
|
"maps"
|
|
"os"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.eeqj.de/sneak/secret/internal/cli"
|
|
"git.eeqj.de/sneak/secret/internal/secret"
|
|
"git.eeqj.de/sneak/secret/internal/vault"
|
|
"github.com/awnumar/memguard"
|
|
"github.com/spf13/afero"
|
|
"github.com/spf13/cobra"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
const (
|
|
// testStateDir is the in-memory state directory of the test vaults.
|
|
testStateDir = "/test/state"
|
|
|
|
// testPassphrase protects the passphrase unlocker of each test vault.
|
|
testPassphrase = "test-passphrase"
|
|
|
|
// testVersion is a version name in the format the vault uses.
|
|
testVersion = "20260101.001"
|
|
|
|
// missingFile is an import source that does not exist, so an import
|
|
// that opened it before checking the name would fail with another error.
|
|
missingFile = "/no/such/file"
|
|
)
|
|
|
|
// newTwoVaultFs returns an in-memory filesystem holding the vaults "work"
|
|
// and "default", the current one. Each holds the secret "x" and a
|
|
// passphrase unlocker, so both secrets.d and unlockers.d have contents.
|
|
//
|
|
//nolint:ireturn // afero.Fs is the filesystem abstraction used throughout
|
|
func newTwoVaultFs(t *testing.T) afero.Fs {
|
|
t.Helper()
|
|
|
|
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
|
|
|
fs := afero.NewMemMapFs()
|
|
|
|
for _, name := range []string{"work", "default"} {
|
|
vlt, err := vault.CreateVault(fs, testStateDir, name)
|
|
require.NoError(t, err)
|
|
|
|
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("value")), false)
|
|
require.NoError(t, err)
|
|
|
|
_, err = vlt.CreatePassphraseUnlocker(
|
|
memguard.NewBufferFromBytes([]byte(testPassphrase)))
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
return fs
|
|
}
|
|
|
|
// snapshotStateDir maps every file under the state directory to its
|
|
// contents, and every directory, written with a trailing "/", to "". Two
|
|
// snapshots are equal only if nothing in it was added, removed or changed.
|
|
func snapshotStateDir(t *testing.T, fs afero.Fs) map[string]string {
|
|
t.Helper()
|
|
|
|
tree := map[string]string{}
|
|
|
|
err := afero.Walk(fs, testStateDir, func(
|
|
path string, info os.FileInfo, err error,
|
|
) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if info.IsDir() {
|
|
tree[path+"/"] = ""
|
|
|
|
return nil
|
|
}
|
|
|
|
content, err := afero.ReadFile(fs, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
tree[path] = string(content)
|
|
|
|
return nil
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
return tree
|
|
}
|
|
|
|
// newFsFromSnapshot returns a new in-memory filesystem holding exactly the
|
|
// directories and files recorded by snapshotStateDir.
|
|
//
|
|
//nolint:ireturn // afero.Fs is the filesystem abstraction used throughout
|
|
func newFsFromSnapshot(t *testing.T, tree map[string]string) afero.Fs {
|
|
t.Helper()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
|
|
// In sorted order every directory comes before its contents.
|
|
for _, path := range slices.Sorted(maps.Keys(tree)) {
|
|
dir, isDir := strings.CutSuffix(path, "/")
|
|
if isDir {
|
|
require.NoError(t, fs.MkdirAll(dir, secret.DirPerms))
|
|
|
|
continue
|
|
}
|
|
|
|
err := afero.WriteFile(fs, path, []byte(tree[path]), secret.FilePerms)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
return fs
|
|
}
|
|
|
|
// requireRejectedAndUnchanged runs a command on a copy of the state
|
|
// directory recorded in before. It requires exactly the error
|
|
// vault.ValidateSecretName gives for the rejected name, so that a later
|
|
// check rejecting the name does not count, and everything under the state
|
|
// directory as it was: the error alone proves nothing, since it could come
|
|
// after the vault had already been deleted.
|
|
func requireRejectedAndUnchanged(
|
|
t *testing.T, before map[string]string, rejected string,
|
|
run func(c *cli.Instance) error,
|
|
) {
|
|
t.Helper()
|
|
|
|
fs := newFsFromSnapshot(t, before)
|
|
|
|
err := run(cli.NewCLIInstanceWithStateDir(fs, testStateDir))
|
|
|
|
require.Equal(t, before, snapshotStateDir(t, fs))
|
|
require.ErrorIs(t, err, vault.ErrInvalidSecretName)
|
|
require.EqualError(t, err, vault.ValidateSecretName(rejected).Error())
|
|
}
|
|
|
|
// TestInvalidSecretNameLeavesVaultsUnchanged is a regression test for
|
|
// https://git.eeqj.de/sneak/secret/issues/33, where `secret rm ..` deleted
|
|
// the whole vault, and `secret rm .` or `secret rm ""` every secret in it.
|
|
// Moves and imports use --force, so that only the name check stands in
|
|
// the way.
|
|
//
|
|
//nolint:paralleltest // newTwoVaultFs uses t.Setenv
|
|
func TestInvalidSecretNameLeavesVaultsUnchanged(t *testing.T) {
|
|
// Creating a passphrase unlocker is slow by design, so the vaults are
|
|
// created once and each case runs on its own copy of them.
|
|
before := snapshotStateDir(t, newTwoVaultFs(t))
|
|
|
|
vaultDir := testStateDir + "/vaults.d/default"
|
|
require.Contains(t, before, vaultDir+"/secrets.d/x/")
|
|
require.Contains(t, before, vaultDir+"/unlockers.d/passphrase/")
|
|
require.Equal(t, "default", before[testStateDir+"/currentvault"])
|
|
|
|
cmd := &cobra.Command{}
|
|
|
|
tests := []struct {
|
|
command string
|
|
rejected string // the secret name the command must reject
|
|
run func(c *cli.Instance) error
|
|
}{
|
|
{"rm ..", "..", func(c *cli.Instance) error {
|
|
return c.RemoveSecret(cmd, "..", false)
|
|
}},
|
|
{"rm .", ".", func(c *cli.Instance) error {
|
|
return c.RemoveSecret(cmd, ".", false)
|
|
}},
|
|
{`rm ""`, "", func(c *cli.Instance) error {
|
|
return c.RemoveSecret(cmd, "", false)
|
|
}},
|
|
{"rm ../../etc", "../../etc", func(c *cli.Instance) error {
|
|
return c.RemoveSecret(cmd, "../../etc", false)
|
|
}},
|
|
{"mv --force .. x", "..", func(c *cli.Instance) error {
|
|
return c.MoveSecret(cmd, "..", "x", true)
|
|
}},
|
|
{"mv --force x ..", "..", func(c *cli.Instance) error {
|
|
return c.MoveSecret(cmd, "x", "..", true)
|
|
}},
|
|
// "work" is not the current vault: a move within it must not
|
|
// select it when a name is rejected.
|
|
{"mv --force work:.. work:x", "..", func(c *cli.Instance) error {
|
|
return c.MoveSecret(cmd, "work:..", "work:x", true)
|
|
}},
|
|
{"mv --force work:x work:..", "..", func(c *cli.Instance) error {
|
|
return c.MoveSecret(cmd, "work:x", "work:..", true)
|
|
}},
|
|
{"mv --force default:.. work", "..", func(c *cli.Instance) error {
|
|
return c.MoveSecret(cmd, "default:..", "work", true)
|
|
}},
|
|
{"mv --force default:.. work:y", "..", func(c *cli.Instance) error {
|
|
return c.MoveSecret(cmd, "default:..", "work:y", true)
|
|
}},
|
|
{"mv --force default:x work:..", "..", func(c *cli.Instance) error {
|
|
return c.MoveSecret(cmd, "default:x", "work:..", true)
|
|
}},
|
|
{"import --force ..", "..", func(c *cli.Instance) error {
|
|
return c.ImportSecret(cmd, "..", missingFile, true)
|
|
}},
|
|
{"import --force .", ".", func(c *cli.Instance) error {
|
|
return c.ImportSecret(cmd, ".", missingFile, true)
|
|
}},
|
|
{"import --force ../../etc", "../../etc", func(c *cli.Instance) error {
|
|
return c.ImportSecret(cmd, "../../etc", missingFile, true)
|
|
}},
|
|
{"version list ..", "..", func(c *cli.Instance) error {
|
|
return c.ListVersions(cmd, "..")
|
|
}},
|
|
{"version promote ..", "..", func(c *cli.Instance) error {
|
|
return c.PromoteVersion(cmd, "..", testVersion)
|
|
}},
|
|
{"version rm ..", "..", func(c *cli.Instance) error {
|
|
return c.RemoveVersion(cmd, "..", testVersion)
|
|
}},
|
|
{"encrypt ..", "..", func(c *cli.Instance) error {
|
|
return c.Encrypt("..", "", "")
|
|
}},
|
|
{"decrypt ..", "..", func(c *cli.Instance) error {
|
|
return c.Decrypt("..", "", "")
|
|
}},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.command, func(t *testing.T) {
|
|
requireRejectedAndUnchanged(t, before, tt.rejected, tt.run)
|
|
})
|
|
}
|
|
}
|