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) }) } }