Compare commits

1 Commits

Author SHA1 Message Date
41cea400a7 Update golangci-lint to v2.12.2 with canonical config (#29)
All checks were successful
check / check (push) Successful in 43s
Bumps golangci-lint from v2.1.6 (digest-only pin in the `Dockerfile` lint stage) to v2.12.2, pinned by tag and digest (Debian-based image).

Replaces `.golangci.yml` with the canonical strict config: all linters enabled except the standard disable list (`exhaustruct`, `depguard`, `godot`, `wsl`, `wrapcheck`, `varnamelen`), `lll` at 88, `funlen` 80/50, `cyclop` 15, `dupl` 100, and test files are now linted (the old config had `tests: false`, an enable-only list of ~20 linters, `lll` 120, and a blanket exclusion of `internal/macse`).

The stricter config surfaced ~1550 findings, all fixed:

- `wsl_v5` (439) / `nlreturn` (24): blank-line insertions
- `lll` (309): line wrapping at 88 columns; long literals split with `+` concatenation, values unchanged
- `noinlineerr` (130): `if err := ...` split into assignment plus check
- `paralleltest` (116): `t.Parallel()` added to tests without shared state; reasoned `//nolint` where `t.Setenv` or shared fixtures forbid it
- `err113` (97): package-level sentinel errors (new `internal/vault/errors.go`), `%w` wrapping, `errors.Is`
- `perfsprint` (74) / `modernize` (39) / `intrange`: `strconv`, `errors.New`, `slices.Contains`, `any`, `SplitSeq`
- `goconst` (40) / `dupword` (41) / `testifylint` (42) / `thelper` (33): constants, assertion fixes, `t.Helper()`
- `noctx` (22): `exec.CommandContext` for gpg/CLI invocations
- `testpackage` (18): black-box tests moved to `_test` packages where they use only exported identifiers; white-box files carry a reasoned `//nolint`
- `funlen`/`cyclop`/`gocognit`/`nestif`/`dupl`: behavior-preserving helper extraction
- assorted singletons: `gosec`, `gosmopolitan`, `funcorder`, `nonamedreturns`, `makezero`, `prealloc`, `godox`, `nolintlint`, `ireturn`, `nilnil`, `gochecknoinits`

## User-visible strings

**None changed.** Every error message this branch composes is byte-identical to the one `main` composes.

The `err113` sentinels are shaped so `fmt.Errorf` reassembles the original text around them: the sentinel carries the fixed words and the caller supplies the interpolated value in the position it has always occupied. Where the value sits mid-sentence the sentinel holds only a fragment (e.g. `vault.ErrVaultNotFound` is `"does not exist"`, composed by its caller as `vault <name> does not exist`); each such sentinel documents the message it participates in.

Verified mechanically, not by inspection: every `fmt.Errorf` and `errors.New` call site in both trees is parsed, the `Error()` text of any sentinel passed to `%w` is substituted in, and the resulting sets of composed message templates are compared. All 350 templates `main` produces are still produced, character for character. The set of lost or altered messages is empty.

## `unlocker list`

`findUnlockerIDByMetadata` returns `(string, error)` rather than signalling failure with an empty ID, so an unreadable `unlockers.d` is no longer indistinguishable from "no matching entry". `UnlockersList` skips such an entry with a warning naming the directory — its behavior before the scan was extracted into a helper — instead of emitting a row under a synthesized fallback ID that no `unlocker remove` or `unlocker select` can match and that suppresses the current-unlocker marker. The duplicate-check and shell-completion callers skip on the same condition, matching their pre-extraction behavior. Covered by `internal/cli/unlockers_list_test.go`.

`TODO.md` records the change plus follow-ups (version-completion TODOs formerly in code comments, darwin-gated files exceeding 88 columns that Linux CI does not lint).

`make check` is green and the pinned v2.12.2 image reports `0 issues.` Note the test suite needs the memlock ulimit from `script/cibuild` for the 10MB memguard test; that requirement is pre-existing.

Not changed: `script/bootstrap` installs golangci-lint via the system package manager (no version pin to bump), and `script/lint` invokes whatever `golangci-lint` is on PATH. golangci-lint v2.12 deprecates `gomodguard` in favor of `gomodguard_v2` (warning only); the canonical config owns that decision.

Co-authored-by: sneak <sneak@sneak.berlin>
Reviewed-on: #29
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-08-10 15:23:33 +02:00
10 changed files with 369 additions and 55 deletions

View File

@@ -32,7 +32,13 @@ Bring the repo into policy compliance in one commit:
findings across `internal/` and `pkg/` (line wrapping, `wsl_v5` findings across `internal/` and `pkg/` (line wrapping, `wsl_v5`
blank lines, sentinel errors for `err113`, `t.Parallel()` where blank lines, sentinel errors for `err113`, `t.Parallel()` where
safe, `_test` package conversions, complexity/`dupl` helper safe, `_test` package conversions, complexity/`dupl` helper
extraction) on branch `golangci-v2.12.2`. extraction) on branch `golangci-v2.12.2`. Reworked after review:
the `err113` sentinels in `internal/vault`, `internal/secret`,
`internal/cli` and `pkg/bip85` were reshaped so every composed
error message is byte-identical to `main`, and
`findUnlockerIDByMetadata` now returns an error so `unlocker list`
skips an unreadable `unlockers.d` entry with a warning instead of
emitting a fabricated fallback ID.
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, - 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
Makefile shims, README Entrypoints section Makefile shims, README Entrypoints section
- 2026-03-11: Secure Enclave unlocker for hardware-backed secret - 2026-03-11: Secure Enclave unlocker for hardware-backed secret

View File

@@ -4,6 +4,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault" "git.eeqj.de/sneak/secret/internal/vault"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -75,7 +76,18 @@ func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func(
for _, metadata := range unlockerMetadataList { for _, metadata := range unlockerMetadataList {
// Get the actual unlocker ID by creating the unlocker instance // Get the actual unlocker ID by creating the unlocker instance
id := findUnlockerIDByMetadata(fs, unlockersDir, metadata, false) id, err := findUnlockerIDByMetadata(
fs, unlockersDir, metadata, false,
)
if err != nil {
secret.Warn(
"Could not read unlockers directory during completion, "+
"skipping unlocker",
"unlockers_dir", unlockersDir, "error", err)
continue
}
if id != "" && strings.HasPrefix(id, toComplete) { if id != "" && strings.HasPrefix(id, toComplete) {
completions = append(completions, id) completions = append(completions, id)
} }

View File

@@ -43,8 +43,10 @@ var (
"keychain unlockers are only supported on macOS") "keychain unlockers are only supported on macOS")
errSecureEnclaveMacOSOnly = errors.New( errSecureEnclaveMacOSOnly = errors.New(
"secure enclave unlockers are only supported on macOS") "secure enclave unlockers are only supported on macOS")
// errGPGKeyAlreadyUnlocker carries only the message tail; the caller
// composes "GPG key <id> is already added as an unlocker".
errGPGKeyAlreadyUnlocker = errors.New( errGPGKeyAlreadyUnlocker = errors.New(
"GPG key is already added as an unlocker") "is already added as an unlocker")
errUnsupportedUnlockerType = errors.New("unsupported unlocker type") errUnsupportedUnlockerType = errors.New("unsupported unlocker type")
errLastUnlocker = errors.New("refusing to remove last unlocker") errLastUnlocker = errors.New("refusing to remove last unlocker")
errUnlockerExists = errors.New("unlocker already exists") errUnlockerExists = errors.New("unlocker already exists")
@@ -342,16 +344,20 @@ func unlockerIDFromDir(
// findUnlockerIDByMetadata scans unlockersDir for the directory whose // findUnlockerIDByMetadata scans unlockersDir for the directory whose
// stored metadata matches the given type and creation time and returns // stored metadata matches the given type and creation time and returns
// the matching unlocker's ID. Returns "" if no match is found. // the matching unlocker's ID. It returns ("", nil) when the directory is
// readable but holds no match, and a non-nil error when the directory
// itself cannot be read. Callers must distinguish the two: an unreadable
// directory means the unlocker's real ID is unknowable, so the entry has
// to be skipped rather than reported under a synthesized ID.
func findUnlockerIDByMetadata( func findUnlockerIDByMetadata(
fs afero.Fs, unlockersDir string, metadata secret.UnlockerMetadata, fs afero.Fs, unlockersDir string, metadata secret.UnlockerMetadata,
includeSecureEnclave bool, includeSecureEnclave bool,
) string { ) (string, error) {
files, err := afero.ReadDir(fs, unlockersDir) files, err := afero.ReadDir(fs, unlockersDir)
if err != nil { if err != nil {
secret.Warn("Could not read unlockers directory", "error", err) return "", fmt.Errorf(
"failed to read unlockers directory %s: %w", unlockersDir, err,
return "" )
} }
for _, file := range files { for _, file := range files {
@@ -385,11 +391,11 @@ func findUnlockerIDByMetadata(
if diskMetadata.Type == metadata.Type && if diskMetadata.Type == metadata.Type &&
diskMetadata.CreatedAt.Equal(metadata.CreatedAt) { diskMetadata.CreatedAt.Equal(metadata.CreatedAt) {
return unlockerIDFromDir(fs, unlockerDir, diskMetadata, return unlockerIDFromDir(fs, unlockerDir, diskMetadata,
includeSecureEnclave) includeSecureEnclave), nil
} }
} }
return "" return "", nil
} }
// UnlockersList lists unlockers in the current vault // UnlockersList lists unlockers in the current vault
@@ -429,7 +435,16 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
// Find the unlocker directory by type and created time // Find the unlocker directory by type and created time
unlockersDir := filepath.Join(vaultDir, "unlockers.d") unlockersDir := filepath.Join(vaultDir, "unlockers.d")
unlockerID := findUnlockerIDByMetadata(cli.fs, unlockersDir, metadata, true)
unlockerID, err := findUnlockerIDByMetadata(
cli.fs, unlockersDir, metadata, true,
)
if err != nil {
secret.Warn("Could not read unlockers directory, skipping unlocker",
"unlockers_dir", unlockersDir, "error", err)
continue
}
// Get the proper ID using the unlocker's ID() method // Get the proper ID using the unlocker's ID() method
var properID string var properID string
@@ -678,7 +693,7 @@ func (cli *Instance) addPGPUnlocker(cmd *cobra.Command) error {
err = cli.checkUnlockerExists(vlt, expectedID) err = cli.checkUnlockerExists(vlt, expectedID)
if err != nil { if err != nil {
return fmt.Errorf("%w: %s", errGPGKeyAlreadyUnlocker, gpgKeyID) return fmt.Errorf("GPG key %s %w", gpgKeyID, errGPGKeyAlreadyUnlocker)
} }
pgpUnlocker, err := secret.CreatePGPUnlocker(cli.fs, cli.stateDir, gpgKeyID) pgpUnlocker, err := secret.CreatePGPUnlocker(cli.fs, cli.stateDir, gpgKeyID)
@@ -781,7 +796,16 @@ func (cli *Instance) checkUnlockerExists(vlt *vault.Vault, unlockerID string) er
for _, metadata := range unlockers { for _, metadata := range unlockers {
// Construct the unlocker matching this metadata to get its ID // Construct the unlocker matching this metadata to get its ID
id := findUnlockerIDByMetadata(cli.fs, unlockersDir, metadata, true) id, err := findUnlockerIDByMetadata(cli.fs, unlockersDir, metadata, true)
if err != nil {
secret.Warn(
"Could not read unlockers directory during duplicate check, "+
"skipping unlocker",
"unlockers_dir", unlockersDir, "error", err)
continue
}
if id != "" && id == unlockerID { if id != "" && id == unlockerID {
return errUnlockerExists return errUnlockerExists
} }

View File

@@ -0,0 +1,229 @@
// Unlocker List Tests
//
// Tests for `secret unlocker list` behavior when the unlockers.d directory
// cannot be read while the listing is being rendered:
//
// - TestUnlockersListSkipsUnreadableUnlockersDir: an unreadable
// unlockers.d yields no rows rather than rows bearing synthesized IDs.
// - TestUnlockersListSkipsOnlyUnreadableEntries: a readable entry is
// still listed, with its real ID and its current-unlocker marker,
// when a later entry's scan fails.
//
// The listing resolves each unlocker's real ID by rescanning unlockers.d
// after the vault has already enumerated it. If that rescan fails the ID
// is unknowable, so the entry must be skipped: a synthesized ID matches
// no `unlocker remove` or `unlocker select` argument and would also
// suppress the current-unlocker marker.
//nolint:testpackage // white-box test of unexported internals
package cli
import (
"bytes"
"encoding/json"
"errors"
"path/filepath"
"testing"
"time"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
// listTestStateDir is the state directory of the synthetic vault used
// by the unlocker listing tests.
listTestStateDir = "/state"
// listTestVaultName is the name of that synthetic vault.
listTestVaultName = "default"
// listTestGPGKeyID is the GPG key ID recorded in the readable PGP
// unlocker's metadata. The unlocker's real ID is derived from it, and
// differs from the timestamp-derived fallback ID.
listTestGPGKeyID = "DEADBEEFDEADBEEF"
// listTestUnlockerDirOne and listTestUnlockerDirTwo are the unlocker
// directory names under unlockers.d.
listTestUnlockerDirOne = "host-pgp-2026-08-09"
listTestUnlockerDirTwo = "host-pgp-2026-08-10"
// listTestUnlockersDirName is the directory the listing rescans to
// resolve unlocker IDs.
listTestUnlockersDirName = "unlockers.d"
// listTestMetadataFileName is the per-unlocker metadata file name.
listTestMetadataFileName = "unlocker-metadata.json"
// listTestDirPerm and listTestFilePerm are the fixture permissions.
listTestDirPerm = 0o700
listTestFilePerm = 0o600
)
// errUnlockersDirUnreadable is returned by the test filesystem in place of
// a successful open of unlockers.d.
var errUnlockersDirUnreadable = errors.New("permission denied")
// unlockersDirFailFs makes unlockers.d unreadable once it has been opened
// successfully openBudget times. This reproduces the directory becoming
// unreadable (permission change, partially restored backup, EIO) between
// the vault's own enumeration and the per-entry rescan that resolves
// unlocker IDs.
type unlockersDirFailFs struct {
afero.Fs
openBudget int
opens int
}
//nolint:ireturn // afero.File is the interface required by afero.Fs
func (f *unlockersDirFailFs) Open(name string) (afero.File, error) {
if filepath.Base(name) == listTestUnlockersDirName {
f.opens++
if f.opens > f.openBudget {
return nil, errUnlockersDirUnreadable
}
}
//nolint:wrapcheck // test double must return the wrapped Fs error as-is
return f.Fs.Open(name)
}
// writePGPUnlocker writes a PGP unlocker directory with metadata that
// yields the real ID "pgp-<keyID>".
func writePGPUnlocker(
t *testing.T, fs afero.Fs, unlockersDir, dirName string,
createdAt time.Time, keyID string,
) {
t.Helper()
metadata := secret.PGPUnlockerMetadata{
UnlockerMetadata: secret.UnlockerMetadata{
Type: unlockerTypePGP,
CreatedAt: createdAt,
},
GPGKeyID: keyID,
}
encoded, err := json.Marshal(metadata)
require.NoError(t, err)
dir := filepath.Join(unlockersDir, dirName)
require.NoError(t, fs.MkdirAll(dir, listTestDirPerm))
require.NoError(t, afero.WriteFile(
fs, filepath.Join(dir, listTestMetadataFileName), encoded,
listTestFilePerm,
))
}
// newListTestVault builds a synthetic vault on a MemMapFs containing the
// given number of PGP unlockers, with the first one selected as current.
func newListTestVault(t *testing.T, unlockerCount int) *afero.MemMapFs {
t.Helper()
base := &afero.MemMapFs{}
vaultDir := filepath.Join(listTestStateDir, "vaults.d", listTestVaultName)
unlockersDir := filepath.Join(vaultDir, listTestUnlockersDirName)
require.NoError(t, afero.WriteFile(
base, filepath.Join(listTestStateDir, "currentvault"),
[]byte(listTestVaultName), listTestFilePerm,
))
names := []string{listTestUnlockerDirOne, listTestUnlockerDirTwo}
names = names[:unlockerCount]
for i, name := range names {
writePGPUnlocker(t, base, unlockersDir, name,
time.Date(2026, time.August, 9+i, 12, 30, 0, 0, time.UTC),
listTestGPGKeyID+string(rune('A'+i)),
)
}
require.NoError(t, afero.WriteFile(
base, filepath.Join(vaultDir, "current-unlocker"),
[]byte(names[0]), listTestFilePerm,
))
return base
}
// listUnlockersJSON runs UnlockersList in JSON mode against the given
// filesystem and decodes the emitted unlocker rows.
func listUnlockersJSON(t *testing.T, fs afero.Fs) []UnlockerInfo {
t.Helper()
var buf bytes.Buffer
cmd := &cobra.Command{}
cmd.SetOut(&buf)
cmd.SetErr(&buf)
instance := &Instance{fs: fs, stateDir: listTestStateDir, cmd: cmd}
require.NoError(t, instance.UnlockersList(true))
var decoded struct {
Unlockers []UnlockerInfo `json:"unlockers"`
}
require.NoError(t, json.Unmarshal(buf.Bytes(), &decoded))
return decoded.Unlockers
}
// TestUnlockersListSkipsUnreadableUnlockersDir asserts that an unlockers.d
// which becomes unreadable after the vault enumerated it produces no rows,
// rather than rows carrying fabricated fallback IDs.
func TestUnlockersListSkipsUnreadableUnlockersDir(t *testing.T) {
t.Parallel()
base := newListTestVault(t, 1)
// Budget of one: the vault's own ListUnlockers scan succeeds, the
// per-entry rescan that resolves the ID fails.
fs := &unlockersDirFailFs{Fs: base, openBudget: 1}
unlockers := listUnlockersJSON(t, fs)
assert.Empty(t, unlockers,
"an unreadable unlockers.d must yield no rows, not fabricated IDs")
}
// TestUnlockersListSkipsOnlyUnreadableEntries asserts that a readable
// entry survives with its real ID and current-unlocker marker when a later
// entry's rescan fails.
func TestUnlockersListSkipsOnlyUnreadableEntries(t *testing.T) {
t.Parallel()
base := newListTestVault(t, 2)
// Budget of two: ListUnlockers plus the first entry's rescan succeed,
// the second entry's rescan fails.
fs := &unlockersDirFailFs{Fs: base, openBudget: 2}
unlockers := listUnlockersJSON(t, fs)
require.Len(t, unlockers, 1,
"only the entry whose directory was readable may be listed")
assert.Equal(t, "pgp-"+listTestGPGKeyID+"A", unlockers[0].ID,
"the surviving row must carry the real unlocker ID")
assert.True(t, unlockers[0].IsCurrent,
"the current-unlocker marker must survive the skip")
}
// TestUnlockersListReadableEntriesAreListed is the control case: with a
// fully readable unlockers.d every entry is listed with its real ID.
func TestUnlockersListReadableEntriesAreListed(t *testing.T) {
t.Parallel()
base := newListTestVault(t, 2)
unlockers := listUnlockersJSON(t, base)
require.Len(t, unlockers, 2)
assert.Equal(t, "pgp-"+listTestGPGKeyID+"A", unlockers[0].ID)
assert.Equal(t, "pgp-"+listTestGPGKeyID+"B", unlockers[1].ID)
assert.True(t, unlockers[0].IsCurrent)
assert.False(t, unlockers[1].IsCurrent)
}

View File

@@ -17,7 +17,10 @@ import (
) )
var ( var (
errSecretNotFound = errors.New("secret not found") // errSecretNotFound carries only the message tail; callers compose
// "secret <name> not found" around it so the emitted text is
// unchanged.
errSecretNotFound = errors.New("not found")
errUnlockerRequired = errors.New("unlocker required to decrypt secret") errUnlockerRequired = errors.New("unlocker required to decrypt secret")
errGetEncryptedDataDeprecated = errors.New( errGetEncryptedDataDeprecated = errors.New(
"GetEncryptedData is deprecated - use version-specific methods") "GetEncryptedData is deprecated - use version-specific methods")
@@ -94,7 +97,7 @@ func (s *Secret) GetValue(unlocker Unlocker) (*memguard.LockedBuffer, error) {
Debug("Secret not found during GetValue", Debug("Secret not found during GetValue",
"secret_name", s.Name, "vault_name", s.vault.GetName()) "secret_name", s.Name, "vault_name", s.vault.GetName())
return nil, fmt.Errorf("%w: %s", errSecretNotFound, s.Name) return nil, fmt.Errorf("secret %s %w", s.Name, errSecretNotFound)
} }
Debug("Secret exists, getting current version", "secret_name", s.Name) Debug("Secret exists, getting current version", "secret_name", s.Name)

View File

@@ -3,6 +3,13 @@ package vault
import "errors" import "errors"
// Sentinel errors returned by vault operations. // Sentinel errors returned by vault operations.
//
// Several of these carry deliberately partial text: the message a caller
// composes with fmt.Errorf places the interpolated value where it has
// always appeared, and the sentinel supplies only the surrounding fixed
// words. This keeps every composed message byte-identical to the dynamic
// errors these sentinels replaced. Each such sentinel notes the message it
// participates in.
var ( var (
// ErrMnemonicMismatch indicates the mnemonic-derived public key does // ErrMnemonicMismatch indicates the mnemonic-derived public key does
// not match the vault's stored public key hash. // not match the vault's stored public key hash.
@@ -11,43 +18,48 @@ var (
) )
// ErrInvalidVaultName indicates a vault name that does not match the // ErrInvalidVaultName indicates a vault name that does not match the
// allowed pattern [a-z0-9.\-_]+. // allowed pattern [a-z0-9.\-_]+. Composed as
ErrInvalidVaultName = errors.New( // "invalid vault name '<name>': must match pattern [a-z0-9.\-_]+".
"invalid vault name: must match pattern [a-z0-9.\\-_]+", ErrInvalidVaultName = errors.New("invalid vault name")
)
// ErrVaultNotFound indicates the named vault does not exist. // ErrVaultNotFound indicates the named vault does not exist. Composed
ErrVaultNotFound = errors.New("vault does not exist") // as "vault <name> does not exist".
ErrVaultNotFound = errors.New("does not exist")
// ErrNilValueBuffer indicates a nil value buffer was supplied. // ErrNilValueBuffer indicates a nil value buffer was supplied.
ErrNilValueBuffer = errors.New("value buffer is nil") ErrNilValueBuffer = errors.New("value buffer is nil")
// ErrInvalidSecretName indicates a secret name that does not match // ErrInvalidSecretName indicates a secret name that does not match
// the allowed pattern [a-z0-9.\-_/]+. // the allowed pattern [a-z0-9.\-_/]+. Composed as
ErrInvalidSecretName = errors.New( // "invalid secret name '<name>': must match pattern [a-z0-9.\-_/]+",
"invalid secret name: must match pattern [a-z0-9.\\-_/]+", // or as "invalid secret name: <name>" by GetSecretObject.
) ErrInvalidSecretName = errors.New("invalid secret name")
// ErrSecretExists indicates the secret already exists and --force // ErrSecretExists indicates the secret already exists and --force
// was not supplied. // was not supplied. Composed as
ErrSecretExists = errors.New( // "secret <name> already exists (use --force to overwrite)", or as
"secret already exists (use --force to overwrite)", // "secret '<name>' already exists in vault '<vault>' (use --force to
) // overwrite)" when copying between vaults.
ErrSecretExists = errors.New("already exists")
// ErrSecretNotFound indicates the named secret does not exist. // ErrSecretNotFound indicates the named secret does not exist.
ErrSecretNotFound = errors.New("secret not found") // Composed as "secret <name> not found".
ErrSecretNotFound = errors.New("not found")
// ErrVersionNotFound indicates the requested secret version does not // ErrVersionNotFound indicates the requested secret version does not
// exist. // exist. Composed as
ErrVersionNotFound = errors.New("version not found") // "version <version> not found for secret <name>".
ErrVersionNotFound = errors.New("not found for secret")
// ErrNoVersions indicates the source secret has no versions. // ErrNoVersions indicates the source secret has no versions. Composed
ErrNoVersions = errors.New("source secret has no versions") // as "source secret '<name>' has no versions".
ErrNoVersions = errors.New("has no versions")
// ErrUnsupportedUnlockerType indicates an unlocker metadata type // ErrUnsupportedUnlockerType indicates an unlocker metadata type
// that this build does not support. // that this build does not support.
ErrUnsupportedUnlockerType = errors.New("unsupported unlocker type") ErrUnsupportedUnlockerType = errors.New("unsupported unlocker type")
// ErrUnlockerNotFound indicates no unlocker with the given ID exists. // ErrUnlockerNotFound indicates no unlocker with the given ID exists.
ErrUnlockerNotFound = errors.New("unlocker not found") // Composed as "unlocker with ID <id> not found".
ErrUnlockerNotFound = errors.New("not found")
) )

View File

@@ -199,7 +199,10 @@ func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
if !isValidVaultName(name) { if !isValidVaultName(name) {
secret.Debug("Invalid vault name provided", "vault_name", name) secret.Debug("Invalid vault name provided", "vault_name", name)
return nil, fmt.Errorf("%w: '%s'", ErrInvalidVaultName, name) return nil, fmt.Errorf(
"%w '%s': must match pattern [a-z0-9.\\-_]+",
ErrInvalidVaultName, name,
)
} }
secret.Debug("Vault name validation passed", "vault_name", name) secret.Debug("Vault name validation passed", "vault_name", name)
@@ -272,7 +275,10 @@ func SelectVault(fs afero.Fs, stateDir string, name string) error {
if !isValidVaultName(name) { if !isValidVaultName(name) {
secret.Debug("Invalid vault name provided", "vault_name", name) secret.Debug("Invalid vault name provided", "vault_name", name)
return fmt.Errorf("%w: '%s'", ErrInvalidVaultName, name) return fmt.Errorf(
"%w '%s': must match pattern [a-z0-9.\\-_]+",
ErrInvalidVaultName, name,
)
} }
secret.Debug("Vault name validation passed", "vault_name", name) secret.Debug("Vault name validation passed", "vault_name", name)
@@ -286,7 +292,7 @@ func SelectVault(fs afero.Fs, stateDir string, name string) error {
} }
if !exists { if !exists {
return fmt.Errorf("%w: %s", ErrVaultNotFound, name) return fmt.Errorf("vault %s %w", name, ErrVaultNotFound)
} }
// Create or update the currentvault file with just the vault name // Create or update the currentvault file with just the vault name

View File

@@ -127,7 +127,10 @@ func (v *Vault) AddSecret(name string, value *memguard.LockedBuffer, force bool)
if !isValidSecretName(name) { if !isValidSecretName(name) {
secret.Debug("Invalid secret name provided", "secret_name", name) secret.Debug("Invalid secret name provided", "secret_name", name)
return fmt.Errorf("%w: '%s'", ErrInvalidSecretName, name) return fmt.Errorf(
"%w '%s': must match pattern [a-z0-9.\\-_/]+",
ErrInvalidSecretName, name,
)
} }
secret.Debug("Secret name validation passed", "secret_name", name) secret.Debug("Secret name validation passed", "secret_name", name)
@@ -356,7 +359,7 @@ func (v *Vault) UnlockVault() (*age.X25519Identity, error) {
// GetSecretObject retrieves a Secret object with metadata loaded from this vault // GetSecretObject retrieves a Secret object with metadata loaded from this vault
func (v *Vault) GetSecretObject(name string) (*secret.Secret, error) { func (v *Vault) GetSecretObject(name string) (*secret.Secret, error) {
if !isValidSecretName(name) { if !isValidSecretName(name) {
return nil, fmt.Errorf("%w: '%s'", ErrInvalidSecretName, name) return nil, fmt.Errorf("%w: %s", ErrInvalidSecretName, name)
} }
// First check if the secret exists by checking for the metadata file // First check if the secret exists by checking for the metadata file
@@ -376,7 +379,7 @@ func (v *Vault) GetSecretObject(name string) (*secret.Secret, error) {
} }
if !exists { if !exists {
return nil, fmt.Errorf("%w: %s", ErrSecretNotFound, name) return nil, fmt.Errorf("secret %s %w", name, ErrSecretNotFound)
} }
// Create a Secret object // Create a Secret object
@@ -493,7 +496,7 @@ func (v *Vault) CopySecretAllVersions(
} }
if len(versions) == 0 { if len(versions) == 0 {
return fmt.Errorf("%w: %s", ErrNoVersions, srcSecretName) return fmt.Errorf("source secret '%s' %w", srcSecretName, ErrNoVersions)
} }
// Get current version name // Get current version name
@@ -564,7 +567,10 @@ func (v *Vault) prepareSecretDir(
secret.Debug("Secret already exists and force not specified", secret.Debug("Secret already exists and force not specified",
"secret_name", name, "secret_dir", secretDir) "secret_name", name, "secret_dir", secretDir)
return true, nil, fmt.Errorf("%w: %s", ErrSecretExists, name) return true, nil, fmt.Errorf(
"secret %s %w (use --force to overwrite)",
name, ErrSecretExists,
)
} }
// Get the current version to update its notAfter timestamp // Get the current version to update its notAfter timestamp
@@ -625,7 +631,10 @@ func (v *Vault) resolveSecretVersion(name, version string) (string, error) {
if !isValidSecretName(name) { if !isValidSecretName(name) {
secret.Debug("Invalid secret name provided", "secret_name", name) secret.Debug("Invalid secret name provided", "secret_name", name)
return "", fmt.Errorf("%w: '%s'", ErrInvalidSecretName, name) return "", fmt.Errorf(
"%w '%s': must match pattern [a-z0-9.\\-_/]+",
ErrInvalidSecretName, name,
)
} }
// Get vault directory // Get vault directory
@@ -651,7 +660,7 @@ func (v *Vault) resolveSecretVersion(name, version string) (string, error) {
if !exists { if !exists {
secret.Debug("Secret not found in vault", "secret_name", name, "vault_name", v.Name) secret.Debug("Secret not found in vault", "secret_name", name, "vault_name", v.Name)
return "", fmt.Errorf("%w: %s", ErrSecretNotFound, name) return "", fmt.Errorf("secret %s %w", name, ErrSecretNotFound)
} }
// Determine which version to get // Determine which version to get
@@ -682,7 +691,10 @@ func (v *Vault) resolveSecretVersion(name, version string) (string, error) {
if !exists { if !exists {
secret.Debug("Version not found", "version", version, "secret_name", name) secret.Debug("Version not found", "version", version, "secret_name", name)
return "", fmt.Errorf("%w: %s (secret %s)", ErrVersionNotFound, version, name) return "", fmt.Errorf(
"version %s %w %s",
version, ErrVersionNotFound, name,
)
} }
return version, nil return version, nil
@@ -785,7 +797,10 @@ func (v *Vault) prepareCopyDestination(
} }
if exists && !force { if exists && !force {
return fmt.Errorf("%w: %s (vault %s)", ErrSecretExists, destSecretName, v.Name) return fmt.Errorf(
"secret '%s' %w in vault '%s' (use --force to overwrite)",
destSecretName, ErrSecretExists, v.Name,
)
} }
if exists && force { if exists && force {

View File

@@ -283,7 +283,7 @@ func (v *Vault) RemoveUnlocker(unlockerID string) error {
} }
if unlocker == nil { if unlocker == nil {
return fmt.Errorf("%w: %s", ErrUnlockerNotFound, unlockerID) return fmt.Errorf("unlocker with ID %s %w", unlockerID, ErrUnlockerNotFound)
} }
// Use the unlocker's Remove method // Use the unlocker's Remove method
@@ -307,7 +307,7 @@ func (v *Vault) SelectUnlocker(unlockerID string) error {
} }
if targetUnlockerDir == "" { if targetUnlockerDir == "" {
return fmt.Errorf("%w: %s", ErrUnlockerNotFound, unlockerID) return fmt.Errorf("unlocker with ID %s %w", unlockerID, ErrUnlockerNotFound)
} }
// Create/update current-unlocker file with just the unlocker name // Create/update current-unlocker file with just the unlocker name

View File

@@ -60,8 +60,15 @@ var (
// is out of range. // is out of range.
ErrInvalidBase85PwdLen = errors.New("pwdLen must be between 10 and 80") ErrInvalidBase85PwdLen = errors.New("pwdLen must be between 10 and 80")
// ErrPasswordTooShort is returned when the derived material is // ErrPasswordTooShort is returned when the derived material is
// shorter than the requested password length. // shorter than the requested password length. It carries only the
ErrPasswordTooShort = errors.New("derived password too short") // middle of the message, which the caller composes as
// "derived password length <n> is shorter than requested length <m>",
// so the emitted text is unchanged.
ErrPasswordTooShort = errors.New("is shorter than requested length")
// ErrEncodedTooShort is returned when the encoded material is shorter
// than the requested password length. Composed as
// "encoded length <n> is less than requested length <m>".
ErrEncodedTooShort = errors.New("is less than requested length")
) )
// Version bytes for extended keys // Version bytes for extended keys
@@ -377,8 +384,8 @@ func DeriveBase64Password(
// Slice to the desired password length // Slice to the desired password length
if len(encodedStr) < int(pwdLen) { if len(encodedStr) < int(pwdLen) {
return "", fmt.Errorf( return "", fmt.Errorf(
"%w: derived length %d is shorter than requested length %d", "derived password length %d %w %d",
ErrPasswordTooShort, len(encodedStr), pwdLen, len(encodedStr), ErrPasswordTooShort, pwdLen,
) )
} }
@@ -407,8 +414,8 @@ func DeriveBase85Password(
// Slice to the desired password length // Slice to the desired password length
if len(encoded) < int(pwdLen) { if len(encoded) < int(pwdLen) {
return "", fmt.Errorf( return "", fmt.Errorf(
"%w: encoded length %d is less than requested length %d", "encoded length %d %w %d",
ErrPasswordTooShort, len(encoded), pwdLen, len(encoded), ErrEncodedTooShort, pwdLen,
) )
} }