Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b467b6822a |
@@ -154,8 +154,7 @@ The `LAST UPDATED` column, and `updated_at` in the `--json` output, give the
|
||||
time the secret's `current` file was last written: every command that adds a
|
||||
version replaces it, and so does `secret version promote`. `created_at` gives
|
||||
the time the `value.age` of the secret's oldest version was written. Both are
|
||||
the files' modification times, so listing asks for no passphrase. Moving a
|
||||
secret to another vault keeps both times.
|
||||
the files' modification times, so listing asks for no passphrase.
|
||||
|
||||
#### `secret remove <secret-name> [--force]` / `secret rm` ⚠️ 🛑
|
||||
|
||||
@@ -188,9 +187,7 @@ period.
|
||||
#### `secret version promote <secret-name> <version>`
|
||||
|
||||
Promotes a specific version to current by rewriting the secret's `current` file
|
||||
to name it. The versions' own times are left unchanged, allowing for rollback
|
||||
scenarios; `secret list` then gives the time of the promote as the secret's last
|
||||
update.
|
||||
to name it. Does not modify any timestamps, allowing for rollback scenarios.
|
||||
|
||||
#### `secret version remove <secret-name> <version> [--force]` / `secret version rm` ⚠️ 🛑
|
||||
|
||||
@@ -206,8 +203,7 @@ for confirmation, naming the version, the secret and its vault (see
|
||||
|
||||
#### `secret generate mnemonic`
|
||||
|
||||
Generates a cryptographically secure BIP39 mnemonic phrase and prints it to
|
||||
stdout. The warning to keep it safe goes to stderr.
|
||||
Generates a cryptographically secure BIP39 mnemonic phrase.
|
||||
|
||||
#### `secret generate secret <name> [--length=16] [--type=base58] [--force]`
|
||||
|
||||
|
||||
@@ -26,17 +26,7 @@ https://git.eeqj.de/sneak/secret/milestone/12
|
||||
and the creation is when its oldest version's `value.age` was written. That
|
||||
version's directory would be wrong, because the next add rewrites the
|
||||
version's `metadata.age` in it. `GetSecretObject`, which `secret get` also
|
||||
uses, no longer loads the times; `secret list` loads them itself. A move to
|
||||
another vault gives each copied `value.age` and `current` the modification
|
||||
time of the file it was copied from, so the moved secret keeps its times.
|
||||
- 2026-10-14: `secret generate mnemonic`, `secret vault list` and
|
||||
`secret unlocker list` print their output to stdout, as `secret list` does, so
|
||||
it can be piped or captured (https://git.eeqj.de/sneak/secret/issues/138).
|
||||
They printed with cobra's `cmd.Println` and `cmd.Printf`, which write to
|
||||
stderr when no output writer is set, so `secret vault list --json | jq`
|
||||
received nothing. "No versions found" from `secret version list` moved to
|
||||
stdout too. Messages such as "Created vault", and the warning after the
|
||||
mnemonic, stay on stderr.
|
||||
uses, no longer loads the times; `secret list` loads them itself.
|
||||
- 2026-10-14: Removing the current unlocker no longer leaves the vault unable to
|
||||
open through the unlockers it still has
|
||||
(https://git.eeqj.de/sneak/secret/issues/137). `current-unlocker` went on
|
||||
|
||||
@@ -103,7 +103,7 @@ func (cli *Instance) GenerateMnemonic(cmd *cobra.Command) error {
|
||||
}
|
||||
|
||||
// Output mnemonic to stdout
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), mnemonic)
|
||||
cmd.Println(mnemonic)
|
||||
|
||||
// Output helpful information to stderr
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
|
||||
@@ -82,49 +82,6 @@ func TestListShowsWhenSecretWasWritten(t *testing.T) {
|
||||
updated.UpdatedAt.Format("2006-01-02 15:04"))
|
||||
}
|
||||
|
||||
// TestMovedSecretKeepsItsTimes checks that a secret moved to another vault
|
||||
// is listed there with the times it was listed with before the move, and
|
||||
// not with the time of the move.
|
||||
func TestMovedSecretKeepsItsTimes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
stateDir := t.TempDir()
|
||||
|
||||
_, err := vault.CreateVault(fs, stateDir, "other", testMnemonicBuffer(t), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// "default" is created last, so it is the current vault
|
||||
vlt, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
vlt.Mnemonic = testMnemonicBuffer(t)
|
||||
|
||||
// Two versions written apart, so that the creation and the last update
|
||||
// are different times
|
||||
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("one")), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
time.Sleep(listClockStep)
|
||||
|
||||
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("two")), true)
|
||||
require.NoError(t, err)
|
||||
|
||||
c := cli.NewCLIInstanceWithStateDir(fs, stateDir)
|
||||
c.Mnemonic = testMnemonicBuffer(t)
|
||||
beforeMove := listedTimes(t, listSecrets(t, c, true))
|
||||
|
||||
time.Sleep(listClockStep)
|
||||
require.NoError(t, c.MoveSecret(&cobra.Command{}, "default:x", "other", false))
|
||||
require.NoError(t, vault.SelectVault(fs, stateDir, "other"))
|
||||
|
||||
afterMove := listedTimes(t, listSecrets(t, c, true))
|
||||
assert.True(t, beforeMove.CreatedAt.Equal(afterMove.CreatedAt),
|
||||
"created %s, then %s", beforeMove.CreatedAt, afterMove.CreatedAt)
|
||||
assert.True(t, beforeMove.UpdatedAt.Equal(afterMove.UpdatedAt),
|
||||
"updated %s, then %s", beforeMove.UpdatedAt, afterMove.UpdatedAt)
|
||||
}
|
||||
|
||||
// listSecrets returns the output of `secret list`, or of
|
||||
// `secret list --json` when jsonOutput is set.
|
||||
func listSecrets(t *testing.T, c *cli.Instance, jsonOutput bool) string {
|
||||
|
||||
@@ -2,7 +2,6 @@ package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -10,7 +9,6 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/bip39"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
)
|
||||
|
||||
@@ -92,66 +90,3 @@ func TestGetCommandOutputsToStdout(t *testing.T) {
|
||||
// Nothing should be in stderr
|
||||
assert.Empty(t, stderr.String(), "stderr should be empty")
|
||||
}
|
||||
|
||||
// TestMnemonicAndListingsOutputToStdout tests that 'secret generate
|
||||
// mnemonic', 'secret vault list' and 'secret unlocker list' print their
|
||||
// output to stdout, so that it can be piped
|
||||
func TestMnemonicAndListingsOutputToStdout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
secretPath := secretBinaryPath(t)
|
||||
|
||||
// run runs the secret binary and returns what it wrote to stdout and to
|
||||
// stderr
|
||||
run := func(args ...string) (string, string) {
|
||||
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||
cmd := exec.CommandContext(t.Context(), secretPath, args...)
|
||||
cmd.Env = []string{
|
||||
secret.EnvStateDir + "=" + tempDir,
|
||||
secret.EnvMnemonic + "=" + testMnemonic,
|
||||
secret.EnvUnlockPassphrase + "=test-passphrase",
|
||||
"PATH=/usr/bin:/bin",
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
require.NoError(t, err, "%v should succeed: %s", args, stderr.String())
|
||||
|
||||
return stdout.String(), stderr.String()
|
||||
}
|
||||
|
||||
stdout, stderr := run("generate", "mnemonic")
|
||||
assert.True(t, bip39.IsMnemonicValid(strings.TrimSpace(stdout)),
|
||||
"stdout should hold the mnemonic, not %q", stdout)
|
||||
assert.Contains(t, stderr, "Save this mnemonic phrase securely",
|
||||
"the warning should be on stderr")
|
||||
|
||||
run("init")
|
||||
|
||||
stdout, _ = run("vault", "list", "--json")
|
||||
|
||||
var vaults map[string]any
|
||||
|
||||
require.NoError(t, json.Unmarshal([]byte(stdout), &vaults),
|
||||
"stdout should hold the vaults as JSON, not %q", stdout)
|
||||
assert.Equal(t, "default", vaults["currentVault"])
|
||||
|
||||
stdout, _ = run("unlocker", "list", "--json")
|
||||
|
||||
var unlockers map[string]any
|
||||
|
||||
require.NoError(t, json.Unmarshal([]byte(stdout), &unlockers),
|
||||
"stdout should hold the unlockers as JSON, not %q", stdout)
|
||||
assert.Len(t, unlockers["unlockers"], 1)
|
||||
|
||||
stdout, _ = run("vault", "list")
|
||||
assert.Contains(t, stdout, "default (current)")
|
||||
|
||||
stdout, _ = run("unlocker", "list")
|
||||
assert.Contains(t, stdout, "Total: 1 unlocker(s)")
|
||||
}
|
||||
|
||||
@@ -326,25 +326,22 @@ func (cli *Instance) printUnlockersJSON(
|
||||
return fmt.Errorf("failed to marshal JSON: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(cli.cmd.OutOrStdout(), string(jsonBytes))
|
||||
cli.cmd.Println(string(jsonBytes))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// printUnlockersTable prints unlockers in a formatted table
|
||||
func (cli *Instance) printUnlockersTable(unlockers []UnlockerInfo) error {
|
||||
out := cli.cmd.OutOrStdout()
|
||||
|
||||
if len(unlockers) == 0 {
|
||||
_, _ = fmt.Fprintln(out, "No unlockers found in current vault.")
|
||||
_, _ = fmt.Fprintln(out, "Run 'secret unlocker add passphrase' to create one.")
|
||||
cli.cmd.Println("No unlockers found in current vault.")
|
||||
cli.cmd.Println("Run 'secret unlocker add passphrase' to create one.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(out, " %-40s %-12s %-20s %s\n",
|
||||
"UNLOCKER ID", "TYPE", "CREATED", "FLAGS")
|
||||
_, _ = fmt.Fprintf(out, " %-40s %-12s %-20s %s\n",
|
||||
cli.cmd.Printf(" %-40s %-12s %-20s %s\n", "UNLOCKER ID", "TYPE", "CREATED", "FLAGS")
|
||||
cli.cmd.Printf(" %-40s %-12s %-20s %s\n",
|
||||
strings.Repeat("-", unlockerIDWidth), strings.Repeat("-", unlockerTypeWidth),
|
||||
strings.Repeat("-", unlockerDateWidth), strings.Repeat("-", unlockerFlagsWidth))
|
||||
|
||||
@@ -359,7 +356,7 @@ func (cli *Instance) printUnlockersTable(unlockers []UnlockerInfo) error {
|
||||
prefix = "* "
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(out, "%s%-40s %-12s %-20s %s\n",
|
||||
cli.cmd.Printf("%s%-40s %-12s %-20s %s\n",
|
||||
prefix,
|
||||
unlocker.ID,
|
||||
unlocker.Type,
|
||||
@@ -367,7 +364,7 @@ func (cli *Instance) printUnlockersTable(unlockers []UnlockerInfo) error {
|
||||
flags)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(out, "\nTotal: %d unlocker(s)\n", len(unlockers))
|
||||
cli.cmd.Printf("\nTotal: %d unlocker(s)\n", len(unlockers))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -186,8 +186,6 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
|
||||
return err
|
||||
}
|
||||
|
||||
out := cmd.OutOrStdout()
|
||||
|
||||
if jsonOutput { //nolint:nestif // Separate JSON and text output formatting logic
|
||||
// Get current vault name for context
|
||||
currentVault := ""
|
||||
@@ -207,13 +205,13 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(out, string(jsonBytes))
|
||||
cmd.Println(string(jsonBytes))
|
||||
} else {
|
||||
// Text output
|
||||
_, _ = fmt.Fprintln(out, "Available vaults:")
|
||||
cmd.Println("Available vaults:")
|
||||
|
||||
if len(vaults) == 0 {
|
||||
_, _ = fmt.Fprintln(out, " (none)")
|
||||
cmd.Println(" (none)")
|
||||
} else {
|
||||
// Try to get current vault for marking
|
||||
currentVault := ""
|
||||
@@ -225,9 +223,9 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
|
||||
|
||||
for _, vaultName := range vaults {
|
||||
if vaultName == currentVault {
|
||||
_, _ = fmt.Fprintf(out, " %s (current)\n", vaultName)
|
||||
cmd.Printf(" %s (current)\n", vaultName)
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(out, " %s\n", vaultName)
|
||||
cmd.Printf(" %s\n", vaultName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,9 +65,7 @@ func VersionCommands(cli *Instance) *cobra.Command {
|
||||
Use: "promote <secret-name> <version>",
|
||||
Short: "Promote a specific version to current",
|
||||
Long: "Updates the current symlink to point to the specified " +
|
||||
"version. The versions' own times are left unchanged; secret " +
|
||||
"list then gives the time of the promote as the secret's last " +
|
||||
"update.",
|
||||
"version without modifying timestamps",
|
||||
Args: cobra.ExactArgs(2), //nolint:mnd // secret-name and version args
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command, args []string, toComplete string,
|
||||
@@ -169,7 +167,7 @@ func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
|
||||
}
|
||||
|
||||
if len(versions) == 0 {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "No versions found")
|
||||
cmd.Println("No versions found")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -573,8 +573,8 @@ func (v *Vault) CopySecretAllVersions(
|
||||
}
|
||||
|
||||
// Copy each version and the current pointer, then move the copy into place
|
||||
err = v.copyVersions(srcVault, srcIdentity, srcSecretName, srcSecretDir,
|
||||
destSecretName, destSecretDir, versions, currentVersion)
|
||||
err = v.copyVersions(srcVault, srcIdentity,
|
||||
srcSecretName, destSecretName, destSecretDir, versions, currentVersion)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -793,13 +793,10 @@ func (v *Vault) createAndSaveVersion(
|
||||
// pointer into a temporary directory, then moves that directory to
|
||||
// destSecretDir, replacing a secret already there. Nothing in this vault
|
||||
// changes until the copy is complete, so an interrupted copy leaves only a
|
||||
// temporary directory behind. Each copied value.age and current file gets
|
||||
// the modification time of the file it was copied from, so `secret list`,
|
||||
// which reads a secret's times from them, lists the copy with the times of
|
||||
// the source.
|
||||
// temporary directory behind.
|
||||
func (v *Vault) copyVersions(
|
||||
srcVault *Vault, srcIdentity *age.X25519Identity,
|
||||
srcSecretName, srcSecretDir, destSecretName, destSecretDir string,
|
||||
srcSecretName, destSecretName, destSecretDir string,
|
||||
versions []string, currentVersion string,
|
||||
) error {
|
||||
buildDir, err := secret.TempDirFor(v.fs, destSecretDir)
|
||||
@@ -818,14 +815,6 @@ func (v *Vault) copyVersions(
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to copy version %s: %w", versionName, err)
|
||||
}
|
||||
|
||||
valuePath := filepath.Join("versions", versionName, "value.age")
|
||||
|
||||
err = copyModTime(srcVault.fs, filepath.Join(srcSecretDir, valuePath),
|
||||
v.fs, filepath.Join(buildDir, valuePath))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = secret.SetCurrentVersion(v.fs, buildDir, currentVersion)
|
||||
@@ -833,12 +822,6 @@ func (v *Vault) copyVersions(
|
||||
return fmt.Errorf("failed to set current version: %w", err)
|
||||
}
|
||||
|
||||
err = copyModTime(srcVault.fs, filepath.Join(srcSecretDir, "current"),
|
||||
v.fs, filepath.Join(buildDir, "current"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// With --force, the secret being replaced goes only now that its
|
||||
// replacement is complete
|
||||
exists, err := afero.DirExists(v.fs, destSecretDir)
|
||||
@@ -863,26 +846,6 @@ func (v *Vault) copyVersions(
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyModTime gives destPath in destFs the modification time of srcPath in
|
||||
// srcFs.
|
||||
func copyModTime(
|
||||
srcFs afero.Fs, srcPath string, destFs afero.Fs, destPath string,
|
||||
) error {
|
||||
info, err := srcFs.Stat(srcPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read modification time of %s: %w",
|
||||
srcPath, err)
|
||||
}
|
||||
|
||||
err = destFs.Chtimes(destPath, info.ModTime(), info.ModTime())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set modification time of %s: %w",
|
||||
destPath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkCopyDestination refuses to copy over an existing secret unless force
|
||||
// is set. A secret being replaced is removed by copyVersions, once its
|
||||
// replacement is complete.
|
||||
|
||||
Reference in New Issue
Block a user