3 Commits
Author SHA1 Message Date
sneak 110e3d5e69 Hold the encrypt and decrypt result in memory until it is written (closes #143)
check / check (push) Canceled after 0s
With --output, secret encrypt and secret decrypt emptied that file before
reading any input, so a failed command left it empty, and encrypting a file
onto itself gave the encryption of nothing.

With --output they now hold the result in memory and open --output, with the
same call as before, only once encryption or decryption has succeeded. A
failed command leaves --output untouched, and --input can be the same file.
Without --output the result still goes to stdout as it is produced.

Model: opus-5-5
2026-10-14 23:38:46 +00:00
clawbot 13c7721bad Refuse an existing vault before asking for the mnemonic (closes #151)
check / check (push) Canceled after 0s
`secret init` and `secret vault create` asked for the mnemonic and the
unlocker passphrase, and `secret init` printed "Initialized secrets
manager at", before refusing a vault that already exists. The check
`vault.CreateVault` made before writing is now `vault.CheckNewVaultName`,
which both commands call while holding the state directory lock, before
the first prompt; `CreateVault` still calls it. `secret init` prints the
"Initialized" line only once the vault is created, so
`TestMnemonicNotReadNamesOnlyMnemonic` no longer expects it.

Judgement call: the same check validates the name, so `secret vault
create` with an invalid name also fails before the prompts.

Model: opus-5-5
2026-10-15 01:32:23 +02:00
clawbot b8842b6184 secret get fails when it cannot write the value (closes #155)
check / check (push) Canceled after 0s
GetSecret and GetSecretWithVersion discarded the result of writing the
value to stdout, so a full disk or a failing stdout left the caller with
an empty or cut-short file and exit status 0. The write error is now
returned, so the command prints it and exits 1.

Deviation: a stdout the shell closed before `secret` started still exits
0, because the Go runtime opens /dev/null in its place and the write
there succeeds.

Model: opus-5-5
2026-10-15 01:32:14 +02:00
9 changed files with 301 additions and 49 deletions
+14 -3
View File
@@ -90,7 +90,8 @@ remove things pass `--force`.
#### `secret init`
Initializes the secret manager with a default vault. Prompts for a BIP39
mnemonic phrase and creates the initial directory structure.
mnemonic phrase and creates the initial directory structure. When the vault
`default` already exists, it fails before asking for the mnemonic or passphrase.
**Environment Variables:**
@@ -105,7 +106,8 @@ Lists all available vaults. The current vault is marked.
#### `secret vault create <name>`
Creates a new vault with the specified name.
Creates a new vault with the specified name. When a vault of that name already
exists, it fails before asking for the mnemonic or passphrase.
**Vault Name Format:** only lowercase ASCII letters, digits, `.`, `-` and `_`
are allowed, and a name must not be empty, `.` or `..`.
@@ -142,7 +144,13 @@ contain `//`, or have `..` as a path segment.
#### `secret get <secret-name> [--version <version>]`
Retrieves and outputs a secret value to stdout.
Retrieves and outputs a secret value to stdout. A failed write of the value,
such as to a file on a full disk, prints the error and exits 1, with two
exceptions. If the program reading from a pipe has already exited, as in
`secret get s | true`, the broken-pipe signal (SIGPIPE) ends `secret` with no
message and the shell reports exit status 141. If the shell closed stdout before
`secret` started (`secret get s >&-`), the value is written to `/dev/null` and
`secret get` exits 0.
- `--version, -v`: Get a specific version (default: current)
@@ -292,6 +300,9 @@ generates a new Age key.
Decrypts data using an Age key stored as a secret.
With `--output`, both hold the result in memory and write it to `--output` only
once they have succeeded, so a failed command leaves `--output` untouched.
## Storage Architecture
### Directory Structure
+42 -3
View File
@@ -222,12 +222,51 @@ func TestMnemonicNotReadNamesOnlyMnemonic(t *testing.T) {
output, err := cmd.CombinedOutput()
require.Error(t, err)
require.Equal(t, "Initialized secrets manager at: "+stateDir+"\n"+
"Error: failed to read mnemonic: there is no terminal to ask on. "+
"Please set the SB_SECRET_MNEMONIC environment variable or run "+
require.Equal(t, "Error: failed to read mnemonic: there is no terminal to "+
"ask on. Please set the SB_SECRET_MNEMONIC environment variable or run "+
"interactively\n", string(output))
}
// TestCreateExistingVaultAsksForNothing is a regression test for
// https://git.eeqj.de/sneak/secret/issues/151: `secret init` and `secret
// vault create` asked for the mnemonic and the unlocker passphrase before
// refusing a vault that already exists, and `secret init` printed
// "Initialized secrets manager at" first. Each runs here as the built binary,
// in a new session, which has no terminal, and with neither
// SB_SECRET_MNEMONIC nor SB_UNLOCK_PASSPHRASE set, so asking for either
// fails. Each must fail saying only that the vault exists.
func TestCreateExistingVaultAsksForNothing(t *testing.T) {
t.Parallel()
stateDir := t.TempDir()
_, err := vault.CreateVault(afero.NewOsFs(), stateDir, "default", nil, nil)
require.NoError(t, err)
// run runs the built binary with args, which must fail, and returns what
// it printed
run := func(args ...string) string {
//nolint:gosec // G204: test executes the freshly built secret binary
cmd := exec.CommandContext(t.Context(), secretBinaryPath(t), args...)
cmd.Env = []string{
secret.EnvStateDir + "=" + stateDir,
"PATH=" + os.Getenv("PATH"),
"HOME=" + os.Getenv("HOME"),
}
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
output, err := cmd.CombinedOutput()
require.Error(t, err, "secret %v", args)
return string(output)
}
require.Equal(t,
"Error: failed to create default vault: vault default already exists\n",
run("init"))
require.Equal(t, "Error: vault default already exists\n",
run("vault", "create", "default"))
}
// TestStopDuringCreateLeavesWholeVaultOrNone is a regression test for
// https://git.eeqj.de/sneak/secret/issues/105: `secret init` or `secret vault
// create` killed after the passphrase prompt but before the unlocker was
+41 -17
View File
@@ -1,6 +1,7 @@
package cli
import (
"bytes"
"errors"
"fmt"
"io"
@@ -188,17 +189,15 @@ func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
input = file
}
// Set up output writer
// Set up output writer. With --output the result is held in memory
// and written only once encryption has succeeded, so a failure leaves
// --output untouched and --input can be the same file.
output := cli.cmd.OutOrStdout()
if outputFile != "" {
file, err := cli.fs.Create(outputFile)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
defer func() { _ = file.Close() }()
var result bytes.Buffer
output = file
if outputFile != "" {
output = &result
}
// Encrypt the data
@@ -217,7 +216,11 @@ func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
return fmt.Errorf("failed to finalize encryption: %w", err)
}
return nil
if outputFile == "" {
return nil
}
return cli.writeOutputFile(outputFile, &result)
}
// Decrypt decrypts data using an age secret key stored in a secret
@@ -278,17 +281,15 @@ func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
input = file
}
// Set up output writer
// Set up output writer. With --output the result is held in memory
// and written only once decryption has succeeded, so a failure leaves
// --output untouched and --input can be the same file.
output := cli.cmd.OutOrStdout()
if outputFile != "" {
file, err := cli.fs.Create(outputFile)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
defer func() { _ = file.Close() }()
var result bytes.Buffer
output = file
if outputFile != "" {
output = &result
}
// Decrypt the data
@@ -302,6 +303,29 @@ func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
return fmt.Errorf("failed to decrypt data: %w", err)
}
if outputFile == "" {
return nil
}
return cli.writeOutputFile(outputFile, &result)
}
// writeOutputFile creates outputFile, or empties it if it exists, and
// writes result into it.
func (cli *Instance) writeOutputFile(
outputFile string, result *bytes.Buffer,
) error {
file, err := cli.fs.Create(outputFile)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
defer func() { _ = file.Close() }()
_, err = result.WriteTo(file)
if err != nil {
return fmt.Errorf("failed to write output file: %w", err)
}
return nil
}
+108
View File
@@ -0,0 +1,108 @@
//nolint:testpackage // sets the unexported fields of Instance
package cli
import (
"io"
"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 failed command 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))
}
+9 -3
View File
@@ -17,6 +17,9 @@ import (
// errPassphraseMismatch is returned when passphrase confirmation fails
var errPassphraseMismatch = errors.New("passphrases do not match")
// defaultVaultName is the name of the vault `secret init` creates
const defaultVaultName = "default"
// NewInitCmd creates the init command
func NewInitCmd() *cobra.Command {
return &cobra.Command{
@@ -92,8 +95,10 @@ func (cli *Instance) initialize(cmd *cobra.Command) error {
return fmt.Errorf("failed to create state directory: %w", err)
}
if cmd != nil {
cmd.Printf("Initialized secrets manager at: %s\n", stateDir)
// Refuse an existing default vault before asking for anything
err = vault.CheckNewVaultName(cli.fs, cli.stateDir, defaultVaultName)
if err != nil {
return fmt.Errorf("failed to create default vault: %w", err)
}
// Prompt for mnemonic
@@ -133,7 +138,7 @@ func (cli *Instance) initialize(cmd *cobra.Command) error {
// Create the default vault with its passphrase unlocker
secret.Debug("Creating default vault")
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, "default",
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, defaultVaultName,
mnemonic, passphraseBuffer)
if err != nil {
secret.Debug("Failed to create default vault", "error", err)
@@ -152,6 +157,7 @@ func (cli *Instance) initialize(cmd *cobra.Command) error {
}
if cmd != nil {
cmd.Printf("Initialized secrets manager at: %s\n", stateDir)
cmd.Printf("\nDefault vault created and configured\n")
cmd.Printf("Long-term public key: %s\n", ltIdentity.Recipient().String())
cmd.Printf("Unlocker ID: %s\n", unlocker.GetID())
+8 -2
View File
@@ -443,7 +443,10 @@ func (cli *Instance) GetSecret(cmd *cobra.Command, secretName string) error {
defer value.Destroy()
// Write the value straight from locked memory, with no trailing newline
_, _ = cmd.OutOrStdout().Write(value.Bytes())
_, err = cmd.OutOrStdout().Write(value.Bytes())
if err != nil {
return fmt.Errorf("failed to write secret value: %w", err)
}
return nil
}
@@ -478,7 +481,10 @@ func (cli *Instance) GetSecretWithVersion(
secret.Debug("Got secret value", "valueLength", value.Size())
// Write the value straight from locked memory, with no trailing newline
_, _ = cmd.OutOrStdout().Write(value.Bytes())
_, err = cmd.OutOrStdout().Write(value.Bytes())
if err != nil {
return fmt.Errorf("failed to write secret value: %w", err)
}
secret.Debug("Printed value to stdout")
+6
View File
@@ -267,6 +267,12 @@ func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
}
defer release()
// Refuse an invalid name or an existing vault before asking for anything
err = vault.CheckNewVaultName(cli.fs, cli.stateDir, name)
if err != nil {
return err
}
mnemonic, cleanupMnemonic, err := cli.promptMnemonic()
if err != nil {
return err
+41
View File
@@ -8,6 +8,7 @@
// - TestPromoteNonExistentVersion: Tests error handling for invalid promotion
// - TestGetSecretWithVersion: Tests `secret get --version` flag functionality
// - TestGetSecretWritesBinaryValue: Tests `secret get` output of binary values
// - TestGetSecretReturnsWriteError: Tests `secret get` when stdout fails
// - TestVersionCommandStructure: Tests command structure and help text
// - TestListVersionsEmptyOutput: Tests edge case with no versions
//
@@ -20,6 +21,7 @@ package cli
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
@@ -360,6 +362,45 @@ func TestGetSecretWritesBinaryValue(t *testing.T) {
assert.Equal(t, value, buf.Bytes())
}
// TestGetSecretReturnsWriteError checks that `secret get`, with and without
// a version, returns the error of a failed write of the value.
func TestGetSecretReturnsWriteError(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
cli := NewCLIInstanceWithStateDir(fs, testStateDir)
cli.Mnemonic = testMnemonicBuffer(t)
setupTestVault(t, fs)
vlt, err := vault.GetCurrentVault(fs, testStateDir)
require.NoError(t, err)
addTestSecret(t, vlt, []byte("value"), false)
vaultDir, err := vlt.GetDirectory()
require.NoError(t, err)
versions, err := secret.ListVersions(fs,
filepath.Join(vaultDir, "secrets.d", "test%secret"))
require.NoError(t, err)
require.Len(t, versions, 1)
// Stdout is a file closed before the value is written to it
out, err := os.Create(filepath.Join(t.TempDir(), "stdout"))
require.NoError(t, err)
require.NoError(t, out.Close())
cmd := newRootCmd()
cmd.SetOut(out)
err = cli.GetSecret(cmd, "test/secret")
require.ErrorIs(t, err, os.ErrClosed)
err = cli.GetSecretWithVersion(cmd, "test/secret", versions[0])
require.ErrorIs(t, err, os.ErrClosed)
}
//nolint:paralleltest // reads process environment to determine the state dir
func TestVersionCommandStructure(t *testing.T) {
// Test that version commands are properly structured
+32 -21
View File
@@ -212,16 +212,40 @@ func processMnemonicForVault(
return ltIdentity, derivationIndex, publicKeyHash, familyHash, nil
}
// CheckNewVaultName returns an error when name cannot be the name of a new
// vault: one wrapping ErrInvalidVaultName when it is not a valid vault name,
// and one wrapping ErrVaultExists when that vault already exists. Creating an
// existing vault again would replace its keys, and its secrets could no
// longer be decrypted. CreateVault checks this before writing anything; the
// commands that create a vault also check it before they ask for the
// mnemonic or passphrase, holding the state directory lock.
func CheckNewVaultName(fs afero.Fs, stateDir string, name string) error {
err := ValidateVaultName(name)
if err != nil {
return err
}
exists, err := afero.DirExists(fs, filepath.Join(stateDir, "vaults.d", name))
if err != nil {
return fmt.Errorf("failed to check if vault exists: %w", err)
}
if exists {
return fmt.Errorf("vault %s %w", name, ErrVaultExists)
}
return nil
}
// CreateVault creates a new vault and selects it as the current vault. When
// mnemonic is not nil, the vault's long-term key is derived from it, and the
// returned vault has it as its Mnemonic; when it is nil, the vault has no
// long-term key until one is imported. When passphrase is not nil, the vault
// gets a passphrase unlocker protected by it, as its current unlocker; that
// needs a mnemonic. It refuses a vault that already exists before writing
// anything: creating it again would replace its keys, and its secrets could
// no longer be decrypted. The commands that call it hold the state directory
// lock, so no other command can create the vault between the check and the
// writes.
// needs a mnemonic. It refuses an invalid name or a vault that already exists,
// as CheckNewVaultName does, before writing anything. The commands that call
// it hold the state directory lock, so no other command can create the vault
// between the check and the writes.
//
// The vault is written whole into a temporary directory, which is renamed
// into vaults.d only once complete, and only then selected: a crash at any
@@ -233,30 +257,17 @@ func CreateVault(
) (*Vault, error) {
secret.Debug("Creating new vault", "name", name, "state_dir", stateDir)
err := ValidateVaultName(name)
err := CheckNewVaultName(fs, stateDir, name)
if err != nil {
secret.Debug("Invalid vault name provided", "vault_name", name)
return nil, err
}
secret.Debug("Vault name validation passed", "vault_name", name)
vaultDir := filepath.Join(stateDir, "vaults.d", name)
exists, err := afero.DirExists(fs, vaultDir)
if err != nil {
return nil, fmt.Errorf("failed to check if vault exists: %w", err)
}
if exists {
return nil, fmt.Errorf("vault %s %w", name, ErrVaultExists)
}
if passphrase != nil && mnemonic == nil {
return nil, fmt.Errorf("vault %s %w", name, ErrUnlockerWithoutMnemonic)
}
vaultDir := filepath.Join(stateDir, "vaults.d", name)
secret.Debug("Creating vault directory structure", "vault_dir", vaultDir)
err = secret.WriteDir(fs, vaultDir, func(dir string) error {