package cli_test import ( "os" "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 } // requireRejectedAndUnchanged runs a command against the two test vaults // and requires that it fails with vault.ErrInvalidSecretName and leaves // everything under the state directory as it was. The error alone proves // nothing: it could be returned after the vault had already been deleted. func requireRejectedAndUnchanged(t *testing.T, run func(c *cli.Instance) error) { t.Helper() fs := newTwoVaultFs(t) before := snapshotStateDir(t, fs) vaultDir := testStateDir + "/vaults.d/default" require.Contains(t, before, vaultDir+"/secrets.d/x/") require.Contains(t, before, vaultDir+"/unlockers.d/passphrase/") err := run(cli.NewCLIInstanceWithStateDir(fs, testStateDir)) require.Equal(t, before, snapshotStateDir(t, fs)) require.ErrorIs(t, err, vault.ErrInvalidSecretName) } // 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 // subtests use t.Setenv via newTwoVaultFs func TestInvalidSecretNameLeavesVaultsUnchanged(t *testing.T) { cmd := &cobra.Command{} tests := []struct { command string 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", 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) }}, {"mv --force default:.. work", func(c *cli.Instance) error { return c.MoveSecret(cmd, "default:..", "work", 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", 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, tt.run) }) } }