1 Commits
Author SHA1 Message Date
clawbot b467b6822a secret list gives each secret's own creation and last-update times (closes #142)
check / check (push) Waiting to run
secret list and secret list --json gave the time of the listing as every
secret's creation and last-update time, because Secret.LoadMetadata set
both to the current time. It now reads file modification times, so
listing still needs no passphrase: the last update is when the secret's
current file was written, and the creation is when its oldest version's
value.age was written.

GetSecretObject no longer loads the times, so secret get reads no more
files than before and keeps its error for a missing current file;
secret list calls LoadMetadata itself.

Judgement call: the creation time comes from value.age, not from the
oldest version's directory, whose time changes when the next add
rewrites that version's metadata.age.

Model: opus-5-5
2026-10-14 20:07:05 +00:00
9 changed files with 23 additions and 189 deletions
+3 -7
View File
@@ -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]`
+1 -11
View File
@@ -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
+1 -1
View File
@@ -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, "")
-43
View File
@@ -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 {
-65
View File
@@ -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)")
}
+7 -10
View File
@@ -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
}
+5 -7
View File
@@ -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)
}
}
}
+2 -4
View File
@@ -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
}
+4 -41
View File
@@ -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.