// Copyright (c) 2015-2026 MinIO, Inc.
//
// This file is part of MinIO Object Storage stack
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
package notify
import (
"context"
"crypto/tls"
"go/ast"
"go/parser"
"go/token"
"io/fs"
"net/http"
"path/filepath"
"slices"
"sort"
"strconv"
"strings"
"testing"
"github.com/minio/minio/internal/config"
"github.com/minio/minio/internal/event/target"
)
// Test fixtures. Every value here is deliberately non-functional: the paths do
// not exist and the credentials are placeholders, so a leaked fixture is inert.
const (
testCredsPath = "/nonexistent/test.creds"
testNKeyPath = "/nonexistent/test.nk"
testNATSAddr = "127.0.0.1:4222"
testNATSSubj = "test-subject"
testAMQPURL = "amqp://guest:guest@127.0.0.1:5672"
testTargetName = "FITCHECK" // named target from issue #39
// The config key an operator types into `mc admin config set`. Spelled as a
// literal so these tests pin the on-disk schema rather than tracking
// whatever the Go constant happens to say.
natsCredsKey = "user_credentials"
// The env var name that the pre-fix migration wrote into the config store
// as if it were a config key.
legacyNATSCredsKey = "MINIO_NOTIFY_NATS_USER_CREDENTIALS"
)
// natsKVSFor builds a stored (on-disk) NATS target KVS carrying the three keys
// that issue #39 reported as rejected. config.Merge passes a stored target
// through verbatim rather than layering it over the defaults, so the numeric
// keys the parser reads must be present here too.
func natsKVSFor(credsKey string) config.KVS {
return config.KVS{
config.KV{Key: config.Enable, Value: config.EnableOn},
config.KV{Key: target.NATSAddress, Value: testNATSAddr},
config.KV{Key: target.NATSSubject, Value: testNATSSubj},
config.KV{Key: credsKey, Value: testCredsPath},
config.KV{Key: target.NATSNKeySeed, Value: testNKeyPath},
config.KV{Key: target.NATSTLSHandshakeFirst, Value: config.EnableOn},
config.KV{Key: target.NATSPingInterval, Value: "0"},
config.KV{Key: target.NATSQueueLimit, Value: "0"},
}
}
// T1: an enable=on NATS target carrying user_credentials / nkey_seed /
// tls_handshake_first must pass key validation. This is the exact shape from
// issue #39 (`mc admin config set us notify_nats:FITCHECK ...`).
func TestCheckValidNotificationKeysNATSJWTKeys(t *testing.T) {
for _, tgtName := range []string{config.Default, testTargetName} {
t.Run(tgtName, func(t *testing.T) {
tgt := map[string]config.KVS{
tgtName: natsKVSFor(natsCredsKey),
}
if err := checkValidNotificationKeysForSubSys(config.NotifyNATSSubSys, tgt); err != nil {
t.Fatalf("expected NATS JWT/nkey/handshake keys to validate, got: %v", err)
}
})
}
}
// T2: the parser must surface the three values from stored config (no env).
func TestGetNotifyNATSFromStoredKVS(t *testing.T) {
for _, tgtName := range []string{config.Default, testTargetName} {
t.Run(tgtName, func(t *testing.T) {
natsKVS := map[string]config.KVS{
tgtName: natsKVSFor(natsCredsKey),
}
targets, err := GetNotifyNATS(natsKVS, nil)
if err != nil {
t.Fatalf("GetNotifyNATS: %v", err)
}
args, ok := targets[tgtName]
if !ok {
t.Fatalf("target %q missing from result %v", tgtName, targets)
}
if args.UserCredentials != testCredsPath {
t.Errorf("UserCredentials = %q, want %q", args.UserCredentials, testCredsPath)
}
if args.NKeySeed != testNKeyPath {
t.Errorf("NKeySeed = %q, want %q", args.NKeySeed, testNKeyPath)
}
if !args.TLSHandshakeFirst {
t.Errorf("TLSHandshakeFirst = false, want true")
}
})
}
}
// T3: AMQP `immediate` must validate and parse.
func TestNotifyAMQPImmediate(t *testing.T) {
amqpKVS := map[string]config.KVS{
config.Default: {
config.KV{Key: config.Enable, Value: config.EnableOn},
config.KV{Key: target.AmqpURL, Value: testAMQPURL},
config.KV{Key: target.AmqpImmediate, Value: config.EnableOn},
config.KV{Key: target.AmqpDeliveryMode, Value: "0"},
config.KV{Key: target.AmqpQueueLimit, Value: "0"},
},
}
if err := checkValidNotificationKeysForSubSys(config.NotifyAMQPSubSys, amqpKVS); err != nil {
t.Fatalf("expected amqp immediate key to validate, got: %v", err)
}
targets, err := GetNotifyAMQP(amqpKVS)
if err != nil {
t.Fatalf("GetNotifyAMQP: %v", err)
}
args, ok := targets[config.Default]
if !ok {
t.Fatalf("default target missing from result %v", targets)
}
if !args.Immediate {
t.Errorf("Immediate = false, want true")
}
if args.Internal {
t.Errorf("Internal = true, want false (immediate must not bleed into internal)")
}
}
// T4 (guard, must hold before and after the fix): environment variables win
// over stored config, for both the default and the `_` suffixed form.
// Env var names are asserted as raw literals so that a rename of the Go
// constant cannot silently change the public interface.
func TestNotifyEnvOverridesStoredKVS(t *testing.T) {
const (
envCreds = "MINIO_NOTIFY_NATS_USER_CREDENTIALS"
envNKeySeed = "MINIO_NOTIFY_NATS_NKEY_SEED"
envHandshakeFirst = "MINIO_NOTIFY_NATS_TLS_HANDSHAKE_FIRST"
envAMQPImmediate = "MINIO_NOTIFY_AMQP_IMMEDIATE"
envCredsOverride = "/nonexistent/env-override.creds"
envNKeySeedOverride = "/nonexistent/env-override.nk"
)
t.Run("nats-default", func(t *testing.T) {
t.Setenv(envCreds, envCredsOverride)
t.Setenv(envNKeySeed, envNKeySeedOverride)
t.Setenv(envHandshakeFirst, config.EnableOff)
natsKVS := map[string]config.KVS{config.Default: natsKVSFor(natsCredsKey)}
targets, err := GetNotifyNATS(natsKVS, nil)
if err != nil {
t.Fatalf("GetNotifyNATS: %v", err)
}
args := targets[config.Default]
if args.UserCredentials != envCredsOverride {
t.Errorf("UserCredentials = %q, want env value %q", args.UserCredentials, envCredsOverride)
}
if args.NKeySeed != envNKeySeedOverride {
t.Errorf("NKeySeed = %q, want env value %q", args.NKeySeed, envNKeySeedOverride)
}
if args.TLSHandshakeFirst {
t.Errorf("TLSHandshakeFirst = true, want env value false")
}
})
t.Run("nats-named-target", func(t *testing.T) {
t.Setenv(envCreds+config.Default+testTargetName, envCredsOverride)
t.Setenv(envNKeySeed+config.Default+testTargetName, envNKeySeedOverride)
t.Setenv(envHandshakeFirst+config.Default+testTargetName, config.EnableOff)
natsKVS := map[string]config.KVS{testTargetName: natsKVSFor(natsCredsKey)}
targets, err := GetNotifyNATS(natsKVS, nil)
if err != nil {
t.Fatalf("GetNotifyNATS: %v", err)
}
args := targets[testTargetName]
if args.UserCredentials != envCredsOverride {
t.Errorf("UserCredentials = %q, want env value %q", args.UserCredentials, envCredsOverride)
}
if args.NKeySeed != envNKeySeedOverride {
t.Errorf("NKeySeed = %q, want env value %q", args.NKeySeed, envNKeySeedOverride)
}
if args.TLSHandshakeFirst {
t.Errorf("TLSHandshakeFirst = true, want env value false")
}
})
t.Run("amqp-immediate", func(t *testing.T) {
t.Setenv(envAMQPImmediate, config.EnableOn)
amqpKVS := map[string]config.KVS{
config.Default: {
config.KV{Key: config.Enable, Value: config.EnableOn},
config.KV{Key: target.AmqpURL, Value: testAMQPURL},
config.KV{Key: target.AmqpDeliveryMode, Value: "0"},
config.KV{Key: target.AmqpQueueLimit, Value: "0"},
},
}
targets, err := GetNotifyAMQP(amqpKVS)
if err != nil {
t.Fatalf("GetNotifyAMQP: %v", err)
}
if !targets[config.Default].Immediate {
t.Errorf("Immediate = false, want env value true")
}
})
// Pin the env var names that the constants must keep producing. These are
// public interface: renaming one silently breaks every deployment that
// sets it.
t.Run("env-names-are-stable", func(t *testing.T) {
for _, tc := range []struct{ got, want string }{
{target.EnvNATSUserCredentials, envCreds},
{target.EnvNATSNKeySeed, envNKeySeed},
{target.EnvNatsTLSHandshakeFirst, envHandshakeFirst},
{target.EnvAMQPImmediate, envAMQPImmediate},
} {
if tc.got != tc.want {
t.Errorf("env var name = %q, want %q", tc.got, tc.want)
}
}
})
// The config key must be the snake_case name, not the env var name.
t.Run("config-key-names-are-snake-case", func(t *testing.T) {
for _, tc := range []struct{ got, want string }{
{target.NATSUserCredentials, natsCredsKey},
{target.NATSNKeySeed, "nkey_seed"},
{target.NATSTLSHandshakeFirst, "tls_handshake_first"},
{target.AmqpImmediate, "immediate"},
} {
if tc.got != tc.want {
t.Errorf("config key = %q, want %q", tc.got, tc.want)
}
}
})
}
// T6: a store written by the pre-fix migration carries the literal env-var name
// as a config key. Such a store must keep loading, and the value must still
// reach the parser. When both the legacy and the current key are present, the
// current key wins.
func TestNotifyNATSLegacyUserCredentialsKey(t *testing.T) {
const legacyKey = legacyNATSCredsKey
const newValue = "/nonexistent/new-key.creds"
t.Run("legacy-key-alone-validates-and-is-read", func(t *testing.T) {
natsKVS := map[string]config.KVS{
config.Default: natsKVSFor(legacyKey),
}
if err := checkValidNotificationKeysForSubSys(config.NotifyNATSSubSys, natsKVS); err != nil {
t.Fatalf("legacy-migrated store must keep validating, got: %v", err)
}
targets, err := GetNotifyNATS(natsKVS, nil)
if err != nil {
t.Fatalf("GetNotifyNATS: %v", err)
}
if got := targets[config.Default].UserCredentials; got != testCredsPath {
t.Errorf("UserCredentials = %q, want fallback to legacy key value %q", got, testCredsPath)
}
})
t.Run("new-key-wins-over-legacy", func(t *testing.T) {
kvs := natsKVSFor(legacyKey)
kvs = append(kvs, config.KV{Key: natsCredsKey, Value: newValue})
natsKVS := map[string]config.KVS{config.Default: kvs}
if err := checkValidNotificationKeysForSubSys(config.NotifyNATSSubSys, natsKVS); err != nil {
t.Fatalf("mixed old/new store must validate, got: %v", err)
}
targets, err := GetNotifyNATS(natsKVS, nil)
if err != nil {
t.Fatalf("GetNotifyNATS: %v", err)
}
if got := targets[config.Default].UserCredentials; got != newValue {
t.Errorf("UserCredentials = %q, want new key value %q", got, newValue)
}
})
t.Run("env-wins-over-legacy-key", func(t *testing.T) {
const envOverride = "/nonexistent/env-wins.creds"
t.Setenv(legacyNATSCredsKey, envOverride)
natsKVS := map[string]config.KVS{config.Default: natsKVSFor(legacyKey)}
targets, err := GetNotifyNATS(natsKVS, nil)
if err != nil {
t.Fatalf("GetNotifyNATS: %v", err)
}
if got := targets[config.Default].UserCredentials; got != envOverride {
t.Errorf("UserCredentials = %q, want env value %q", got, envOverride)
}
})
t.Run("legacy-key-rejected-for-other-subsystems", func(t *testing.T) {
// The tolerance is NATS-scoped; it must not become a global escape hatch.
amqpKVS := map[string]config.KVS{
config.Default: {
config.KV{Key: config.Enable, Value: config.EnableOn},
config.KV{Key: target.AmqpURL, Value: testAMQPURL},
config.KV{Key: legacyKey, Value: testCredsPath},
},
}
if err := checkValidNotificationKeysForSubSys(config.NotifyAMQPSubSys, amqpKVS); err == nil {
t.Fatal("expected the NATS legacy key to be rejected for the AMQP sub-system")
}
})
}
// T9 (characterization, not an endorsement): FetchEnabledTargets fails fast.
// A single sub-system with an invalid key aborts the whole target list, which
// is why one bad notify config disables every other notification target. This
// test pins the current behavior so that a future change to it is deliberate.
func TestFetchEnabledTargetsFailsFastAcrossSubSystems(t *testing.T) {
cfg := config.Config{
config.NotifyNATSSubSys: map[string]config.KVS{
config.Default: {
config.KV{Key: config.Enable, Value: config.EnableOn},
config.KV{Key: target.NATSAddress, Value: testNATSAddr},
config.KV{Key: target.NATSSubject, Value: testNATSSubj},
config.KV{Key: target.NATSPingInterval, Value: "0"},
config.KV{Key: target.NATSQueueLimit, Value: "0"},
config.KV{Key: "this_key_does_not_exist", Value: "junk"},
},
},
// A healthy sub-system that would yield a working target if the call
// got that far. NotifySubSystems.ToSlice() is sorted, so notify_nats
// is reached before notify_webhook and the error aborts the loop
// before this target is ever constructed -- the healthy config is lost
// without anything being built or dialed.
config.NotifyWebhookSubSys: map[string]config.KVS{
config.Default: {
config.KV{Key: config.Enable, Value: config.EnableOn},
config.KV{Key: target.WebhookEndpoint, Value: "http://127.0.0.1:65535/"},
config.KV{Key: target.WebhookQueueLimit, Value: "0"},
},
},
}
// FetchEnabledTargets dereferences transport.TLSClientConfig unconditionally
// for some sub-systems, so a real transport is required even though no
// target here performs I/O.
transport := &http.Transport{TLSClientConfig: &tls.Config{}}
targetList, err := FetchEnabledTargets(context.Background(), cfg, transport)
if err == nil {
t.Fatal("expected FetchEnabledTargets to fail fast on the invalid sub-system")
}
if targetList != nil {
t.Errorf("target list = %v, want nil (fail-fast discards healthy targets)", targetList)
}
}
// ---------------------------------------------------------------------------
// T7: source-level consistency guard.
//
// Every config key the parser reads or the legacy migration writes must be
// registered in the sub-system's default KVS, otherwise config.CheckValidKeys
// rejects a config that the parser would happily consume (issue #39).
// ---------------------------------------------------------------------------
// notifySubSysAudit enumerates the notify sub-systems and the symbols that make
// up their config surface. Keep in sync with DefaultNotificationKVS; the audit
// itself asserts that every registered sub-system appears here.
// tolerated lists keys that are not registered but are still accepted by
// checkValidNotificationKeysForSubSys as deprecated keys. The parser may read
// them; the migration must never write them and they stay undocumented.
var notifySubSysAudit = []struct {
subSys string
getFn string
setFn string
help config.HelpKVS
tolerated []string
}{
{config.NotifyAMQPSubSys, "GetNotifyAMQP", "SetNotifyAMQP", HelpAMQP, nil},
{config.NotifyESSubSys, "GetNotifyES", "SetNotifyES", HelpES, nil},
{config.NotifyKafkaSubSys, "GetNotifyKafka", "SetNotifyKafka", HelpKafka, nil},
{config.NotifyMQTTSubSys, "GetNotifyMQTT", "SetNotifyMQTT", HelpMQTT, nil},
{config.NotifyMySQLSubSys, "GetNotifyMySQL", "SetNotifyMySQL", HelpMySQL, nil},
{config.NotifyNATSSubSys, "GetNotifyNATS", "SetNotifyNATS", HelpNATS, []string{legacyNATSUserCredentialsKey}},
{config.NotifyNSQSubSys, "GetNotifyNSQ", "SetNotifyNSQ", HelpNSQ, nil},
{config.NotifyPostgresSubSys, "GetNotifyPostgres", "SetNotifyPostgres", HelpPostgres, nil},
{config.NotifyRedisSubSys, "GetNotifyRedis", "SetNotifyRedis", HelpRedis, nil},
{config.NotifyWebhookSubSys, "GetNotifyWebhook", "SetNotifyWebhook", HelpWebhook, nil},
}
// notifyPkgConsts resolves bare identifiers used as config keys in this
// package. Compiler-resolved, so they cannot drift.
var notifyPkgConsts = map[string]string{
"legacyNATSUserCredentialsKey": legacyNATSUserCredentialsKey,
}
// configPkgConsts resolves the `config.X` selectors that appear as config keys
// in parse.go / legacy.go. These are compiler-resolved, so they cannot drift.
var configPkgConsts = map[string]string{
"Enable": config.Enable,
"Comment": config.Comment,
}
// knownUnregisteredWrites records pre-existing instances of the exact defect
// this audit exists to catch: a legacy migration writing config keys that no
// default KVS registers, so the migrated config is rejected on the next load.
//
// These are inherited from upstream and are the same class as issue #39, but
// they are NOT part of the issue #39 fix and were left untouched deliberately.
// The Postgres/MySQL keys below are the pre-connection-string DSN fields; the
// migration still writes them and `password` carries a plaintext database
// password.
//
// This list must only ever shrink. Do not add entries to silence a new gap.
var knownUnregisteredWrites = map[string][]string{
"SetNotifyPostgres": {"host", "port", "username", "password", "database"},
"SetNotifyMySQL": {"host", "port", "username", "password", "database"},
}
func TestNotifyConfigKeysAreRegistered(t *testing.T) {
targetConsts, err := parseTargetPkgStringConsts("../../event/target")
if err != nil {
t.Fatalf("resolving internal/event/target constants: %v", err)
}
fset := token.NewFileSet()
readKeys := map[string]map[string]token.Position{} // fn name -> key -> pos
writtenKeys := map[string]map[string]token.Position{} // fn name -> key -> pos
for _, src := range []string{"parse.go", "legacy.go"} {
f, err := parser.ParseFile(fset, src, nil, 0)
if err != nil {
t.Fatalf("parsing %s: %v", src, err)
}
resolve := func(e ast.Expr) (string, bool) {
return resolveKeyExpr(t, fset, src, e, targetConsts)
}
for _, decl := range f.Decls {
fn, ok := decl.(*ast.FuncDecl)
if !ok || fn.Body == nil {
continue
}
name := fn.Name.Name
switch {
case strings.HasPrefix(name, "GetNotify"):
readKeys[name] = collectKVGetKeys(fset, fn, resolve)
case strings.HasPrefix(name, "SetNotify"):
writtenKeys[name] = collectKVWriteKeys(fset, fn, resolve)
}
}
}
// Guard against a new sub-system slipping past the audit table.
audited := map[string]bool{}
for _, a := range notifySubSysAudit {
audited[a.subSys] = true
}
for subSys := range DefaultNotificationKVS {
if !audited[subSys] {
t.Errorf("sub-system %q is registered in DefaultNotificationKVS but missing from notifySubSysAudit", subSys)
}
}
for _, a := range notifySubSysAudit {
t.Run(a.subSys, func(t *testing.T) {
defaults := DefaultNotificationKVS[a.subSys]
registered := map[string]bool{}
for _, kv := range defaults {
registered[kv.Key] = true
}
read, ok := readKeys[a.getFn]
if !ok {
t.Fatalf("%s not found in parse.go/legacy.go", a.getFn)
}
for _, key := range sortedKeys(read) {
if !registered[key] && !slices.Contains(a.tolerated, key) {
t.Errorf("%s reads key %q (%s) which is not registered in the default KVS for %s",
a.getFn, key, read[key], a.subSys)
}
}
// Floor assertion, in the opposite direction. Every registered key
// is in fact read by every GetNotifyX today, so this holds. Its
// real job is to fail loudly if the read collector ever stops
// matching the source — a collector that silently returns nothing
// would otherwise make the check above pass vacuously.
for _, kv := range defaults {
if _, ok := read[kv.Key]; !ok {
t.Errorf("registered key %q for %s is never read by %s; either the key is dead or this audit has gone blind",
kv.Key, a.subSys, a.getFn)
}
}
// A tolerated key must genuinely be accepted by validation,
// otherwise reading it is pointless.
for _, key := range a.tolerated {
kvs := map[string]config.KVS{config.Default: {
config.KV{Key: config.Enable, Value: config.EnableOn},
config.KV{Key: key, Value: "x"},
}}
if err := checkValidNotificationKeysForSubSys(a.subSys, kvs); err != nil {
t.Errorf("tolerated key %q is still rejected for %s: %v", key, a.subSys, err)
}
}
written, ok := writtenKeys[a.setFn]
if !ok {
t.Fatalf("%s not found in parse.go/legacy.go", a.setFn)
}
allowed := knownUnregisteredWrites[a.setFn]
for _, key := range sortedKeys(written) {
if !registered[key] && !slices.Contains(allowed, key) {
t.Errorf("%s writes key %q (%s) which is not registered in the default KVS for %s",
a.setFn, key, written[key], a.subSys)
}
}
// Keep the allowlist honest: an entry that no longer corresponds to
// a real gap must be deleted, not left to rot.
for _, key := range allowed {
if registered[key] {
t.Errorf("%s: %q is registered now; remove it from knownUnregisteredWrites", a.setFn, key)
} else if _, ok := written[key]; !ok {
t.Errorf("%s no longer writes %q; remove it from knownUnregisteredWrites", a.setFn, key)
}
}
// Help entries must describe registered keys only. `comment` is
// accepted for every sub-system by CheckValidKeys and is therefore
// documented without being registered.
for _, hkv := range a.help {
if hkv.Key == config.Comment {
continue
}
if !registered[hkv.Key] {
t.Errorf("Help entry %q for %s is not registered in the default KVS", hkv.Key, a.subSys)
}
}
// Conversely: every registered key a user can set should be
// documented, so `mc admin config get` explains it. `enable` is
// implicit for every target and HiddenIfEmpty keys are deprecated
// leftovers that are deliberately undocumented.
for _, kv := range defaults {
if kv.Key == config.Enable || kv.HiddenIfEmpty {
continue
}
if _, ok := a.help.Lookup(kv.Key); !ok {
t.Errorf("registered key %q for %s has no Help entry", kv.Key, a.subSys)
}
}
})
}
}
func sortedKeys(m map[string]token.Position) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// resolveKeyExpr turns a config-key expression into its string value. Only the
// shapes actually used in this package are supported; anything else fails the
// test loudly rather than being skipped, so the audit cannot silently go blind.
func resolveKeyExpr(t *testing.T, fset *token.FileSet, src string, e ast.Expr, targetConsts map[string]string) (string, bool) {
t.Helper()
switch v := e.(type) {
case *ast.Ident:
val, ok := notifyPkgConsts[v.Name]
if !ok {
t.Errorf("%s: %s used as a config key but not listed in notifyPkgConsts; add it",
fset.Position(v.Pos()), v.Name)
return "", false
}
return val, true
case *ast.BasicLit:
if v.Kind == token.STRING {
s, err := strconv.Unquote(v.Value)
if err != nil {
t.Errorf("%s: unquoting %s: %v", fset.Position(v.Pos()), v.Value, err)
return "", false
}
return s, true
}
case *ast.SelectorExpr:
pkg, ok := v.X.(*ast.Ident)
if !ok {
break
}
switch pkg.Name {
case "target":
val, ok := targetConsts[v.Sel.Name]
if !ok {
t.Errorf("%s: cannot resolve target.%s to a string constant", fset.Position(v.Pos()), v.Sel.Name)
return "", false
}
return val, true
case "config":
val, ok := configPkgConsts[v.Sel.Name]
if !ok {
t.Errorf("%s: config.%s used as a config key but not listed in configPkgConsts; add it",
fset.Position(v.Pos()), v.Sel.Name)
return "", false
}
return val, true
}
}
t.Errorf("%s: unsupported config-key expression %T in %s; extend resolveKeyExpr", fset.Position(e.Pos()), e, src)
return "", false
}
// collectKVGetKeys finds every `.Get()` call inside fn. This is how
// GetNotifyX reads a stored config value.
func collectKVGetKeys(fset *token.FileSet, fn *ast.FuncDecl, resolve func(ast.Expr) (string, bool)) map[string]token.Position {
keys := map[string]token.Position{}
ast.Inspect(fn.Body, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok || len(call.Args) != 1 {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "Get" {
return true
}
recv, ok := sel.X.(*ast.Ident)
if !ok || recv.Name != "kv" {
return true
}
if key, ok := resolve(call.Args[0]); ok {
keys[key] = fset.Position(call.Pos())
}
return true
})
return keys
}
// collectKVWriteKeys finds every `{Key: , ...}` composite literal inside
// fn. This is how SetNotifyX writes a migrated config.
//
// Both the explicit `config.KV{...}` form and the elided `{...}` form that Go
// permits inside a `config.KVS{...}` literal must be matched: the elided form
// is idiomatic and would otherwise slip past this audit silently.
func collectKVWriteKeys(fset *token.FileSet, fn *ast.FuncDecl, resolve func(ast.Expr) (string, bool)) map[string]token.Position {
keys := map[string]token.Position{}
ast.Inspect(fn.Body, func(n ast.Node) bool {
lit, ok := n.(*ast.CompositeLit)
if !ok {
return true
}
// A typed literal that is neither config.KV nor config.KVS cannot hold
// or contain a config key. An untyped (elided) literal has no type to
// check, so it must be inspected rather than skipped.
if sel, ok := lit.Type.(*ast.SelectorExpr); ok && sel.Sel.Name != "KV" && sel.Sel.Name != "KVS" {
return true
}
for _, elt := range lit.Elts {
kve, ok := elt.(*ast.KeyValueExpr)
if !ok {
continue
}
ident, ok := kve.Key.(*ast.Ident)
if !ok || ident.Name != "Key" {
continue
}
if key, ok := resolve(kve.Value); ok {
keys[key] = fset.Position(lit.Pos())
}
}
return true
})
return keys
}
// parseTargetPkgStringConsts reads every `Name = "literal"` constant from the
// internal/event/target sources. Those constants are the single source of truth
// for both config keys and env var names.
func parseTargetPkgStringConsts(dir string) (map[string]string, error) {
fset := token.NewFileSet()
pkgs, err := parser.ParseDir(fset, filepath.Clean(dir), func(fi fs.FileInfo) bool {
return !strings.HasSuffix(fi.Name(), "_test.go")
}, 0)
if err != nil {
return nil, err
}
out := map[string]string{}
for _, pkg := range pkgs {
for _, f := range pkg.Files {
for _, decl := range f.Decls {
gd, ok := decl.(*ast.GenDecl)
if !ok || gd.Tok != token.CONST {
continue
}
for _, spec := range gd.Specs {
vs, ok := spec.(*ast.ValueSpec)
if !ok || len(vs.Names) != 1 || len(vs.Values) != 1 {
continue
}
lit, ok := vs.Values[0].(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
continue
}
s, err := strconv.Unquote(lit.Value)
if err != nil {
continue
}
out[vs.Names[0].Name] = s
}
}
}
}
if len(out) == 0 {
return nil, errNoTargetConsts
}
return out, nil
}
var errNoTargetConsts = errConst("no string constants found in internal/event/target; the audit would be vacuous")
type errConst string
func (e errConst) Error() string { return string(e) }
// Sanity check for the resolver itself: if this ever stops finding known
// constants the audit above would pass vacuously.
func TestParseTargetPkgStringConsts(t *testing.T) {
consts, err := parseTargetPkgStringConsts("../../event/target")
if err != nil {
t.Fatal(err)
}
for name, want := range map[string]string{
"NATSAddress": "address",
"NATSNKeySeed": "nkey_seed",
"AmqpImmediate": "immediate",
"EnvAMQPImmediate": "MINIO_NOTIFY_AMQP_IMMEDIATE",
} {
if got := consts[name]; got != want {
t.Errorf("target.%s = %q, want %q", name, got, want)
}
}
if slices.Contains([]string{""}, consts["NATSSubject"]) {
t.Error("target.NATSSubject resolved to empty string")
}
}