diff --git a/go.mod b/go.mod index 3b4b50d7ed6..5330c35b5f2 100644 --- a/go.mod +++ b/go.mod @@ -46,6 +46,7 @@ replace github.com/MoeForge/moego v0.1.0-alpha.1 => github.com/router-for-me/moe require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dlclark/regexp2/v2 v2.5.1 // indirect + github.com/nats-io/nkeys v0.4.17 // indirect github.com/pion/datachannel v1.6.2 // indirect github.com/pion/dtls/v3 v3.1.5 // indirect github.com/pion/logging v0.2.4 // indirect diff --git a/go.sum b/go.sum index 8873b5cdf17..b5d9c6b697c 100644 --- a/go.sum +++ b/go.sum @@ -155,6 +155,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/nats-io/nkeys v0.4.17 h1:JAJrlDhOyQqOt0a1eg4cbIZ1Kc7+vmkCigAltpwQpVA= +github.com/nats-io/nkeys v0.4.17/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs= github.com/pelletier/go-toml/v2 v2.4.3-0.20260624044523-35f78d5908f1 h1:gIZQQ2+wbsdQGWLPDSdJFptR25tBCjSC0KbSIQZ1rgE= github.com/pelletier/go-toml/v2 v2.4.3-0.20260624044523-35f78d5908f1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pierrec/xxHash v0.1.5 h1:n/jBpwTHiER4xYvK3/CdPVnLDPchj8eTJFFLUb4QHBo= diff --git a/internal/store/natsstore/secret.go b/internal/store/natsstore/secret.go new file mode 100644 index 00000000000..e7380ef3e36 --- /dev/null +++ b/internal/store/natsstore/secret.go @@ -0,0 +1,63 @@ +// Package natsstore holds the NATS JetStream connection, settings and bucket +// bootstrap that back the NATS token store. +package natsstore + +import ( + "fmt" + "strings" +) + +const redacted = "[redacted]" + +// Secret wraps a credential so it never reaches logs, errors or serialized +// output by accident. Only Reveal returns the value. +type Secret struct { + value string +} + +// NewSecret wraps value, reporting false for an empty one because empty counts as unset. +func NewSecret(value string) (Secret, bool) { + if value == "" { + return Secret{}, false + } + return Secret{value: value}, true +} + +// Reveal returns the wrapped value for the one place that hands it to the NATS client. +func (s Secret) Reveal() string { return s.value } + +func (s Secret) String() string { return redacted } + +func (s Secret) GoString() string { return redacted } + +// Format redacts the secret under every fmt verb, including %v and %+v on a struct holding it. +func (s Secret) Format(f fmt.State, _ rune) { _, _ = f.Write([]byte(redacted)) } + +func (s Secret) MarshalText() ([]byte, error) { return []byte(redacted), nil } + +func (s Secret) MarshalJSON() ([]byte, error) { return []byte(`"` + redacted + `"`), nil } + +// redactServer strips URL userinfo so a server address is safe to log. +func redactServer(server string) string { + rest := server + scheme := "" + if i := strings.Index(rest, "://"); i >= 0 { + scheme, rest = rest[:i+3], rest[i+3:] + } + host := rest + if i := strings.IndexAny(rest, "/?#"); i >= 0 { + host = rest[:i] + } + if at := strings.LastIndex(host, "@"); at >= 0 { + return scheme + redacted + "@" + rest[at+1:] + } + return server +} + +func redactServers(servers []string) []string { + out := make([]string, len(servers)) + for i, server := range servers { + out[i] = redactServer(server) + } + return out +} diff --git a/internal/store/natsstore/settings.go b/internal/store/natsstore/settings.go new file mode 100644 index 00000000000..7a30ab6cc6c --- /dev/null +++ b/internal/store/natsstore/settings.go @@ -0,0 +1,441 @@ +package natsstore + +import ( + "errors" + "fmt" + "io/fs" + "os" + "strings" + + "github.com/nats-io/nkeys" +) + +// Connection environment variables, named and read the way the nats CLI does. +const ( + EnvURL = "NATS_URL" + EnvCreds = "NATS_CREDS" + EnvNKey = "NATS_NKEY" + EnvUser = "NATS_USER" + EnvPassword = "NATS_PASSWORD" + EnvToken = "NATS_TOKEN" + EnvJWT = "NATS_JWT" + EnvSeed = "NATS_SEED" + EnvCert = "NATS_CERT" + EnvKey = "NATS_KEY" + EnvCA = "NATS_CA" + + // DefaultURL is used when NATS_URL is unset. + DefaultURL = "localhost:4222" +) + +// seedPrefixes are the prefixes of operator, account and user NKey seeds. +var seedPrefixes = []string{"SO", "SA", "SU"} + +// Lookup reads one environment variable, reporting whether it is set. +type Lookup func(key string) (string, bool) + +// OSLookup reads the process environment. +func OSLookup(key string) (string, bool) { return os.LookupEnv(key) } + +// SettingError names the setting a problem belongs to without ever carrying a secret value. +type SettingError struct { + Setting string + Err error +} + +func (e *SettingError) Error() string { return fmt.Sprintf("invalid %s: %v", e.Setting, e.Err) } + +func (e *SettingError) Unwrap() error { return e.Err } + +// ErrUnsupportedScheme marks a reference scheme this package does not resolve. +var ErrUnsupportedScheme = errors.New("unsupported scheme") + +func unsupportedScheme(scheme string) error { + return fmt.Errorf("%w `%s`", ErrUnsupportedScheme, scheme) +} + +// ioReason drops the path from a filesystem error, keeping only the cause. +func ioReason(err error) error { + var pathErr *fs.PathError + if errors.As(err, &pathErr) { + return pathErr.Err + } + return err +} + +// splitScheme splits `scheme:rest`, dropping a leading `//` from rest. It returns +// false when the prefix is not a URI scheme, so `C:\x` and bare values stay literal. +func splitScheme(raw string) (scheme, rest string, ok bool) { + end := strings.IndexByte(raw, ':') + if end < 0 { + return "", "", false + } + scheme = raw[:end] + if len(scheme) < 2 || !isAlpha(scheme[0]) { + return "", "", false + } + for i := 1; i < len(scheme); i++ { + c := scheme[i] + if !isAlpha(c) && !(c >= '0' && c <= '9') && c != '+' && c != '-' && c != '.' { + return "", "", false + } + } + rest = raw[end+1:] + return scheme, strings.TrimPrefix(rest, "//"), true +} + +func isAlpha(c byte) bool { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') } + +// Location is a filesystem location written as a bare path or as file://path. +type Location struct { + path string + scheme string +} + +// ParseLocation parses raw, reporting false for an empty value. +func ParseLocation(raw string) (Location, bool) { + if raw == "" { + return Location{}, false + } + scheme, rest, ok := splitScheme(raw) + switch { + case !ok: + return Location{path: raw}, true + case strings.EqualFold(scheme, "file"): + return Location{path: rest}, true + default: + return Location{scheme: strings.ToLower(scheme)}, true + } +} + +// Path returns the filesystem path, or an error when the scheme is not file. +func (l Location) Path() (string, error) { + if l.scheme != "" { + return "", unsupportedScheme(l.scheme) + } + return l.path, nil +} + +// readSeed reads an NKey seed from a plain or decorated seed file (including a +// .creds file), taking the first line with a seed prefix. Errors never echo the +// path, because a mistyped inline seed is read as a path. +func (l Location) readSeed() (string, error) { + path, err := l.Path() + if err != nil { + return "", err + } + contents, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("cannot read the NKey seed file: %w", ioReason(err)) + } + for _, line := range strings.Split(string(contents), "\n") { + line = strings.TrimSpace(line) + if !hasSeedPrefix(line) { + continue + } + if _, err := nkeys.FromSeed([]byte(line)); err != nil { + return "", fmt.Errorf("invalid NKey seed: %w", err) + } + return line, nil + } + return "", errors.New("no NKey seed found in the NKey seed file") +} + +func hasSeedPrefix(line string) bool { + for _, prefix := range seedPrefixes { + if strings.HasPrefix(line, prefix) { + return true + } + } + return false +} + +// NKeySeed is an NKey seed given inline or as a seed file location. A value that +// parses as a seed is used inline; anything else is a bare or file:// path, which +// is how the nats CLI reads NATS_NKEY. +type NKeySeed struct { + inline Secret + file Location + isFile bool +} + +// ParseNKeySeed parses raw, reporting false for an empty value. +func ParseNKeySeed(raw string) (NKeySeed, bool) { + trimmed := strings.TrimSpace(raw) + if _, err := nkeys.FromSeed([]byte(trimmed)); err == nil { + inline, ok := NewSecret(trimmed) + return NKeySeed{inline: inline}, ok + } + location, ok := ParseLocation(trimmed) + return NKeySeed{file: location, isFile: true}, ok +} + +func (s NKeySeed) resolve() (string, error) { + if s.isFile { + return s.file.readSeed() + } + return s.inline.Reveal(), nil +} + +func (s NKeySeed) String() string { return "NKeySeed(" + redacted + ")" } + +func (s NKeySeed) GoString() string { return s.String() } + +func (s NKeySeed) Format(f fmt.State, _ rune) { _, _ = f.Write([]byte(s.String())) } + +type credentialKind int + +const ( + credentialLiteral credentialKind = iota + credentialFile + credentialEnv + credentialUnsupported +) + +// CredentialValue is a configured value that is a literal or a reference to where +// it lives (file://path, env://NAME). +type CredentialValue struct { + literal Secret + file string + env string + scheme string + kind credentialKind +} + +// ParseCredentialValue parses raw, reporting false for an empty value. +func ParseCredentialValue(raw string) (CredentialValue, bool) { + if scheme, rest, ok := splitScheme(raw); ok { + switch { + case strings.EqualFold(scheme, "file"): + return CredentialValue{kind: credentialFile, file: rest}, true + case strings.EqualFold(scheme, "env"): + return CredentialValue{kind: credentialEnv, env: rest}, true + case strings.EqualFold(scheme, "op"), strings.EqualFold(scheme, "nsc"), strings.EqualFold(scheme, "data"): + return CredentialValue{kind: credentialUnsupported, scheme: strings.ToLower(scheme)}, true + } + } + literal, ok := NewSecret(raw) + return CredentialValue{literal: literal}, ok +} + +func (v CredentialValue) resolve(env Lookup) (string, error) { + switch v.kind { + case credentialFile: + contents, err := os.ReadFile(v.file) + if err != nil { + return "", fmt.Errorf("cannot read %s: %w", v.file, ioReason(err)) + } + return strings.TrimRight(string(contents), "\r\n"), nil + case credentialEnv: + value, ok := env(v.env) + if !ok { + return "", fmt.Errorf("environment variable `%s` is not set", v.env) + } + return value, nil + case credentialUnsupported: + return "", unsupportedScheme(v.scheme) + default: + return v.literal.Reveal(), nil + } +} + +func (v CredentialValue) String() string { + switch v.kind { + case credentialFile: + return "file://" + v.file + case credentialEnv: + return "env://" + v.env + case credentialUnsupported: + return v.scheme + "://" + default: + return redacted + } +} + +func (v CredentialValue) GoString() string { return v.String() } + +func (v CredentialValue) Format(f fmt.State, _ rune) { _, _ = f.Write([]byte(v.String())) } + +// Auth is the authentication method, chosen exclusively by Settings.Resolve. +type Auth interface { + // Description names the method for logs, never carrying a value. + Description() string + isAuth() +} + +type ( + // CredentialsAuth authenticates with a user credentials (.creds) file. + CredentialsAuth struct{ File Location } + // NKeyAuth authenticates with an NKey seed. + NKeyAuth struct{ Seed NKeySeed } + // UserPasswordAuth authenticates with a user and password. + UserPasswordAuth struct { + User string + Password CredentialValue + } + // JWTAuth authenticates with a user JWT, signing the nonce with Seed when present. + JWTAuth struct { + JWT CredentialValue + Seed *CredentialValue + } + // TokenAuth authenticates with a token. + TokenAuth struct{ Token CredentialValue } + // NoAuth connects without credentials. + NoAuth struct{} +) + +func (CredentialsAuth) Description() string { return "credentials file" } +func (NKeyAuth) Description() string { return "NKey" } +func (UserPasswordAuth) Description() string { return "user/password" } +func (a JWTAuth) Description() string { + if a.Seed == nil { + return "JWT without signature" + } + return "JWT" +} +func (TokenAuth) Description() string { return "token" } +func (NoAuth) Description() string { return "none" } + +func (CredentialsAuth) isAuth() {} +func (NKeyAuth) isAuth() {} +func (UserPasswordAuth) isAuth() {} +func (JWTAuth) isAuth() {} +func (TokenAuth) isAuth() {} +func (NoAuth) isAuth() {} + +// TLS holds client TLS material. A client certificate needs both its certificate +// and key, and setting either alone is reported instead of ignored. +type TLS struct { + Cert *Location + Key *Location + CA *Location +} + +// Configured reports whether any TLS setting is present. +func (t TLS) Configured() bool { return t.Cert != nil || t.Key != nil || t.CA != nil } + +// clientCertificate returns the cert and key pair, nil when neither is set. +func (t TLS) clientCertificate() (*[2]Location, error) { + switch { + case t.Cert != nil && t.Key != nil: + return &[2]Location{*t.Cert, *t.Key}, nil + case t.Cert != nil: + return nil, &SettingError{Setting: EnvCert, Err: fmt.Errorf("must be set together with %s", EnvKey)} + case t.Key != nil: + return nil, &SettingError{Setting: EnvKey, Err: fmt.Errorf("must be set together with %s", EnvCert)} + } + return nil, nil +} + +// Config is the resolved connection configuration. +type Config struct { + Servers []string + Auth Auth + TLS TLS +} + +// Settings holds the raw, unparsed connection inputs. Resolve is the single place +// that decides which authentication method wins, with this precedence: +// 1. Creds +// 2. NKey +// 3. User + Password +// 4. Token, or User when it is set without a Password +// 5. JWT (signed with Seed when present) +// +// Empty values count as unset. +type Settings struct { + URL string + User string + Password Secret + Token Secret + Creds string + NKey Secret + JWT Secret + Seed Secret + Cert string + Key string + CA string +} + +// SettingsFromEnv reads the connection settings from the environment variables the nats CLI reads. +func SettingsFromEnv(env Lookup) Settings { + read := func(key string) string { + value, _ := env(key) + return value + } + secret := func(key string) Secret { + value, _ := NewSecret(read(key)) + return value + } + return Settings{ + URL: read(EnvURL), + User: read(EnvUser), + Password: secret(EnvPassword), + Token: secret(EnvToken), + Creds: read(EnvCreds), + NKey: secret(EnvNKey), + JWT: secret(EnvJWT), + Seed: secret(EnvSeed), + Cert: read(EnvCert), + Key: read(EnvKey), + CA: read(EnvCA), + } +} + +// Resolve applies the precedence and returns the connection configuration. +func (s Settings) Resolve() Config { + return Config{Servers: s.servers(), Auth: s.auth(), TLS: s.tls()} +} + +func (s Settings) auth() Auth { + if location, ok := ParseLocation(s.Creds); ok { + return CredentialsAuth{File: location} + } + if seed, ok := ParseNKeySeed(s.NKey.Reveal()); ok { + return NKeyAuth{Seed: seed} + } + if s.User != "" && s.Password.Reveal() != "" { + if password, ok := ParseCredentialValue(s.Password.Reveal()); ok { + return UserPasswordAuth{User: s.User, Password: password} + } + } + token := s.Token.Reveal() + if token == "" && s.Password.Reveal() == "" { + token = s.User + } + if value, ok := ParseCredentialValue(token); ok { + return TokenAuth{Token: value} + } + if jwt, ok := ParseCredentialValue(s.JWT.Reveal()); ok { + auth := JWTAuth{JWT: jwt} + if seed, ok := ParseCredentialValue(s.Seed.Reveal()); ok { + auth.Seed = &seed + } + return auth + } + return NoAuth{} +} + +func (s Settings) tls() TLS { + location := func(raw string) *Location { + if l, ok := ParseLocation(raw); ok { + return &l + } + return nil + } + return TLS{Cert: location(s.Cert), Key: location(s.Key), CA: location(s.CA)} +} + +func (s Settings) servers() []string { + url := s.URL + if url == "" { + url = DefaultURL + } + var servers []string + for _, server := range strings.Split(url, ",") { + if server = strings.TrimSpace(server); server != "" { + servers = append(servers, server) + } + } + return servers +} diff --git a/internal/store/natsstore/settings_test.go b/internal/store/natsstore/settings_test.go new file mode 100644 index 00000000000..22c77b4d73e --- /dev/null +++ b/internal/store/natsstore/settings_test.go @@ -0,0 +1,254 @@ +package natsstore + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/nats-io/nkeys" +) + +func mapEnv(values map[string]string) Lookup { + return func(key string) (string, bool) { + value, ok := values[key] + return value, ok + } +} + +func newSeed(t *testing.T) string { + t.Helper() + pair, err := nkeys.CreateUser() + if err != nil { + t.Fatal(err) + } + seed, err := pair.Seed() + if err != nil { + t.Fatal(err) + } + return string(seed) +} + +func TestSettingsServers(t *testing.T) { + tests := []struct { + name string + url string + want []string + }{ + {"default", "", []string{DefaultURL}}, + {"single", "nats://a:4222", []string{"nats://a:4222"}}, + {"list trimmed", " nats://a:4222 , nats://b:4222,, ", []string{"nats://a:4222", "nats://b:4222"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := SettingsFromEnv(mapEnv(map[string]string{EnvURL: tt.url})).Resolve().Servers + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("servers = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSettingsAuthPrecedence(t *testing.T) { + seed := newSeed(t) + all := map[string]string{ + EnvCreds: "/run/creds/user.creds", + EnvNKey: seed, + EnvUser: "alice", + EnvPassword: "pw", + EnvToken: "tok", + EnvJWT: "jwt", + EnvSeed: seed, + } + steps := []struct { + drop string + want string + }{ + {"", "credentials file"}, + {EnvCreds, "NKey"}, + {EnvNKey, "user/password"}, + {EnvPassword, "token"}, + {EnvToken, "token"}, // user without a password is used as a token + {EnvUser, "JWT"}, + {EnvSeed, "JWT without signature"}, + {EnvJWT, "none"}, + } + env := map[string]string{} + for k, v := range all { + env[k] = v + } + for _, step := range steps { + delete(env, step.drop) + got := SettingsFromEnv(mapEnv(env)).Resolve().Auth.Description() + if got != step.want { + t.Fatalf("after dropping %q: auth = %q, want %q", step.drop, got, step.want) + } + } +} + +func TestSettingsEmptyValuesCountAsUnset(t *testing.T) { + env := map[string]string{EnvCreds: "", EnvNKey: "", EnvUser: "", EnvPassword: "", EnvToken: "", EnvJWT: ""} + cfg := SettingsFromEnv(mapEnv(env)).Resolve() + if _, ok := cfg.Auth.(NoAuth); !ok { + t.Fatalf("auth = %T, want NoAuth", cfg.Auth) + } + if cfg.TLS.Configured() { + t.Fatal("TLS configured from empty values") + } +} + +func TestSettingsNKeyAcceptsInlineSeedOrFile(t *testing.T) { + seed := newSeed(t) + path := filepath.Join(t.TempDir(), "user.creds") + creds := "-----BEGIN NATS USER JWT-----\neyJ.fake.jwt\n------END NATS USER JWT------\n\n-----BEGIN USER NKEY SEED-----\n" + seed + "\n------END USER NKEY SEED------\n" + if err := os.WriteFile(path, []byte(creds), 0o600); err != nil { + t.Fatal(err) + } + for name, raw := range map[string]string{"inline": seed, "path": path, "file url": "file://" + path} { + t.Run(name, func(t *testing.T) { + parsed, ok := ParseNKeySeed(raw) + if !ok { + t.Fatal("not parsed") + } + got, err := parsed.resolve() + if err != nil { + t.Fatal(err) + } + if got != seed { + t.Fatal("resolved seed differs") + } + }) + } +} + +func TestNKeySeedFileErrorsDoNotEchoThePath(t *testing.T) { + const mistyped = "SUAMISTYPEDINLINESEEDTHATISNOTVALID" + parsed, _ := ParseNKeySeed(mistyped) + _, err := parsed.resolve() + if err == nil { + t.Fatal("expected an error") + } + if strings.Contains(err.Error(), mistyped) { + t.Fatalf("error echoes the value: %v", err) + } +} + +func TestCredentialValueReferences(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "token") + if err := os.WriteFile(path, []byte("from-file\n"), 0o600); err != nil { + t.Fatal(err) + } + env := mapEnv(map[string]string{"TOKEN_VAR": "from-env"}) + tests := []struct { + raw string + want string + wantErr error + }{ + {raw: "literal", want: "literal"}, + {raw: "file://" + path, want: "from-file"}, + {raw: "env://TOKEN_VAR", want: "from-env"}, + {raw: "op://vault/item", wantErr: ErrUnsupportedScheme}, + {raw: "nsc://op/acct/user", wantErr: ErrUnsupportedScheme}, + {raw: "data:abc", wantErr: ErrUnsupportedScheme}, + } + for _, tt := range tests { + value, ok := ParseCredentialValue(tt.raw) + if !ok { + t.Fatalf("%q not parsed", tt.raw) + } + got, err := value.resolve(env) + if tt.wantErr != nil { + if !errors.Is(err, tt.wantErr) { + t.Fatalf("%q: err = %v, want %v", tt.raw, err, tt.wantErr) + } + continue + } + if err != nil || got != tt.want { + t.Fatalf("%q: got %q, %v; want %q", tt.raw, got, err, tt.want) + } + } + if _, err := mustCredential(t, "env://UNSET_VAR").resolve(env); err == nil { + t.Fatal("unset environment reference resolved") + } + if _, ok := ParseCredentialValue(""); ok { + t.Fatal("empty value parsed") + } +} + +func mustCredential(t *testing.T, raw string) CredentialValue { + t.Helper() + value, ok := ParseCredentialValue(raw) + if !ok { + t.Fatalf("%q not parsed", raw) + } + return value +} + +func TestSecretsAreRedactedEverywhere(t *testing.T) { + const ( + password = "hunter2-password-value" + token = "tok-secret-value" + jwt = "jwt-secret-value" + ) + seed := newSeed(t) + settings := SettingsFromEnv(mapEnv(map[string]string{ + EnvURL: "nats://svc:urlpass@host:4222", + EnvUser: "alice", + EnvPassword: password, + EnvToken: token, + EnvJWT: jwt, + EnvNKey: seed, + EnvSeed: seed, + })) + cfg := settings.Resolve() + auths := []Auth{ + cfg.Auth, + UserPasswordAuth{User: "alice", Password: mustCredential(t, password)}, + TokenAuth{Token: mustCredential(t, token)}, + JWTAuth{JWT: mustCredential(t, jwt), Seed: ptr(mustCredential(t, seed))}, + NKeyAuth{Seed: func() NKeySeed { s, _ := ParseNKeySeed(seed); return s }()}, + } + secrets := []string{password, token, jwt, seed} + for _, subject := range []any{settings, cfg, auths} { + for _, format := range []string{"%v", "%+v", "%#v", "%s"} { + out := fmt.Sprintf(format, subject) + for _, secret := range secrets { + if strings.Contains(out, secret) { + t.Fatalf("%s of %T leaks a secret", format, subject) + } + } + } + } + encoded, err := json.Marshal(settings) + if err != nil { + t.Fatal(err) + } + for _, secret := range secrets { + if strings.Contains(string(encoded), secret) { + t.Fatal("JSON of settings leaks a secret") + } + } +} + +func ptr[T any](v T) *T { return &v } + +func TestRedactServer(t *testing.T) { + tests := map[string]string{ + "nats://host:4222": "nats://host:4222", + "host:4222": "host:4222", + "nats://user:pass@host:4222": "nats://[redacted]@host:4222", + "nats://tok@host:4222": "nats://[redacted]@host:4222", + "user:pass@host:4222": "[redacted]@host:4222", + "tls://u:p@host:4222/path?x=y": "tls://[redacted]@host:4222/path?x=y", + } + for in, want := range tests { + if got := redactServer(in); got != want { + t.Errorf("redactServer(%q) = %q, want %q", in, got, want) + } + } +}