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>
230 lines
7.2 KiB
Go
230 lines
7.2 KiB
Go
// 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)
|
|
}
|