Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ae2f47a6d |
@@ -118,11 +118,7 @@ are allowed, and a name must not be empty, `.` or `..`.
|
|||||||
|
|
||||||
#### `secret vault select <name>`
|
#### `secret vault select <name>`
|
||||||
|
|
||||||
Switches to the specified vault for subsequent operations. When `currentvault`
|
Switches to the specified vault for subsequent operations.
|
||||||
is empty or names a vault that does not exist, every command that uses the
|
|
||||||
current vault fails with an error that names the file and says to run
|
|
||||||
`secret vault select <name>`. `secret vault list` still lists the vaults, and
|
|
||||||
`secret info` still runs; both show no vault as current.
|
|
||||||
|
|
||||||
#### `secret vault remove <name> [--force]` / `secret vault rm` ⚠️ 🛑
|
#### `secret vault remove <name> [--force]` / `secret vault rm` ⚠️ 🛑
|
||||||
|
|
||||||
@@ -322,12 +318,6 @@ name. The file's bytes are stored unchanged, a final newline included.
|
|||||||
|
|
||||||
### Encryption Operations
|
### Encryption Operations
|
||||||
|
|
||||||
With `--output`, `secret encrypt` and `secret decrypt` hold the result in memory
|
|
||||||
and write it to `--output` only once encryption or decryption has succeeded, so
|
|
||||||
a failure to encrypt or decrypt leaves `--output` untouched. A failure while
|
|
||||||
writing `--output`, such as a full disk, can leave it empty or partly written.
|
|
||||||
The command can need up to about four times as much memory as the result.
|
|
||||||
|
|
||||||
#### `secret encrypt <secret-name> [--input=file] [--output=file]`
|
#### `secret encrypt <secret-name> [--input=file] [--output=file]`
|
||||||
|
|
||||||
Encrypts data using an Age key stored as a secret. If the secret doesn't exist,
|
Encrypts data using an Age key stored as a secret. If the secret doesn't exist,
|
||||||
|
|||||||
+19
-50
@@ -1,7 +1,6 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -108,8 +107,8 @@ func (cli *Instance) storeNewEncryptionKey(
|
|||||||
// does not exist. The caller must destroy the returned buffer. Only storing
|
// does not exist. The caller must destroy the returned buffer. Only storing
|
||||||
// a new key takes the state directory lock, so that reading an existing key
|
// a new key takes the state directory lock, so that reading an existing key
|
||||||
// works on a read-only state directory and keeps no other command waiting
|
// works on a read-only state directory and keeps no other command waiting
|
||||||
// at the passphrase prompt, and Encrypt reads its input and writes its
|
// at the passphrase prompt, and Encrypt streams its input and output
|
||||||
// output without the lock.
|
// unlocked.
|
||||||
func (cli *Instance) resolveEncryptionKey(
|
func (cli *Instance) resolveEncryptionKey(
|
||||||
vlt *vault.Vault, secretName string,
|
vlt *vault.Vault, secretName string,
|
||||||
) (*memguard.LockedBuffer, error) {
|
) (*memguard.LockedBuffer, error) {
|
||||||
@@ -189,17 +188,17 @@ func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
|
|||||||
input = file
|
input = file
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up output writer. With --output the result is held in memory
|
// Set up output writer
|
||||||
// and written only once encryption has succeeded, so a failure to
|
|
||||||
// encrypt leaves --output untouched and --input can be the same file.
|
|
||||||
// A failure while writing --output, such as a full disk, can leave it
|
|
||||||
// empty or partly written.
|
|
||||||
output := cli.cmd.OutOrStdout()
|
output := cli.cmd.OutOrStdout()
|
||||||
|
|
||||||
var result bytes.Buffer
|
|
||||||
|
|
||||||
if outputFile != "" {
|
if outputFile != "" {
|
||||||
output = &result
|
file, err := cli.fs.Create(outputFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create output file: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = file.Close() }()
|
||||||
|
|
||||||
|
output = file
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encrypt the data
|
// Encrypt the data
|
||||||
@@ -218,11 +217,7 @@ func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
|
|||||||
return fmt.Errorf("failed to finalize encryption: %w", err)
|
return fmt.Errorf("failed to finalize encryption: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if outputFile == "" {
|
return nil
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return cli.writeOutputFile(outputFile, result.Bytes())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt decrypts data using an age secret key stored in a secret
|
// Decrypt decrypts data using an age secret key stored in a secret
|
||||||
@@ -283,17 +278,17 @@ func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
|
|||||||
input = file
|
input = file
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up output writer. With --output the result is held in memory
|
// Set up output writer
|
||||||
// and written only once decryption has succeeded, so a failure to
|
|
||||||
// decrypt leaves --output untouched and --input can be the same file.
|
|
||||||
// A failure while writing --output, such as a full disk, can leave it
|
|
||||||
// empty or partly written.
|
|
||||||
output := cli.cmd.OutOrStdout()
|
output := cli.cmd.OutOrStdout()
|
||||||
|
|
||||||
var result bytes.Buffer
|
|
||||||
|
|
||||||
if outputFile != "" {
|
if outputFile != "" {
|
||||||
output = &result
|
file, err := cli.fs.Create(outputFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create output file: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = file.Close() }()
|
||||||
|
|
||||||
|
output = file
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt the data
|
// Decrypt the data
|
||||||
@@ -307,32 +302,6 @@ func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
|
|||||||
return fmt.Errorf("failed to decrypt data: %w", err)
|
return fmt.Errorf("failed to decrypt data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if outputFile == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return cli.writeOutputFile(outputFile, result.Bytes())
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeOutputFile creates outputFile, or empties it if it exists, and
|
|
||||||
// writes result into it.
|
|
||||||
func (cli *Instance) writeOutputFile(outputFile string, result []byte) error {
|
|
||||||
file, err := cli.fs.Create(outputFile)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create output file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = file.Write(result)
|
|
||||||
|
|
||||||
closeErr := file.Close()
|
|
||||||
if err == nil {
|
|
||||||
err = closeErr
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to write output file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,185 +0,0 @@
|
|||||||
//nolint:testpackage // sets the unexported fields of Instance
|
|
||||||
package cli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/secret/internal/vault"
|
|
||||||
)
|
|
||||||
|
|
||||||
// plainText is the content of plain.txt in the directory newCryptoTest makes.
|
|
||||||
const plainText = "document"
|
|
||||||
|
|
||||||
// newCryptoTest returns an Instance whose current vault, in a new state
|
|
||||||
// directory on the real filesystem, holds the age key secret "key", and a
|
|
||||||
// new directory holding only plain.txt and plain.txt.age, its encryption
|
|
||||||
// with that key.
|
|
||||||
func newCryptoTest(t *testing.T) (*Instance, string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
fs := afero.NewOsFs()
|
|
||||||
stateDir := t.TempDir()
|
|
||||||
|
|
||||||
_, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t), nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
|
||||||
cli.Mnemonic = testMnemonicBuffer(t)
|
|
||||||
cli.cmd = &cobra.Command{}
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
plain := filepath.Join(dir, "plain.txt")
|
|
||||||
require.NoError(t, afero.WriteFile(fs, plain, []byte(plainText), 0o600))
|
|
||||||
require.NoError(t, cli.Encrypt("key", plain, plain+".age"))
|
|
||||||
|
|
||||||
return cli, dir
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFailedCryptoLeavesOutputUnchanged is a regression test for
|
|
||||||
// https://git.eeqj.de/sneak/secret/issues/143, where secret encrypt and
|
|
||||||
// secret decrypt emptied the file named by --output before they failed.
|
|
||||||
// A failure to encrypt or decrypt must leave an existing file as it was and
|
|
||||||
// create none, and give the same error as without --output.
|
|
||||||
func TestFailedCryptoLeavesOutputUnchanged(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
run func(cli *Instance, dir, output string) error
|
|
||||||
}{
|
|
||||||
// Reading a directory fails once encryption has begun
|
|
||||||
{"encrypt", func(cli *Instance, dir, output string) error {
|
|
||||||
return cli.Encrypt("key", dir, output)
|
|
||||||
}},
|
|
||||||
// plain.txt is not an age file
|
|
||||||
{"decrypt", func(cli *Instance, dir, output string) error {
|
|
||||||
return cli.Decrypt("key", filepath.Join(dir, "plain.txt"), output)
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cli, dir := newCryptoTest(t)
|
|
||||||
existing := filepath.Join(dir, "existing.txt")
|
|
||||||
require.NoError(t, afero.WriteFile(cli.fs, existing, []byte("precious"), 0o600))
|
|
||||||
|
|
||||||
cli.cmd.SetOut(io.Discard)
|
|
||||||
toStdout := tt.run(cli, dir, "")
|
|
||||||
require.Error(t, toStdout)
|
|
||||||
|
|
||||||
require.EqualError(t, tt.run(cli, dir, existing), toStdout.Error())
|
|
||||||
require.EqualError(t, tt.run(cli, dir, filepath.Join(dir, "new.txt")),
|
|
||||||
toStdout.Error())
|
|
||||||
|
|
||||||
content, err := afero.ReadFile(cli.fs, existing)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, "precious", string(content))
|
|
||||||
|
|
||||||
exists, err := afero.Exists(cli.fs, filepath.Join(dir, "new.txt"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.False(t, exists, "a failed %s created --output", tt.name)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCryptoInPlace checks that secret encrypt and secret decrypt given
|
|
||||||
// one file as both --input and --output replace it with the right result.
|
|
||||||
// Before https://git.eeqj.de/sneak/secret/issues/143 they emptied the file
|
|
||||||
// before reading it.
|
|
||||||
func TestCryptoInPlace(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cli, dir := newCryptoTest(t)
|
|
||||||
file := filepath.Join(dir, "plain.txt")
|
|
||||||
|
|
||||||
require.NoError(t, cli.Encrypt("key", file, file))
|
|
||||||
require.NoError(t, cli.Decrypt("key", file, file))
|
|
||||||
|
|
||||||
content, err := afero.ReadFile(cli.fs, file)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, plainText, string(content))
|
|
||||||
}
|
|
||||||
|
|
||||||
// errCloseFailed is returned by the files closeFailFs creates in place of
|
|
||||||
// a successful Close, as NFS reports a full disk only when the file is
|
|
||||||
// closed.
|
|
||||||
var errCloseFailed = errors.New("no space left on device")
|
|
||||||
|
|
||||||
// closeFailFs creates files whose Close fails with errCloseFailed.
|
|
||||||
type closeFailFs struct {
|
|
||||||
afero.Fs
|
|
||||||
}
|
|
||||||
|
|
||||||
// closeFailFile is a file created through closeFailFs.
|
|
||||||
type closeFailFile struct {
|
|
||||||
afero.File
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f closeFailFile) Close() error {
|
|
||||||
_ = f.File.Close()
|
|
||||||
|
|
||||||
return errCloseFailed
|
|
||||||
}
|
|
||||||
|
|
||||||
//nolint:ireturn // afero.File is the interface required by afero.Fs
|
|
||||||
func (f closeFailFs) Create(name string) (afero.File, error) {
|
|
||||||
file, err := f.Fs.Create(name)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return closeFailFile{File: file}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDecryptFailsWhenClosingOutputFails checks that secret decrypt fails
|
|
||||||
// when closing --output fails, since some filesystems report a failed
|
|
||||||
// write only then.
|
|
||||||
func TestDecryptFailsWhenClosingOutputFails(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cli, dir := newCryptoTest(t)
|
|
||||||
cli.fs = closeFailFs{Fs: cli.fs}
|
|
||||||
|
|
||||||
err := cli.Decrypt("key", filepath.Join(dir, "plain.txt.age"),
|
|
||||||
filepath.Join(dir, "out.txt"))
|
|
||||||
require.ErrorIs(t, err, errCloseFailed)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDecryptToDescriptorOfDeletedFile checks that secret decrypt given
|
|
||||||
// /dev/fd/N as --output writes through that descriptor. A program may give
|
|
||||||
// the command a temporary file it has already deleted as stdout, pass
|
|
||||||
// /dev/stdout as --output and read the result back from that file.
|
|
||||||
func TestDecryptToDescriptorOfDeletedFile(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cli, dir := newCryptoTest(t)
|
|
||||||
|
|
||||||
file, err := os.CreateTemp(t.TempDir(), "out")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
defer func() { _ = file.Close() }()
|
|
||||||
|
|
||||||
require.NoError(t, os.Remove(file.Name()))
|
|
||||||
|
|
||||||
output := fmt.Sprintf("/dev/fd/%d", file.Fd())
|
|
||||||
require.NoError(t, cli.Decrypt("key", filepath.Join(dir, "plain.txt.age"), output))
|
|
||||||
|
|
||||||
// On macOS the command's open of /dev/fd/N shares this descriptor's
|
|
||||||
// position, which its write leaves at the end.
|
|
||||||
_, err = file.Seek(0, io.SeekStart)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
content, err := io.ReadAll(file)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, plainText, string(content))
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
package cli_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/secret/internal/cli"
|
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
|
||||||
"sneak.berlin/go/secret/internal/vault"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestListFailsWhenCurrentVaultDoesNotExist is a regression test for
|
|
||||||
// https://git.eeqj.de/sneak/secret/issues/156, where a currentvault file
|
|
||||||
// that was empty, held an invalid vault name, or named a vault that does
|
|
||||||
// not exist, made `secret list` report an empty vault. Listing must fail
|
|
||||||
// with an error that names the file and says to run `secret vault select`,
|
|
||||||
// `secret vault list` must still list the vaults, and selecting a vault
|
|
||||||
// must make listing work again. The name "." would make vaults.d itself the
|
|
||||||
// vault, and "../vaults.d/work" would reach the vault work by a path.
|
|
||||||
func TestListFailsWhenCurrentVaultDoesNotExist(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, name := range []string{"", ".", "../vaults.d/work", "gone"} {
|
|
||||||
t.Run(strconv.Quote(name), func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := newTwoVaultFs(t)
|
|
||||||
currentVaultPath := filepath.Join(testStateDir, "currentvault")
|
|
||||||
require.NoError(t, afero.WriteFile(fs, currentVaultPath,
|
|
||||||
[]byte(name), secret.FilePerms))
|
|
||||||
|
|
||||||
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
|
||||||
|
|
||||||
cmd := &cobra.Command{}
|
|
||||||
cmd.SetOut(&out)
|
|
||||||
|
|
||||||
err := c.ListSecrets(cmd, false, true, "")
|
|
||||||
require.ErrorIs(t, err, vault.ErrVaultNotFound)
|
|
||||||
require.ErrorContains(t, err, currentVaultPath)
|
|
||||||
require.ErrorContains(t, err, "secret vault select")
|
|
||||||
assert.Empty(t, out.String())
|
|
||||||
|
|
||||||
require.NoError(t, c.ListVaults(cmd, false))
|
|
||||||
assert.Equal(t, "Available vaults:\n default\n work\n", out.String())
|
|
||||||
|
|
||||||
require.NoError(t, c.SelectVault(cmd, "default"))
|
|
||||||
out.Reset()
|
|
||||||
require.NoError(t, c.ListSecrets(cmd, false, true, ""))
|
|
||||||
assert.Equal(t, "x\n", out.String())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
//nolint:testpackage // white-box test of unexported internals
|
|
||||||
package cli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/awnumar/memguard"
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/secret/internal/vault"
|
|
||||||
)
|
|
||||||
|
|
||||||
// errPermissionDenied is returned by renameFailFs and removeAllFailFs in
|
|
||||||
// place of a rename or removal that succeeds.
|
|
||||||
var errPermissionDenied = errors.New("permission denied")
|
|
||||||
|
|
||||||
// renameFailFs fails every Rename of one path, as a read-only parent
|
|
||||||
// directory does.
|
|
||||||
type renameFailFs struct {
|
|
||||||
afero.Fs
|
|
||||||
|
|
||||||
path string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *renameFailFs) Rename(oldname, newname string) error {
|
|
||||||
if oldname == f.path {
|
|
||||||
return errPermissionDenied
|
|
||||||
}
|
|
||||||
|
|
||||||
return f.Fs.Rename(oldname, newname)
|
|
||||||
}
|
|
||||||
|
|
||||||
// removeAllFailFs fails every RemoveAll of an entry of the directory dir,
|
|
||||||
// as a file in it that cannot be deleted does, and records that entry's
|
|
||||||
// path in failed.
|
|
||||||
type removeAllFailFs struct {
|
|
||||||
afero.Fs
|
|
||||||
|
|
||||||
dir string
|
|
||||||
failed string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *removeAllFailFs) RemoveAll(path string) error {
|
|
||||||
if filepath.Dir(path) == f.dir {
|
|
||||||
f.failed = path
|
|
||||||
|
|
||||||
return errPermissionDenied
|
|
||||||
}
|
|
||||||
|
|
||||||
return f.Fs.RemoveAll(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// createCrossVaults creates in fs the vaults "work" and "default", where
|
|
||||||
// "default" holds the secret "x" with the value "value".
|
|
||||||
func createCrossVaults(t *testing.T, fs afero.Fs) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
_, err := vault.CreateVault(fs, testStateDir, "work",
|
|
||||||
testMnemonicBuffer(t), nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
source, err := vault.CreateVault(fs, testStateDir, "default",
|
|
||||||
testMnemonicBuffer(t), nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = source.AddSecret("x",
|
|
||||||
memguard.NewBufferFromBytes([]byte("value")), false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// requireValue requires that the vault vaultName in fs holds secretName
|
|
||||||
// with the value createCrossVaults stored.
|
|
||||||
func requireValue(t *testing.T, fs afero.Fs, vaultName, secretName string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
vlt := vault.NewVault(fs, testStateDir, vaultName)
|
|
||||||
vlt.Mnemonic = testMnemonicBuffer(t)
|
|
||||||
|
|
||||||
value, err := vlt.GetSecret(secretName)
|
|
||||||
require.NoError(t, err, vaultName+":"+secretName)
|
|
||||||
|
|
||||||
defer value.Destroy()
|
|
||||||
|
|
||||||
require.Equal(t, []byte("value"), value.Bytes())
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCrossVaultMoveFailsWhenSourceRemains is a regression test for
|
|
||||||
// https://git.eeqj.de/sneak/secret/issues/159, where a move between two
|
|
||||||
// vaults whose source could not be removed reported the secret moved and
|
|
||||||
// succeeded. It must fail, and the copy and the source must both be there.
|
|
||||||
// The state directory lock refuses the wrapped filesystem, so the test
|
|
||||||
// calls moveSecret, which MoveSecret runs once it holds the lock.
|
|
||||||
func TestCrossVaultMoveFailsWhenSourceRemains(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
base := afero.NewMemMapFs()
|
|
||||||
createCrossVaults(t, base)
|
|
||||||
|
|
||||||
instance := NewCLIInstanceWithStateDir(&renameFailFs{
|
|
||||||
Fs: base,
|
|
||||||
path: testStateDir + "/vaults.d/default/secrets.d/x",
|
|
||||||
}, testStateDir)
|
|
||||||
instance.Mnemonic = testMnemonicBuffer(t)
|
|
||||||
|
|
||||||
err := instance.moveSecret(&cobra.Command{}, "default:x", "work:y", false)
|
|
||||||
require.ErrorIs(t, err, errPermissionDenied)
|
|
||||||
require.ErrorContains(t, err, "secret 'default:x' is copied with all "+
|
|
||||||
"1 version(s) to 'work:y', but the source remains")
|
|
||||||
|
|
||||||
requireValue(t, base, "default", "x")
|
|
||||||
requireValue(t, base, "work", "y")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCrossVaultMoveFailsWhenOldFilesRemain makes the removal of the source
|
|
||||||
// fail after the source has been moved out of its vault, when only the
|
|
||||||
// temporary directory it was moved into cannot be deleted. The move must
|
|
||||||
// fail, say that the secret is moved, and name that directory.
|
|
||||||
func TestCrossVaultMoveFailsWhenOldFilesRemain(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
base := afero.NewMemMapFs()
|
|
||||||
createCrossVaults(t, base)
|
|
||||||
|
|
||||||
sourceVaultDir := testStateDir + "/vaults.d/default"
|
|
||||||
fs := &removeAllFailFs{Fs: base, dir: sourceVaultDir}
|
|
||||||
|
|
||||||
instance := NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
||||||
instance.Mnemonic = testMnemonicBuffer(t)
|
|
||||||
|
|
||||||
err := instance.moveSecret(&cobra.Command{}, "default:x", "work:y", false)
|
|
||||||
require.ErrorIs(t, err, errPermissionDenied)
|
|
||||||
require.ErrorContains(t, err, "secret 'default:x' is moved with all "+
|
|
||||||
"1 version(s) to 'work:y', but its old files could not all be deleted")
|
|
||||||
|
|
||||||
// The error names the directory that holds the old files
|
|
||||||
require.NotEmpty(t, fs.failed)
|
|
||||||
require.ErrorContains(t, err, fs.failed)
|
|
||||||
|
|
||||||
oldFiles, err := afero.DirExists(base, filepath.Join(fs.failed, "x"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.True(t, oldFiles)
|
|
||||||
|
|
||||||
// The source vault no longer holds the secret
|
|
||||||
remains, err := afero.DirExists(base, sourceVaultDir+"/secrets.d/x")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.False(t, remains)
|
|
||||||
|
|
||||||
requireValue(t, base, "work", "y")
|
|
||||||
}
|
|
||||||
+6
-31
@@ -1138,9 +1138,12 @@ func (cli *Instance) moveSecretCrossVault(
|
|||||||
// Delete source secret
|
// Delete source secret
|
||||||
err = secret.RemoveDirAtomic(cli.fs, srcSecretDir)
|
err = secret.RemoveDirAtomic(cli.fs, srcSecretDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cli.sourceRemovalError(err, srcSecretDir,
|
// Copy succeeded but delete failed - warn but don't fail
|
||||||
srcVault.Name+":"+srcSecretName, destVault.Name+":"+destSecretName,
|
cmd.Printf("Warning: copied secret but failed to remove source: %v\n", err)
|
||||||
versionCount)
|
cmd.Printf("Moved secret '%s:%s' to '%s:%s' (%d version(s))\n",
|
||||||
|
srcVault.Name, srcSecretName, destVault.Name, destSecretName, versionCount)
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Printf("Moved secret '%s:%s' to '%s:%s' (%d version(s))\n",
|
cmd.Printf("Moved secret '%s:%s' to '%s:%s' (%d version(s))\n",
|
||||||
@@ -1148,31 +1151,3 @@ func (cli *Instance) moveSecretCrossVault(
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// sourceRemovalError returns the error of a move between vaults that
|
|
||||||
// copied all versionCount versions of the secret srcName to destName, and
|
|
||||||
// then failed with removeErr to remove srcSecretDir, the directory of
|
|
||||||
// srcName. The removal can fail after the source has left its vault, when
|
|
||||||
// only the temporary directory it was moved into cannot be deleted, so the
|
|
||||||
// error says the source remains only if srcSecretDir is still there.
|
|
||||||
func (cli *Instance) sourceRemovalError(
|
|
||||||
removeErr error, srcSecretDir, srcName, destName string, versionCount int,
|
|
||||||
) error {
|
|
||||||
remains, err := afero.DirExists(cli.fs, srcSecretDir)
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case err != nil:
|
|
||||||
return fmt.Errorf("secret '%s' is copied with all %d version(s) "+
|
|
||||||
"to '%s', but removing the source failed and whether it "+
|
|
||||||
"remains is unknown: %w",
|
|
||||||
srcName, versionCount, destName, errors.Join(removeErr, err))
|
|
||||||
case remains:
|
|
||||||
return fmt.Errorf("secret '%s' is copied with all %d version(s) "+
|
|
||||||
"to '%s', but the source remains: %w",
|
|
||||||
srcName, versionCount, destName, removeErr)
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("secret '%s' is moved with all %d version(s) "+
|
|
||||||
"to '%s', but its old files could not all be deleted: %w",
|
|
||||||
srcName, versionCount, destName, removeErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -36,11 +36,17 @@ func newSizeTestVault(t *testing.T) (afero.Fs, *vault.Vault) {
|
|||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
// Create vault, which selects it as the current vault
|
// Create vault
|
||||||
_, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
_, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
||||||
testMnemonicBuffer(t), nil)
|
testMnemonicBuffer(t), nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Set current vault
|
||||||
|
currentVaultPath := filepath.Join(testStateDir, "currentvault")
|
||||||
|
vaultPath := filepath.Join(testStateDir, "vaults.d", testVaultName)
|
||||||
|
err = afero.WriteFile(fs, currentVaultPath, []byte(vaultPath), 0o600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Get vault and set up long-term key
|
// Get vault and set up long-term key
|
||||||
vlt, err := vault.GetCurrentVault(fs, testStateDir)
|
vlt, err := vault.GetCurrentVault(fs, testStateDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -149,9 +149,7 @@ func WriteDir(fs afero.Fs, dir string, write func(dir string) error) error {
|
|||||||
|
|
||||||
// RemoveDirAtomic deletes the directory dir so that it disappears in one
|
// RemoveDirAtomic deletes the directory dir so that it disappears in one
|
||||||
// rename: dir is moved into a new directory from TempDirFor, which is then
|
// rename: dir is moved into a new directory from TempDirFor, which is then
|
||||||
// deleted. A crash part-way leaves only that temporary directory behind. If
|
// deleted. A crash part-way leaves only that temporary directory behind.
|
||||||
// only deleting the temporary directory fails, dir is already gone, and the
|
|
||||||
// error names the temporary directory, which holds what is left of it.
|
|
||||||
func RemoveDirAtomic(fs afero.Fs, dir string) error {
|
func RemoveDirAtomic(fs afero.Fs, dir string) error {
|
||||||
tmp, err := TempDirFor(fs, dir)
|
tmp, err := TempDirFor(fs, dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -167,8 +165,7 @@ func RemoveDirAtomic(fs afero.Fs, dir string) error {
|
|||||||
|
|
||||||
err = fs.RemoveAll(tmp)
|
err = fs.RemoveAll(tmp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to remove %s after moving %s into it: %w",
|
return fmt.Errorf("failed to remove %s: %w", dir, err)
|
||||||
tmp, dir, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -55,10 +55,7 @@ func ValidateVaultName(name string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ResolveVaultSymlink reads the currentvault file to get the path to the current vault
|
// ResolveVaultSymlink reads the currentvault file to get the path to the current vault
|
||||||
// The file contains just the vault name (e.g., "default"). An empty or
|
// The file contains just the vault name (e.g., "default")
|
||||||
// invalid name, or the name of a vault that does not exist, is an error
|
|
||||||
// wrapping ErrVaultNotFound that names the file and says to run
|
|
||||||
// 'secret vault select <name>'.
|
|
||||||
func ResolveVaultSymlink(fs afero.Fs, currentVaultPath string) (string, error) {
|
func ResolveVaultSymlink(fs afero.Fs, currentVaultPath string) (string, error) {
|
||||||
secret.Debug("resolveVaultSymlink starting", "path", currentVaultPath)
|
secret.Debug("resolveVaultSymlink starting", "path", currentVaultPath)
|
||||||
|
|
||||||
@@ -77,21 +74,6 @@ func ResolveVaultSymlink(fs afero.Fs, currentVaultPath string) (string, error) {
|
|||||||
stateDir := filepath.Dir(currentVaultPath)
|
stateDir := filepath.Dir(currentVaultPath)
|
||||||
absolutePath := filepath.Join(stateDir, "vaults.d", vaultName)
|
absolutePath := filepath.Join(stateDir, "vaults.d", vaultName)
|
||||||
|
|
||||||
// An empty name would make vaults.d itself the vault's directory
|
|
||||||
exists := false
|
|
||||||
if isValidVaultName(vaultName) {
|
|
||||||
exists, err = afero.DirExists(fs, absolutePath)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to check if vault exists: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !exists {
|
|
||||||
return "", fmt.Errorf("vault '%s' named in %s %w; run "+
|
|
||||||
"'secret vault select <name>' to select a vault",
|
|
||||||
vaultName, currentVaultPath, ErrVaultNotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
secret.Debug("Resolved to absolute path", "absolute_path", absolutePath)
|
secret.Debug("Resolved to absolute path", "absolute_path", absolutePath)
|
||||||
|
|
||||||
return absolutePath, nil
|
return absolutePath, nil
|
||||||
|
|||||||
Reference in New Issue
Block a user