package cli_test import ( "os" "path/filepath" "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" ) // TestRejectedMoveWithinVaultLeavesStateUnchanged is a regression test for // https://git.eeqj.de/sneak/secret/issues/73, where a forced move of a secret // onto itself deleted it, also when "work" was spelled two ways, and a failed // move within "work" left "work" the current vault. "default" is the current // vault in every case, and each case runs on its own copy of the state // directory. // //nolint:paralleltest // newTwoVaultFs uses t.Setenv func TestRejectedMoveWithinVaultLeavesStateUnchanged(t *testing.T) { before := snapshotStateDir(t, newTwoVaultFs(t)) require.Equal(t, "default", before[testStateDir+"/currentvault"]) const ( ontoItself = "secret 'x' cannot be moved onto itself" workX = "work:x" ) tests := []struct { command string source, dest string force bool wantErr string }{ {"mv x x", "x", "x", false, ontoItself}, {"mv --force x x", "x", "x", true, ontoItself}, {"mv --force work:x work:", workX, "work:", true, ontoItself}, // An empty destination name defaults to the source name. {`mv --force work:x ""`, workX, "", true, ontoItself}, // "work" is a vault name, so the destination is work:x. {"mv --force work:x work", workX, "work", true, ontoItself}, { "mv work:nosuch work:y", "work:nosuch", "work:y", false, "secret 'nosuch' not found", }, // Only an existing vault is used, so ".." cannot reach the state // directory itself. { "mv --force ..:x ..:y", "..:x", "..:y", true, "vault '..' does not exist", }, // Each of these spells "work" a second way. The spelling is not an // existing vault name, so the move is not taken for a move between // two vaults, which would delete the destination, here the source. { "mv --force work:x work/:x", workX, "work/:x", true, "vault 'work/' does not exist", }, { "mv --force work/:x work:", "work/:x", "work:", true, "vault 'work/' does not exist", }, { "mv --force work:x ./work:x", workX, "./work:x", true, "vault './work' does not exist", }, } for _, tt := range tests { t.Run(tt.command, func(t *testing.T) { fs := newFsFromSnapshot(t, before) c := cli.NewCLIInstanceWithStateDir(fs, testStateDir) err := c.MoveSecret(&cobra.Command{}, tt.source, tt.dest, tt.force) require.Equal(t, before, snapshotStateDir(t, fs)) require.EqualError(t, err, tt.wantErr) }) } } // TestMoveWithinOtherVaultKeepsCurrentVault checks that `secret mv work:x // work:y`, with "default" the current vault, renames "x" to "y" in "work" and // leaves "default" the current vault. // //nolint:paralleltest // newTwoVaultFs uses t.Setenv func TestMoveWithinOtherVaultKeepsCurrentVault(t *testing.T) { fs := newTwoVaultFs(t) c := cli.NewCLIInstanceWithStateDir(fs, testStateDir) err := c.MoveSecret(&cobra.Command{}, "work:x", "work:y", false) require.NoError(t, err) after := snapshotStateDir(t, fs) workSecrets := testStateDir + "/vaults.d/work/secrets.d/" require.Equal(t, "default", after[testStateDir+"/currentvault"]) require.Contains(t, after, workSecrets+"y/") require.NotContains(t, after, workSecrets+"x/") } // TestMoveOntoSameSecretUnderAnotherNameIsRejected is a regression test for // https://git.eeqj.de/sneak/secret/issues/78: on a case-insensitive // filesystem "Foo" and "foo" are one secret, and `secret mv --force Foo foo` // removed the destination, which was the source. Symbolic links on the real // filesystem give one secret two names here: in "default", "y" is a link to // the secret "x", and the secrets.d of "other" is a link to that of // "default", so other:x is default:x. Each move must be rejected and leave // the secret and the links as they were. // //nolint:paralleltest // t.Setenv func TestMoveOntoSameSecretUnderAnotherNameIsRejected(t *testing.T) { t.Setenv(secret.EnvMnemonic, testMnemonic) const isSame = "is the same secret on this filesystem" tests := []struct { command string source, dest string force bool wantErr string }{ { "mv --force y x", "y", "x", true, "secret 'y' cannot be moved onto itself: 'x' " + isSame, }, { "mv --force x y", "x", "y", true, "secret 'x' cannot be moved onto itself: 'y' " + isSame, }, { "mv x y", "x", "y", false, "secret 'x' cannot be moved onto itself: 'y' " + isSame, }, { "mv --force default:x other:x", "default:x", "other:x", true, "secret 'default:x' cannot be moved onto itself: 'other:x' " + isSame, }, { "mv default:x other", "default:x", "other", false, "secret 'default:x' cannot be moved onto itself: 'other:x' " + isSame, }, } for _, tt := range tests { t.Run(tt.command, func(t *testing.T) { fs := afero.NewOsFs() stateDir := t.TempDir() vaultsDir := filepath.Join(stateDir, "vaults.d") // "default" is created last, so it is the current vault. _, err := vault.CreateVault(fs, stateDir, "other") require.NoError(t, err) vlt, err := vault.CreateVault(fs, stateDir, "default") require.NoError(t, err) err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("value")), false) require.NoError(t, err) defaultSecrets := filepath.Join(vaultsDir, "default", "secrets.d") otherSecrets := filepath.Join(vaultsDir, "other", "secrets.d") link := filepath.Join(defaultSecrets, "y") require.NoError(t, os.Symlink("x", link)) require.NoError(t, os.Remove(otherSecrets)) require.NoError(t, os.Symlink(defaultSecrets, otherSecrets)) c := cli.NewCLIInstanceWithStateDir(fs, stateDir) moveErr := c.MoveSecret(&cobra.Command{}, tt.source, tt.dest, tt.force) value, err := vlt.GetSecret("x") require.NoError(t, err) defer value.Destroy() require.Equal(t, []byte("value"), value.Bytes()) target, err := os.Readlink(link) require.NoError(t, err) require.Equal(t, "x", target) target, err = os.Readlink(otherSecrets) require.NoError(t, err) require.Equal(t, defaultSecrets, target) require.EqualError(t, moveErr, tt.wantErr) }) } } // TestForcedCaseOnlyMoveOnCaseSensitiveFilesystem checks that where "Foo" // and "foo" are two secrets, `secret mv --force Foo foo` still replaces "foo" // with "Foo". func TestForcedCaseOnlyMoveOnCaseSensitiveFilesystem(t *testing.T) { t.Setenv(secret.EnvMnemonic, testMnemonic) fs := afero.NewOsFs() stateDir := t.TempDir() vlt, err := vault.CreateVault(fs, stateDir, "default") require.NoError(t, err) err = vlt.AddSecret("Foo", memguard.NewBufferFromBytes([]byte("upper")), false) require.NoError(t, err) _, err = os.Stat(filepath.Join(stateDir, "vaults.d", "default", "secrets.d", "foo")) if err == nil { t.Skip("the temporary directory is on a case-insensitive filesystem") } err = vlt.AddSecret("foo", memguard.NewBufferFromBytes([]byte("lower")), false) require.NoError(t, err) c := cli.NewCLIInstanceWithStateDir(fs, stateDir) err = c.MoveSecret(&cobra.Command{}, "Foo", "foo", true) require.NoError(t, err) value, err := vlt.GetSecret("foo") require.NoError(t, err) defer value.Destroy() require.Equal(t, []byte("upper"), value.Bytes()) _, err = vlt.GetSecret("Foo") require.ErrorIs(t, err, vault.ErrSecretNotFound) }