chore: update golangci-lint to v2.12.2 with canonical config (#54)
All checks were successful
check / check (push) Successful in 4s

Canonical v2-schema `.golangci.yml`, golangci-lint pins bumped to v2.12.2 in `Dockerfile` and `script/bootstrap`, and the tree brought to `0 issues.` under it.

Three behaviour deltas: `Cache.StoreVariant` takes a context (cancelled requests skip the accounting row, recovered by reconciliation); `MetadataStorage.Store` no longer leaks `.tmp-*.json` on Write/Close/Rename failure (dead-defer bug fix); the `signing_key` too-short error text gained a `value too short:` prefix.

Eviction-loop context cancellation deferred to #102.
This commit was merged in pull request #54.
This commit is contained in:
2026-08-10 16:12:22 +02:00
parent 63fbc98e63
commit 2d805125ee
61 changed files with 3550 additions and 2472 deletions

View File

@@ -2,7 +2,6 @@ package config
import (
"errors"
"io"
"log/slog"
"os"
"path/filepath"
@@ -10,10 +9,16 @@ import (
"testing"
)
// Static errors returned by the stub free-space probes below.
var (
errTestStatfsFailed = errors.New("statfs failed")
errTestProbeNotExpected = errors.New("probe must not be called")
)
// discardLogger returns a logger that swallows all output, for tests
// that exercise code paths which log.
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
return slog.New(slog.DiscardHandler)
}
// TestCacheMaxBytesExplicitValueUsedWithoutFloor verifies that an
@@ -21,6 +26,8 @@ func discardLogger() *slog.Logger {
// given: the 500 MiB floor applies only to the computed default, never
// to explicit values.
func TestCacheMaxBytesExplicitValueUsedWithoutFloor(t *testing.T) {
t.Parallel()
yamlContent := "signing_key: " + validTestSigningKey + "\ncache_max_bytes: 1024\n"
c, err := configFromYAML(t, yamlContent)
@@ -38,6 +45,8 @@ func TestCacheMaxBytesExplicitValueUsedWithoutFloor(t *testing.T) {
// explicit zero is a valid value (it disables the disk cache), not an
// error.
func TestCacheMaxBytesZeroIsValidAndDisablesCache(t *testing.T) {
t.Parallel()
yamlContent := "signing_key: " + validTestSigningKey + "\ncache_max_bytes: 0\n"
c, err := configFromYAML(t, yamlContent)
@@ -53,7 +62,10 @@ func TestCacheMaxBytesZeroIsValidAndDisablesCache(t *testing.T) {
// TestCacheMaxBytesLargeExplicitValueParses verifies that values above
// 32-bit range parse correctly (the field is an int64 byte count).
func TestCacheMaxBytesLargeExplicitValueParses(t *testing.T) {
yamlContent := "signing_key: " + validTestSigningKey + "\ncache_max_bytes: 10737418240\n"
t.Parallel()
yamlContent := "signing_key: " + validTestSigningKey +
"\ncache_max_bytes: 10737418240\n"
c, err := configFromYAML(t, yamlContent)
if err != nil {
@@ -70,6 +82,8 @@ func TestCacheMaxBytesLargeExplicitValueParses(t *testing.T) {
// offending value, per the no-silent-fallback rule: defaults apply
// only to omitted keys.
func TestCacheMaxBytesInvalidValuesAbortStartup(t *testing.T) {
t.Parallel()
signingKeyLine := "signing_key: " + validTestSigningKey + "\n"
cases := []struct {
@@ -81,45 +95,48 @@ func TestCacheMaxBytesInvalidValuesAbortStartup(t *testing.T) {
{
name: "negative",
yaml: signingKeyLine + "cache_max_bytes: -1024\n",
wantErrSubstrings: []string{"cache_max_bytes", "-1024"},
wantErrSubstrings: []string{keyCacheMaxBytes, "-1024"},
},
{
name: "float",
yaml: signingKeyLine + "cache_max_bytes: 3.5\n",
wantErrSubstrings: []string{"cache_max_bytes", "3.5"},
wantErrSubstrings: []string{keyCacheMaxBytes, "3.5"},
},
{
name: "non-numeric string",
yaml: signingKeyLine + "cache_max_bytes: banana\n",
wantErrSubstrings: []string{"cache_max_bytes", "banana"},
wantErrSubstrings: []string{keyCacheMaxBytes, "banana"},
},
{
name: "explicit null",
yaml: signingKeyLine + "cache_max_bytes: null\n",
wantErrSubstrings: []string{"cache_max_bytes", "null"},
wantErrSubstrings: []string{keyCacheMaxBytes, "null"},
},
{
name: "bare key no value",
yaml: signingKeyLine + "cache_max_bytes:\n",
wantErrSubstrings: []string{"cache_max_bytes", "null"},
wantErrSubstrings: []string{keyCacheMaxBytes, "null"},
},
{
name: "boolean",
yaml: signingKeyLine + "cache_max_bytes: true\n",
wantErrSubstrings: []string{"cache_max_bytes", "true"},
wantErrSubstrings: []string{keyCacheMaxBytes, "true"},
},
{
name: "list",
yaml: signingKeyLine + "cache_max_bytes:\n - 1\n",
wantErrSubstrings: []string{"cache_max_bytes"},
wantErrSubstrings: []string{keyCacheMaxBytes},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
c, err := configFromYAML(t, tc.yaml)
if err == nil {
t.Fatalf("config with %s cache_max_bytes must abort startup, got config: %+v",
t.Fatalf(
"config with %s cache_max_bytes must abort startup, got config: %+v",
tc.name, c)
}
@@ -138,6 +155,8 @@ func TestCacheMaxBytesInvalidValuesAbortStartup(t *testing.T) {
// computed default is 75% of the probed free space when that exceeds
// the floor.
func TestComputeDefaultCacheMaxBytesUses75PercentOfFreeSpace(t *testing.T) {
t.Parallel()
// 4 GiB free -> 3 GiB default.
probe := func(string) (uint64, error) { return 4294967296, nil }
@@ -147,7 +166,8 @@ func TestComputeDefaultCacheMaxBytesUses75PercentOfFreeSpace(t *testing.T) {
}
if got != 3221225472 {
t.Errorf("ComputeDefaultCacheMaxBytes = %d, want 3221225472 (75%% of 4 GiB)", got)
t.Errorf("ComputeDefaultCacheMaxBytes = %d, want 3221225472 (75%% of 4 GiB)",
got)
}
}
@@ -155,6 +175,8 @@ func TestComputeDefaultCacheMaxBytesUses75PercentOfFreeSpace(t *testing.T) {
// verifies that when 75% of free space is below 500 MiB, the computed
// default is floored at DefaultCacheMaxBytesFloor.
func TestComputeDefaultCacheMaxBytesAppliesFloorToComputedDefault(t *testing.T) {
t.Parallel()
cases := []struct {
name string
freeBytes uint64
@@ -166,6 +188,8 @@ func TestComputeDefaultCacheMaxBytesAppliesFloorToComputedDefault(t *testing.T)
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
probe := func(string) (uint64, error) { return tc.freeBytes, nil }
got, err := ComputeDefaultCacheMaxBytes(t.TempDir(), probe)
@@ -185,7 +209,9 @@ func TestComputeDefaultCacheMaxBytesAppliesFloorToComputedDefault(t *testing.T)
// failing free-space probe produces an error naming the config key,
// instead of a silently wrong default.
func TestComputeDefaultCacheMaxBytesPropagatesProbeError(t *testing.T) {
probe := func(string) (uint64, error) { return 0, errors.New("statfs failed") }
t.Parallel()
probe := func(string) (uint64, error) { return 0, errTestStatfsFailed }
_, err := ComputeDefaultCacheMaxBytes(t.TempDir(), probe)
if err == nil {
@@ -194,7 +220,7 @@ func TestComputeDefaultCacheMaxBytesPropagatesProbeError(t *testing.T) {
t.Logf("got expected error: %v", err)
if !strings.Contains(err.Error(), "cache_max_bytes") {
if !strings.Contains(err.Error(), keyCacheMaxBytes) {
t.Errorf("error %q does not name the config key cache_max_bytes", err.Error())
}
}
@@ -205,6 +231,8 @@ func TestComputeDefaultCacheMaxBytesPropagatesProbeError(t *testing.T) {
// first so statfs measures the right filesystem), and that the result
// lands on the Config.
func TestResolveCacheMaxBytesComputesDefaultWhenOmitted(t *testing.T) {
t.Parallel()
c, err := configFromYAML(t, "signing_key: "+validTestSigningKey+"\n")
if err != nil {
t.Fatalf("minimal config should be valid, got error: %v", err)
@@ -222,16 +250,19 @@ func TestResolveCacheMaxBytesComputesDefaultWhenOmitted(t *testing.T) {
return 4294967296, nil
}
if err := c.resolveCacheMaxBytes(discardLogger(), probe); err != nil {
err = c.resolveCacheMaxBytes(discardLogger(), probe)
if err != nil {
t.Fatalf("resolveCacheMaxBytes returned error: %v", err)
}
if c.CacheMaxBytes != 3221225472 {
t.Errorf("CacheMaxBytes = %d, want computed default 3221225472", c.CacheMaxBytes)
t.Errorf("CacheMaxBytes = %d, want computed default 3221225472",
c.CacheMaxBytes)
}
if probedPath != wantCacheDir {
t.Errorf("free space probed at %q, want cache directory %q", probedPath, wantCacheDir)
t.Errorf("free space probed at %q, want cache directory %q",
probedPath, wantCacheDir)
}
info, err := os.Stat(wantCacheDir)
@@ -245,6 +276,8 @@ func TestResolveCacheMaxBytesComputesDefaultWhenOmitted(t *testing.T) {
// an explicitly configured value survives resolution untouched and
// that the free-space probe is never consulted for it.
func TestResolveCacheMaxBytesDoesNotOverrideExplicitValue(t *testing.T) {
t.Parallel()
yamlContent := "signing_key: " + validTestSigningKey + "\ncache_max_bytes: 1024\n"
c, err := configFromYAML(t, yamlContent)
@@ -257,10 +290,11 @@ func TestResolveCacheMaxBytesDoesNotOverrideExplicitValue(t *testing.T) {
probe := func(string) (uint64, error) {
t.Error("free-space probe must not be consulted for explicit values")
return 0, errors.New("probe must not be called")
return 0, errTestProbeNotExpected
}
if err := c.resolveCacheMaxBytes(discardLogger(), probe); err != nil {
err = c.resolveCacheMaxBytes(discardLogger(), probe)
if err != nil {
t.Fatalf("resolveCacheMaxBytes returned error: %v", err)
}

View File

@@ -36,15 +36,17 @@ type FreeSpaceProbeFunc func(path string) (uint64, error)
// the given path, as available to unprivileged processes.
func defaultFreeSpaceProbe(path string) (uint64, error) {
var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil {
err := syscall.Statfs(path, &stat)
if err != nil {
return 0, err
}
if stat.Bsize < 0 {
return 0, fmt.Errorf("statfs reported negative block size %d for %q", stat.Bsize, path)
return 0, fmt.Errorf("%w %d for %q", errNegativeBlockSize, stat.Bsize, path)
}
blockSize := uint64(stat.Bsize) //nolint:gosec // G115: negative Bsize rejected above
blockSize := uint64(stat.Bsize)
return stat.Bavail * blockSize, nil
}
@@ -52,7 +54,9 @@ func defaultFreeSpaceProbe(path string) (uint64, error) {
// ComputeDefaultCacheMaxBytes returns the default cache size limit for
// the filesystem containing cacheDir: 75% of the free bytes reported
// by probe, with a floor of DefaultCacheMaxBytesFloor.
func ComputeDefaultCacheMaxBytes(cacheDir string, probe FreeSpaceProbeFunc) (int64, error) {
func ComputeDefaultCacheMaxBytes(
cacheDir string, probe FreeSpaceProbeFunc,
) (int64, error) {
freeBytes, err := probe(cacheDir)
if err != nil {
return 0, fmt.Errorf("config key %q: cannot determine free space for %q: %w",
@@ -60,15 +64,14 @@ func ComputeDefaultCacheMaxBytes(cacheDir string, probe FreeSpaceProbeFunc) (int
}
computed := freeBytes / freeSpaceFractionDenominator * freeSpaceFractionNumerator
if computed > math.MaxInt64 {
computed = math.MaxInt64
}
computed = min(computed, math.MaxInt64)
limit := int64(computed) //nolint:gosec // G115: clamped to MaxInt64 above
if limit < DefaultCacheMaxBytesFloor {
limit = DefaultCacheMaxBytesFloor
}
// gosec cannot see that min() above bounds computed, so it reads
// this conversion as potentially overflowing. It cannot: computed is
// at most math.MaxInt64 on every path here.
//nolint:gosec // G115: clamped to MaxInt64 by min above
limit := int64(computed)
limit = max(limit, DefaultCacheMaxBytesFloor)
return limit, nil
}
@@ -79,13 +82,16 @@ func ComputeDefaultCacheMaxBytes(cacheDir string, probe FreeSpaceProbeFunc) (int
// on free space in <state_dir>/cache/. The cache directory is created
// first so statfs measures the filesystem that will actually hold the
// cache. The effective limit is logged either way.
func (c *Config) resolveCacheMaxBytes(log *slog.Logger, probe FreeSpaceProbeFunc) error {
func (c *Config) resolveCacheMaxBytes(
log *slog.Logger, probe FreeSpaceProbeFunc,
) error {
if !c.cacheMaxBytesExplicit {
cacheDir := filepath.Join(c.StateDir, "cache")
if err := os.MkdirAll(cacheDir, cacheDirPerms); err != nil {
err := os.MkdirAll(cacheDir, cacheDirPerms)
if err != nil {
return fmt.Errorf("config key %q: cannot create cache directory %q: %w",
"cache_max_bytes", cacheDir, err)
keyCacheMaxBytes, cacheDir, err)
}
limit, err := ComputeDefaultCacheMaxBytes(cacheDir, probe)

View File

@@ -2,6 +2,7 @@
package config
import (
"errors"
"fmt"
"log/slog"
"math"
@@ -25,9 +26,59 @@ const (
DefaultUpstreamConnectionsPerHost = 20
)
// Configuration key names.
const (
keyDebug = "debug"
keyMaintenanceMode = "maintenance_mode"
keyPort = "port"
keyStateDir = "state_dir"
keySentryDSN = "sentry_dsn"
keyDBURL = "db_url"
keyMetrics = "metrics"
keyMetricsUsername = "metrics.username"
keyMetricsPassword = "metrics.password"
keySigningKey = "signing_key"
keyAllowlistHosts = "allowlist_hosts"
keyAllowHTTP = "allow_http"
keyUpstreamConnectionsPerHost = "upstream_connections_per_host"
keyCacheMaxBytes = "cache_max_bytes"
)
// Static validation errors. Each use site attaches the offending key
// and value by wrapping these with fmt.Errorf and %w.
var (
errValueRequired = errors.New("a value is required")
errValueEmpty = errors.New("value must not be empty")
errUnknownConfigKeys = errors.New("unknown config keys")
errNotAString = errors.New("not a string")
errNotAnInteger = errors.New("not an integer")
errNotABoolean = errors.New("not a boolean")
errNotAStringList = errors.New("not a list of strings")
errNotAMetricsMap = errors.New("not a map of metrics settings")
errEmptyListEntry = errors.New("list contains an empty entry")
errEmptyEntry = errors.New("contains an empty entry")
errNotAValidURL = errors.New("not a valid URL")
errPortOutOfRange = errors.New("outside the valid port range")
errTooFewConnections = errors.New("must be at least 1")
errValueTooShort = errors.New("value too short")
errMustBeSetTogether = errors.New("must be set together")
errMustNotBeNegative = errors.New("must not be negative")
errOverflowsInt64 = errors.New("overflows a 64-bit integer")
errNegativeBlockSize = errors.New(
"statfs reported negative block size")
errValueNull = errors.New(
"value is null; omit the key entirely to use the default")
errValuesNull = errors.New(
"value is null; omit a key entirely to use its default")
errNotBareHostname = errors.New(
"must be a bare hostname without scheme, path, or whitespace")
errNoHostnameLabels = errors.New("contains no hostname labels")
)
// Params defines dependencies for Config.
type Params struct {
fx.In
Globals *globals.Globals
Logger *logger.Logger
}
@@ -82,11 +133,13 @@ func New(_ fx.Lifecycle, params Params) (*Config, error) {
return nil, err
}
if err := c.ensureStateDirWritable(); err != nil {
err = c.ensureStateDirWritable()
if err != nil {
return nil, err
}
if err := c.resolveCacheMaxBytes(log, defaultFreeSpaceProbe); err != nil {
err = c.resolveCacheMaxBytes(log, defaultFreeSpaceProbe)
if err != nil {
return nil, err
}
@@ -104,11 +157,13 @@ func New(_ fx.Lifecycle, params Params) (*Config, error) {
// to omitted keys, never to invalid explicit values.
func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
if sc != nil {
if err := validateKnownKeys(sc); err != nil {
err := validateKnownKeys(sc)
if err != nil {
return nil, err
}
if err := validateAllowlistHostsValue(sc); err != nil {
err = validateAllowlistHostsValue(sc)
if err != nil {
return nil, err
}
}
@@ -116,26 +171,26 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
loader := &strictLoader{sc: sc}
c := &Config{
Debug: loader.boolVal("debug", false),
MaintenanceMode: loader.boolVal("maintenance_mode", false),
Port: loader.intVal("port", DefaultPort),
StateDir: loader.stringVal("state_dir", DefaultStateDir),
SentryDSN: loader.stringVal("sentry_dsn", ""),
MetricsUsername: loader.stringVal("metrics.username", ""),
MetricsPassword: loader.stringVal("metrics.password", ""),
SigningKey: loader.stringVal("signing_key", ""),
AllowlistHosts: getStringSlice(sc, "allowlist_hosts"),
AllowHTTP: loader.boolVal("allow_http", false),
Debug: loader.boolVal(keyDebug, false),
MaintenanceMode: loader.boolVal(keyMaintenanceMode, false),
Port: loader.intVal(keyPort, DefaultPort),
StateDir: loader.stringVal(keyStateDir, DefaultStateDir),
SentryDSN: loader.stringVal(keySentryDSN, ""),
MetricsUsername: loader.stringVal(keyMetricsUsername, ""),
MetricsPassword: loader.stringVal(keyMetricsPassword, ""),
SigningKey: loader.stringVal(keySigningKey, ""),
AllowlistHosts: getStringSlice(sc),
AllowHTTP: loader.boolVal(keyAllowHTTP, false),
UpstreamConnectionsPerHost: loader.intVal(
"upstream_connections_per_host", DefaultUpstreamConnectionsPerHost),
CacheMaxBytes: loader.int64Val("cache_max_bytes", 0),
keyUpstreamConnectionsPerHost, DefaultUpstreamConnectionsPerHost),
CacheMaxBytes: loader.int64Val(keyCacheMaxBytes, 0),
}
// The computed default for cache_max_bytes needs a validated
// state_dir, so it is resolved later (resolveCacheMaxBytes); here
// we only record whether the operator set the key explicitly.
if sc != nil {
if _, present := sc.Get("cache_max_bytes"); present {
if _, present := sc.Get(keyCacheMaxBytes); present {
c.cacheMaxBytesExplicit = true
}
}
@@ -143,13 +198,13 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
// Build DBURL from StateDir if not explicitly set. The derived URL
// is a default: it applies only when db_url is omitted, never to an
// explicitly empty value.
c.DBURL = loader.stringVal("db_url", "")
c.DBURL = loader.stringVal(keyDBURL, "")
if c.DBURL == "" && loader.err == nil {
if sc != nil {
if _, present := sc.Get("db_url"); present {
if _, present := sc.Get(keyDBURL); present {
return nil, fmt.Errorf(
"config key %q: value must not be empty; omit the key to derive it from state_dir",
"db_url")
"config key %q: %w; omit the key to derive it from state_dir",
keyDBURL, errValueEmpty)
}
}
@@ -160,7 +215,8 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
return nil, loader.err
}
if err := c.validate(); err != nil {
err := c.validate()
if err != nil {
return nil, err
}
@@ -189,23 +245,22 @@ func validateKnownKeys(sc *smartconfig.Config) error {
continue
}
if key == "metrics" {
metricsMap, ok := value.(map[string]interface{})
if key == keyMetrics {
metricsMap, ok := value.(map[string]any)
if !ok {
return fmt.Errorf(
"config key %q: value %v is not a map of metrics settings",
"metrics", value)
return fmt.Errorf("config key %q: value %v is %w",
keyMetrics, value, errNotAMetricsMap)
}
for subkey, subvalue := range metricsMap {
if subkey != "username" && subkey != "password" {
unknown = append(unknown, "metrics."+subkey)
unknown = append(unknown, keyMetrics+"."+subkey)
continue
}
if subvalue == nil {
nullKeys = append(nullKeys, "metrics."+subkey)
nullKeys = append(nullKeys, keyMetrics+"."+subkey)
}
}
}
@@ -214,7 +269,7 @@ func validateKnownKeys(sc *smartconfig.Config) error {
if len(unknown) > 0 {
sort.Strings(unknown)
return fmt.Errorf("unknown config keys: %s", strings.Join(unknown, ", "))
return fmt.Errorf("%w: %s", errUnknownConfigKeys, strings.Join(unknown, ", "))
}
if len(nullKeys) > 0 {
@@ -224,9 +279,8 @@ func validateKnownKeys(sc *smartconfig.Config) error {
return errNullConfigValue(nullKeys[0])
}
return fmt.Errorf(
"config keys %s: value is null; omit a key entirely to use its default",
strings.Join(nullKeys, ", "))
return fmt.Errorf("config keys %s: %w",
strings.Join(nullKeys, ", "), errValuesNull)
}
return nil
@@ -236,17 +290,16 @@ func validateKnownKeys(sc *smartconfig.Config) error {
// null (including the bare "key:" form and the "~" alias). Silently
// applying the default would mask a truncated or typo'd config entry.
func errNullConfigValue(key string) error {
return fmt.Errorf(
"config key %q: value is null; omit the key entirely to use the default", key)
return fmt.Errorf("config key %q: %w", key, errValueNull)
}
// isKnownConfigKey reports whether key is a permitted top-level
// configuration key.
func isKnownConfigKey(key string) bool {
switch key {
case "debug", "maintenance_mode", "port", "state_dir", "sentry_dsn",
"db_url", "metrics", "signing_key", "allowlist_hosts", "allow_http",
"upstream_connections_per_host", "cache_max_bytes", "env":
case keyDebug, keyMaintenanceMode, keyPort, keyStateDir, keySentryDSN,
keyDBURL, keyMetrics, keySigningKey, keyAllowlistHosts, keyAllowHTTP,
keyUpstreamConnectionsPerHost, keyCacheMaxBytes, "env":
return true
}
@@ -259,28 +312,30 @@ func isKnownConfigKey(key string) bool {
func (c *Config) ensureStateDirWritable() error {
const stateDirPerms = 0o750
if err := os.MkdirAll(c.StateDir, stateDirPerms); err != nil {
err := os.MkdirAll(c.StateDir, stateDirPerms)
if err != nil {
return fmt.Errorf("config key %q: cannot create directory %q: %w",
"state_dir", c.StateDir, err)
keyStateDir, c.StateDir, err)
}
probe, err := os.CreateTemp(c.StateDir, ".startup-write-probe-*")
if err != nil {
return fmt.Errorf("config key %q: directory %q is not writable: %w",
"state_dir", c.StateDir, err)
keyStateDir, c.StateDir, err)
}
probePath := probe.Name()
if err := probe.Close(); err != nil {
err = probe.Close()
if err != nil {
return fmt.Errorf("config key %q: cannot close probe file %q: %w",
"state_dir", probePath, err)
keyStateDir, probePath, err)
}
//nolint:gosec // G703: probePath comes from os.CreateTemp inside the just-validated StateDir
if err := os.Remove(probePath); err != nil {
err = os.Remove(probePath)
if err != nil {
return fmt.Errorf("config key %q: cannot remove probe file %q: %w",
"state_dir", probePath, err)
keyStateDir, probePath, err)
}
return nil
@@ -291,40 +346,42 @@ func (c *Config) ensureStateDirWritable() error {
func (c *Config) validate() error {
// The signing key value is never echoed in error messages.
if c.SigningKey == "" {
return fmt.Errorf("config key %q: a value is required", "signing_key")
return fmt.Errorf("config key %q: %w", keySigningKey, errValueRequired)
}
// Minimum key length for security (32 bytes = 256 bits)
const minKeyLength = 32
if len(c.SigningKey) < minKeyLength {
return fmt.Errorf("config key %q: value must be at least %d characters, got %d",
"signing_key", minKeyLength, len(c.SigningKey))
return fmt.Errorf("config key %q: %w: must be at least %d characters, got %d",
keySigningKey, errValueTooShort, minKeyLength, len(c.SigningKey))
}
const maxPort = 65535
if c.Port < 1 || c.Port > maxPort {
return fmt.Errorf("config key %q: value %d is outside the valid port range 1-%d",
"port", c.Port, maxPort)
return fmt.Errorf("config key %q: value %d is %w 1-%d",
keyPort, c.Port, errPortOutOfRange, maxPort)
}
if c.UpstreamConnectionsPerHost < 1 {
return fmt.Errorf("config key %q: value %d must be at least 1",
"upstream_connections_per_host", c.UpstreamConnectionsPerHost)
return fmt.Errorf("config key %q: value %d %w",
keyUpstreamConnectionsPerHost, c.UpstreamConnectionsPerHost,
errTooFewConnections)
}
if c.StateDir == "" {
return fmt.Errorf("config key %q: value must not be empty", "state_dir")
return fmt.Errorf("config key %q: %w", keyStateDir, errValueEmpty)
}
// Zero is valid (it disables the disk cache); only negative
// values are rejected. No floor applies to explicit values.
if c.CacheMaxBytes < 0 {
return fmt.Errorf("config key %q: value %d must not be negative",
"cache_max_bytes", c.CacheMaxBytes)
return fmt.Errorf("config key %q: value %d %w",
keyCacheMaxBytes, c.CacheMaxBytes, errMustNotBeNegative)
}
for _, host := range c.AllowlistHosts {
if err := validateAllowlistHost(host); err != nil {
err := validateAllowlistHost(host)
if err != nil {
return err
}
}
@@ -332,14 +389,14 @@ func (c *Config) validate() error {
if c.SentryDSN != "" {
parsed, err := url.Parse(c.SentryDSN)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return fmt.Errorf("config key %q: value %q is not a valid URL",
"sentry_dsn", c.SentryDSN)
return fmt.Errorf("config key %q: value %q is %w",
keySentryDSN, c.SentryDSN, errNotAValidURL)
}
}
if (c.MetricsUsername == "") != (c.MetricsPassword == "") {
return fmt.Errorf("config keys %q and %q must be set together",
"metrics.username", "metrics.password")
return fmt.Errorf("config keys %q and %q %w",
keyMetricsUsername, keyMetricsPassword, errMustBeSetTogether)
}
return nil
@@ -354,21 +411,20 @@ func (c *Config) validate() error {
// disable URL signing.
func validateAllowlistHost(host string) error {
if strings.Contains(host, "://") || strings.ContainsAny(host, "/ \t") {
return fmt.Errorf(
"config key %q: entry %q must be a bare hostname without scheme, path, or whitespace",
"allowlist_hosts", host)
return fmt.Errorf("config key %q: entry %q %w",
keyAllowlistHosts, host, errNotBareHostname)
}
if strings.Trim(host, ".") == "" {
return fmt.Errorf(
"config key %q: entry %q contains no hostname labels",
"allowlist_hosts", host)
return fmt.Errorf("config key %q: entry %q %w",
keyAllowlistHosts, host, errNoHostnameLabels)
}
return nil
}
// loadConfigFile loads configuration from PIXA_CONFIG_PATH env var or standard locations.
// loadConfigFile loads configuration from the PIXA_CONFIG_PATH env var
// or standard locations.
func loadConfigFile(log *slog.Logger, appName string) (*smartconfig.Config, error) {
// Check for explicit config path from environment
if envPath := os.Getenv("PIXA_CONFIG_PATH"); envPath != "" {
@@ -394,8 +450,9 @@ func loadConfigFile(log *slog.Logger, appName string) (*smartconfig.Config, erro
for _, path := range configPaths {
cleanPath := filepath.Clean(path)
//nolint:gosec // G703: paths are hardcoded config locations
if _, statErr := os.Stat(cleanPath); statErr == nil {
_, statErr := os.Stat(cleanPath)
if statErr == nil {
// A config file that exists but does not parse is a fatal
// startup error, never something to skip over.
sc, err := smartconfig.NewFromConfigPath(path)
@@ -491,8 +548,8 @@ func getString(sc *smartconfig.Config, key, defaultVal string) (string, error) {
str, ok := raw.(string)
if !ok {
return "", fmt.Errorf("config key %q: value %v (%T) is not a string",
key, raw, raw)
return "", fmt.Errorf("config key %q: value %v (%T) is %w",
key, raw, raw, errNotAString)
}
return str, nil
@@ -522,20 +579,22 @@ func getInt(sc *smartconfig.Config, key string, defaultVal int) (int, error) {
return int(val), nil
case float64:
if val != math.Trunc(val) {
return 0, fmt.Errorf("config key %q: value %v is not an integer", key, val)
return 0, fmt.Errorf("config key %q: value %v is %w",
key, val, errNotAnInteger)
}
return int(val), nil
case string:
parsed, err := strconv.Atoi(strings.TrimSpace(val))
if err != nil {
return 0, fmt.Errorf("config key %q: value %q is not an integer", key, val)
return 0, fmt.Errorf("config key %q: value %q is %w",
key, val, errNotAnInteger)
}
return parsed, nil
default:
return 0, fmt.Errorf("config key %q: value %v (%T) is not an integer",
key, raw, raw)
return 0, fmt.Errorf("config key %q: value %v (%T) is %w",
key, raw, raw, errNotAnInteger)
}
}
@@ -564,27 +623,29 @@ func getInt64(sc *smartconfig.Config, key string, defaultVal int64) (int64, erro
return val, nil
case uint64:
if val > math.MaxInt64 {
return 0, fmt.Errorf("config key %q: value %d overflows a 64-bit integer",
key, val)
return 0, fmt.Errorf("config key %q: value %d %w",
key, val, errOverflowsInt64)
}
return int64(val), nil //nolint:gosec // G115: bounds checked above
return int64(val), nil
case float64:
if val != math.Trunc(val) {
return 0, fmt.Errorf("config key %q: value %v is not an integer", key, val)
return 0, fmt.Errorf("config key %q: value %v is %w",
key, val, errNotAnInteger)
}
return int64(val), nil
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(val), 10, 64)
if err != nil {
return 0, fmt.Errorf("config key %q: value %q is not an integer", key, val)
return 0, fmt.Errorf("config key %q: value %q is %w",
key, val, errNotAnInteger)
}
return parsed, nil
default:
return 0, fmt.Errorf("config key %q: value %v (%T) is not an integer",
key, raw, raw)
return 0, fmt.Errorf("config key %q: value %v (%T) is %w",
key, raw, raw, errNotAnInteger)
}
}
@@ -612,13 +673,14 @@ func getBool(sc *smartconfig.Config, key string, defaultVal bool) (bool, error)
case string:
parsed, err := strconv.ParseBool(strings.TrimSpace(val))
if err != nil {
return false, fmt.Errorf("config key %q: value %q is not a boolean", key, val)
return false, fmt.Errorf("config key %q: value %q is %w",
key, val, errNotABoolean)
}
return parsed, nil
default:
return false, fmt.Errorf("config key %q: value %v (%T) is not a boolean",
key, raw, raw)
return false, fmt.Errorf("config key %q: value %v (%T) is %w",
key, raw, raw, errNotABoolean)
}
}
@@ -628,28 +690,27 @@ func getBool(sc *smartconfig.Config, key string, defaultVal bool) (bool, error)
// (or a comma-separated string), a non-string entry, or an empty entry
// is an error, never silently skipped.
func validateAllowlistHostsValue(sc *smartconfig.Config) error {
const key = "allowlist_hosts"
raw, ok := sc.Get(key)
raw, ok := sc.Get(keyAllowlistHosts)
if !ok {
return nil
}
if raw == nil {
return errNullConfigValue(key)
return errNullConfigValue(keyAllowlistHosts)
}
switch val := raw.(type) {
case []interface{}:
case []any:
for _, item := range val {
str, ok := item.(string)
if !ok {
return fmt.Errorf(
"config key %q: list entry %v (%T) is not a string", key, item, item)
return fmt.Errorf("config key %q: list entry %v (%T) is %w",
keyAllowlistHosts, item, item, errNotAString)
}
if strings.TrimSpace(str) == "" {
return fmt.Errorf("config key %q: list contains an empty entry", key)
return fmt.Errorf("config key %q: %w",
keyAllowlistHosts, errEmptyListEntry)
}
}
case string:
@@ -657,36 +718,36 @@ func validateAllowlistHostsValue(sc *smartconfig.Config) error {
return nil
}
for _, part := range strings.Split(val, ",") {
for part := range strings.SplitSeq(val, ",") {
if strings.TrimSpace(part) == "" {
return fmt.Errorf(
"config key %q: value %q contains an empty entry", key, val)
return fmt.Errorf("config key %q: value %q %w",
keyAllowlistHosts, val, errEmptyEntry)
}
}
default:
return fmt.Errorf("config key %q: value %v (%T) is not a list of strings",
key, raw, raw)
return fmt.Errorf("config key %q: value %v (%T) is %w",
keyAllowlistHosts, raw, raw, errNotAStringList)
}
return nil
}
// getStringSlice returns the list of strings for key, or nil if the key
// is omitted. It accepts a YAML list of strings or a comma-separated
// string (backwards compatibility). Malformed entries are rejected
// beforehand by validateAllowlistHostsValue.
func getStringSlice(sc *smartconfig.Config, key string) []string {
// getStringSlice returns the allowlist_hosts list of strings, or nil if
// the key is omitted. It accepts a YAML list of strings or a
// comma-separated string (backwards compatibility). Malformed entries
// are rejected beforehand by validateAllowlistHostsValue.
func getStringSlice(sc *smartconfig.Config) []string {
if sc == nil {
return nil
}
val, ok := sc.Get(key)
val, ok := sc.Get(keyAllowlistHosts)
if !ok || val == nil {
return nil
}
// Handle YAML list format
if slice, ok := val.([]interface{}); ok {
if slice, ok := val.([]any); ok {
result := make([]string, 0, len(slice))
for _, item := range slice {
if str, ok := item.(string); ok {

View File

@@ -0,0 +1,98 @@
package config
import (
"os"
"path/filepath"
"testing"
"git.eeqj.de/sneak/smartconfig"
)
// writeTestConfig writes yamlContent to a temp config file and returns
// the file path.
func writeTestConfig(t *testing.T, yamlContent string) string {
t.Helper()
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yml")
err := os.WriteFile(configPath, []byte(yamlContent), 0o600)
if err != nil {
t.Fatalf("failed to write test config: %v", err)
}
return configPath
}
// checkAllowlistHosts loads the config at configPath and asserts that
// getStringSlice returns the three expected hosts.
func checkAllowlistHosts(t *testing.T, configPath string) {
t.Helper()
sc, err := loadTestConfig(configPath)
if err != nil {
t.Fatalf("failed to load config: %v", err)
}
hosts := getStringSlice(sc)
if len(hosts) != 3 {
t.Errorf("expected 3 hosts, got %d: %v", len(hosts), hosts)
}
expected := []string{"static.sneak.cloud", "sneak.berlin", testHostS3}
for i, want := range expected {
if i >= len(hosts) {
t.Errorf("missing host at index %d: want %q", i, want)
continue
}
if hosts[i] != want {
t.Errorf("host[%d] = %q, want %q", i, hosts[i], want)
}
}
}
func TestGetStringSlice_YAMLList(t *testing.T) {
t.Parallel()
yamlContent := `
allowlist_hosts:
- static.sneak.cloud
- sneak.berlin
- s3.sneak.cloud
`
checkAllowlistHosts(t, writeTestConfig(t, yamlContent))
}
func TestGetStringSlice_CommaSeparated(t *testing.T) {
t.Parallel()
// Backwards compatibility with comma-separated string values.
yamlContent := `allowlist_hosts: "static.sneak.cloud, sneak.berlin, s3.sneak.cloud"`
checkAllowlistHosts(t, writeTestConfig(t, yamlContent))
}
func TestGetStringSlice_Empty(t *testing.T) {
t.Parallel()
configPath := writeTestConfig(t, `port: 8080`)
sc, err := loadTestConfig(configPath)
if err != nil {
t.Fatalf("failed to load config: %v", err)
}
hosts := getStringSlice(sc)
if len(hosts) != 0 {
t.Errorf("expected nil or empty slice, got %v", hosts)
}
}
// loadTestConfig is a helper to load a config file for testing.
func loadTestConfig(path string) (*smartconfig.Config, error) {
return smartconfig.NewFromConfigPath(path)
}

View File

@@ -1,113 +0,0 @@
package config
import (
"os"
"path/filepath"
"testing"
"git.eeqj.de/sneak/smartconfig"
)
func TestGetStringSlice_YAMLList(t *testing.T) {
// Create a temp config file with YAML list format
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yml")
yamlContent := `
allowlist_hosts:
- static.sneak.cloud
- sneak.berlin
- s3.sneak.cloud
`
err := os.WriteFile(configPath, []byte(yamlContent), 0644)
if err != nil {
t.Fatalf("failed to write test config: %v", err)
}
// Load config using smartconfig
sc, err := loadTestConfig(configPath)
if err != nil {
t.Fatalf("failed to load config: %v", err)
}
// Test that getStringSlice correctly parses YAML list
hosts := getStringSlice(sc, "allowlist_hosts")
if len(hosts) != 3 {
t.Errorf("expected 3 hosts, got %d: %v", len(hosts), hosts)
}
expected := []string{"static.sneak.cloud", "sneak.berlin", "s3.sneak.cloud"}
for i, want := range expected {
if i >= len(hosts) {
t.Errorf("missing host at index %d: want %q", i, want)
continue
}
if hosts[i] != want {
t.Errorf("host[%d] = %q, want %q", i, hosts[i], want)
}
}
}
func TestGetStringSlice_CommaSeparated(t *testing.T) {
// Test backwards compatibility with comma-separated string
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yml")
yamlContent := `allowlist_hosts: "static.sneak.cloud, sneak.berlin, s3.sneak.cloud"`
err := os.WriteFile(configPath, []byte(yamlContent), 0644)
if err != nil {
t.Fatalf("failed to write test config: %v", err)
}
sc, err := loadTestConfig(configPath)
if err != nil {
t.Fatalf("failed to load config: %v", err)
}
hosts := getStringSlice(sc, "allowlist_hosts")
if len(hosts) != 3 {
t.Errorf("expected 3 hosts, got %d: %v", len(hosts), hosts)
}
expected := []string{"static.sneak.cloud", "sneak.berlin", "s3.sneak.cloud"}
for i, want := range expected {
if i >= len(hosts) {
t.Errorf("missing host at index %d: want %q", i, want)
continue
}
if hosts[i] != want {
t.Errorf("host[%d] = %q, want %q", i, hosts[i], want)
}
}
}
func TestGetStringSlice_Empty(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yml")
yamlContent := `port: 8080`
err := os.WriteFile(configPath, []byte(yamlContent), 0644)
if err != nil {
t.Fatalf("failed to write test config: %v", err)
}
sc, err := loadTestConfig(configPath)
if err != nil {
t.Fatalf("failed to load config: %v", err)
}
hosts := getStringSlice(sc, "allowlist_hosts")
if hosts != nil && len(hosts) != 0 {
t.Errorf("expected nil or empty slice, got %v", hosts)
}
}
// loadTestConfig is a helper to load a config file for testing
func loadTestConfig(path string) (*smartconfig.Config, error) {
return smartconfig.NewFromConfigPath(path)
}

View File

@@ -1,7 +1,6 @@
package config
import (
"io"
"log/slog"
"os"
"path/filepath"
@@ -15,6 +14,26 @@ import (
// minimum length requirement in validate().
const validTestSigningKey = "0123456789abcdef0123456789abcdef"
// signingKeyLine is a valid signing_key config line used as the base of
// test config files.
const signingKeyLine = "signing_key: " + validTestSigningKey + "\n"
// testHostS3 is an allowlist host entry used across the config tests.
const testHostS3 = "s3.sneak.cloud"
// nullValueText is the substring that error messages about explicitly
// null config values must contain.
const nullValueText = "null"
// abortCase describes a config file that must abort startup with an
// error mentioning every string in wantErrSubstrings.
type abortCase struct {
name string
yaml string
// wantErrSubstrings must all appear in the error message.
wantErrSubstrings []string
}
// configFromYAML writes yamlContent to a temporary config file, loads it
// via smartconfig, and constructs a Config from it using the same code
// path the server uses at startup.
@@ -24,7 +43,8 @@ func configFromYAML(t *testing.T, yamlContent string) (*Config, error) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yml")
if err := os.WriteFile(configPath, []byte(yamlContent), 0o600); err != nil {
err := os.WriteFile(configPath, []byte(yamlContent), 0o600)
if err != nil {
t.Fatalf("failed to write test config: %v", err)
}
@@ -37,7 +57,9 @@ func configFromYAML(t *testing.T, yamlContent string) (*Config, error) {
}
func TestOmittedValuesUseDefaults(t *testing.T) {
c, err := configFromYAML(t, "signing_key: "+validTestSigningKey+"\n")
t.Parallel()
c, err := configFromYAML(t, signingKeyLine)
if err != nil {
t.Fatalf("minimal config should be valid, got error: %v", err)
}
@@ -78,6 +100,8 @@ func TestOmittedValuesUseDefaults(t *testing.T) {
}
func TestExplicitValidValuesAreUsed(t *testing.T) {
t.Parallel()
yamlContent := `
port: 9090
debug: true
@@ -118,9 +142,10 @@ metrics:
t.Errorf("DBURL = %q, want explicit value", c.DBURL)
}
if len(c.AllowlistHosts) != 2 || c.AllowlistHosts[0] != "s3.sneak.cloud" ||
if len(c.AllowlistHosts) != 2 || c.AllowlistHosts[0] != testHostS3 ||
c.AllowlistHosts[1] != ".example.com" {
t.Errorf("AllowlistHosts = %v, want [s3.sneak.cloud .example.com]", c.AllowlistHosts)
t.Errorf("AllowlistHosts = %v, want [s3.sneak.cloud .example.com]",
c.AllowlistHosts)
}
if c.UpstreamConnectionsPerHost != 5 {
@@ -138,8 +163,10 @@ metrics:
}
func TestCommaSeparatedAllowlistStillSupported(t *testing.T) {
yamlContent := `signing_key: ` + validTestSigningKey + `
allowlist_hosts: "s3.sneak.cloud, sneak.berlin"
t.Parallel()
yamlContent := signingKeyLine +
`allowlist_hosts: "s3.sneak.cloud, sneak.berlin"
`
c, err := configFromYAML(t, yamlContent)
@@ -147,9 +174,160 @@ allowlist_hosts: "s3.sneak.cloud, sneak.berlin"
t.Fatalf("comma-separated allowlist should load, got error: %v", err)
}
if len(c.AllowlistHosts) != 2 || c.AllowlistHosts[0] != "s3.sneak.cloud" ||
if len(c.AllowlistHosts) != 2 || c.AllowlistHosts[0] != testHostS3 ||
c.AllowlistHosts[1] != "sneak.berlin" {
t.Errorf("AllowlistHosts = %v, want [s3.sneak.cloud sneak.berlin]", c.AllowlistHosts)
t.Errorf("AllowlistHosts = %v, want [s3.sneak.cloud sneak.berlin]",
c.AllowlistHosts)
}
}
// runAbortCases asserts that each case's config aborts startup with an
// error message mentioning every expected substring.
func runAbortCases(t *testing.T, cases []abortCase) {
t.Helper()
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
c, err := configFromYAML(t, tc.yaml)
if err == nil {
t.Fatalf("config with %s must abort startup, got config: %+v", tc.name, c)
}
t.Logf("got expected error: %v", err)
for _, want := range tc.wantErrSubstrings {
if !strings.Contains(err.Error(), want) {
t.Errorf("error %q does not mention %q", err.Error(), want)
}
}
})
}
}
// invalidScalarValueCases are configs where a scalar key is explicitly
// set to an unparseable or out-of-range value; each must abort startup
// naming the offending key, never silently fall back to the default.
func invalidScalarValueCases() []abortCase {
return []abortCase{
{
name: "port not a number",
yaml: signingKeyLine + "port: banana\n",
wantErrSubstrings: []string{keyPort, "banana"},
},
{
name: "port zero",
yaml: signingKeyLine + "port: 0\n",
wantErrSubstrings: []string{keyPort, "0"},
},
{
name: "port above 65535",
yaml: signingKeyLine + "port: 99999\n",
wantErrSubstrings: []string{keyPort, "99999"},
},
{
name: "port fractional",
yaml: signingKeyLine + "port: 8080.5\n",
wantErrSubstrings: []string{keyPort, "8080.5"},
},
{
name: "debug not a bool",
yaml: signingKeyLine + "debug: notabool\n",
wantErrSubstrings: []string{keyDebug, "notabool"},
},
{
name: "maintenance_mode not a bool",
yaml: signingKeyLine + "maintenance_mode: sometimes\n",
wantErrSubstrings: []string{keyMaintenanceMode, "sometimes"},
},
{
name: "allow_http numeric",
yaml: signingKeyLine + "allow_http: 2\n",
wantErrSubstrings: []string{keyAllowHTTP, "2"},
},
{
name: "upstream_connections_per_host zero",
yaml: signingKeyLine + "upstream_connections_per_host: 0\n",
wantErrSubstrings: []string{keyUpstreamConnectionsPerHost, "0"},
},
{
name: "upstream_connections_per_host negative",
yaml: signingKeyLine + "upstream_connections_per_host: -3\n",
wantErrSubstrings: []string{keyUpstreamConnectionsPerHost, "-3"},
},
{
name: "upstream_connections_per_host not a number",
yaml: signingKeyLine + "upstream_connections_per_host: many\n",
wantErrSubstrings: []string{keyUpstreamConnectionsPerHost, "many"},
},
}
}
// invalidHostAndCredentialCases are configs where allowlist_hosts,
// signing_key, state_dir, sentry_dsn, or metrics is explicitly set to
// an invalid value; each must abort startup naming the offending key.
func invalidHostAndCredentialCases() []abortCase {
return []abortCase{
{
name: "allowlist host with scheme",
yaml: signingKeyLine + "allowlist_hosts:\n - https://example.com\n",
wantErrSubstrings: []string{
keyAllowlistHosts, "https://example.com",
},
},
{
name: "allowlist host with path",
yaml: signingKeyLine + "allowlist_hosts:\n - example.com/images\n",
wantErrSubstrings: []string{
keyAllowlistHosts, "example.com/images",
},
},
{
name: "allowlist host with whitespace",
yaml: signingKeyLine + "allowlist_hosts:\n - \"exa mple.com\"\n",
wantErrSubstrings: []string{keyAllowlistHosts, "exa mple.com"},
},
{
name: "allowlist entry not a string",
yaml: signingKeyLine + "allowlist_hosts:\n - 123\n",
wantErrSubstrings: []string{keyAllowlistHosts, "123"},
},
{
name: "allowlist not a list",
yaml: signingKeyLine + "allowlist_hosts:\n key: value\n",
wantErrSubstrings: []string{keyAllowlistHosts},
},
{
name: "signing_key too short",
yaml: "signing_key: short\n",
wantErrSubstrings: []string{keySigningKey},
},
{
name: "signing_key missing",
yaml: "port: 8080\n",
wantErrSubstrings: []string{keySigningKey},
},
{
name: "state_dir explicitly empty",
yaml: signingKeyLine + "state_dir: \"\"\n",
wantErrSubstrings: []string{keyStateDir},
},
{
name: "sentry_dsn not a URL",
yaml: signingKeyLine + "sentry_dsn: \"not a url\"\n",
wantErrSubstrings: []string{keySentryDSN, "not a url"},
},
{
name: "metrics username without password",
yaml: signingKeyLine + "metrics:\n username: bob\n",
wantErrSubstrings: []string{keyMetrics},
},
{
name: "metrics password without username",
yaml: signingKeyLine + "metrics:\n password: hunter2\n",
wantErrSubstrings: []string{keyMetrics},
},
}
}
@@ -158,140 +336,84 @@ allowlist_hosts: "s3.sneak.cloud, sneak.berlin"
// value must produce a startup error naming the offending key, never
// silently fall back to the default.
func TestSetButInvalidValueAbortsStartup(t *testing.T) {
signingKeyLine := "signing_key: " + validTestSigningKey + "\n"
t.Parallel()
cases := []struct {
name string
yaml string
// wantErrSubstrings must all appear in the error message.
wantErrSubstrings []string
}{
runAbortCases(t, append(
invalidScalarValueCases(), invalidHostAndCredentialCases()...))
}
// explicitNullValueCases are configs where a key is explicitly set to
// null (including the bare "key:" form and the "~" alias); each must
// abort startup naming the key.
func explicitNullValueCases() []abortCase {
return []abortCase{
{
name: "port not a number",
yaml: signingKeyLine + "port: banana\n",
wantErrSubstrings: []string{"port", "banana"},
name: "port explicit null",
yaml: signingKeyLine + "port: null\n",
wantErrSubstrings: []string{keyPort, nullValueText},
},
{
name: "port zero",
yaml: signingKeyLine + "port: 0\n",
wantErrSubstrings: []string{"port", "0"},
name: "port bare key no value",
yaml: signingKeyLine + "port:\n",
wantErrSubstrings: []string{keyPort, nullValueText},
},
{
name: "port above 65535",
yaml: signingKeyLine + "port: 99999\n",
wantErrSubstrings: []string{"port", "99999"},
name: "debug tilde null",
yaml: signingKeyLine + "debug: ~\n",
wantErrSubstrings: []string{keyDebug, nullValueText},
},
{
name: "port fractional",
yaml: signingKeyLine + "port: 8080.5\n",
wantErrSubstrings: []string{"port", "8080.5"},
name: "maintenance_mode null",
yaml: signingKeyLine + "maintenance_mode: null\n",
wantErrSubstrings: []string{keyMaintenanceMode, nullValueText},
},
{
name: "debug not a bool",
yaml: signingKeyLine + "debug: notabool\n",
wantErrSubstrings: []string{"debug", "notabool"},
name: "allow_http null",
yaml: signingKeyLine + "allow_http: null\n",
wantErrSubstrings: []string{keyAllowHTTP, nullValueText},
},
{
name: "maintenance_mode not a bool",
yaml: signingKeyLine + "maintenance_mode: sometimes\n",
wantErrSubstrings: []string{"maintenance_mode", "sometimes"},
name: "state_dir null",
yaml: signingKeyLine + "state_dir: null\n",
wantErrSubstrings: []string{keyStateDir, nullValueText},
},
{
name: "allow_http numeric",
yaml: signingKeyLine + "allow_http: 2\n",
wantErrSubstrings: []string{"allow_http", "2"},
name: "db_url null",
yaml: signingKeyLine + "db_url: null\n",
wantErrSubstrings: []string{keyDBURL, nullValueText},
},
{
name: "upstream_connections_per_host zero",
yaml: signingKeyLine + "upstream_connections_per_host: 0\n",
wantErrSubstrings: []string{"upstream_connections_per_host", "0"},
name: "sentry_dsn null",
yaml: signingKeyLine + "sentry_dsn: null\n",
wantErrSubstrings: []string{keySentryDSN, nullValueText},
},
{
name: "upstream_connections_per_host negative",
yaml: signingKeyLine + "upstream_connections_per_host: -3\n",
wantErrSubstrings: []string{"upstream_connections_per_host", "-3"},
name: "upstream_connections_per_host null",
yaml: signingKeyLine + "upstream_connections_per_host: null\n",
wantErrSubstrings: []string{keyUpstreamConnectionsPerHost, nullValueText},
},
{
name: "upstream_connections_per_host not a number",
yaml: signingKeyLine + "upstream_connections_per_host: many\n",
wantErrSubstrings: []string{"upstream_connections_per_host", "many"},
name: "allowlist_hosts null",
yaml: signingKeyLine + "allowlist_hosts: null\n",
wantErrSubstrings: []string{keyAllowlistHosts, nullValueText},
},
{
name: "allowlist host with scheme",
yaml: signingKeyLine + "allowlist_hosts:\n - https://example.com\n",
name: "signing_key null",
yaml: "signing_key: null\n",
wantErrSubstrings: []string{keySigningKey, nullValueText},
},
{
name: "metrics null",
yaml: signingKeyLine + "metrics: null\n",
wantErrSubstrings: []string{keyMetrics, nullValueText},
},
{
name: "metrics subkeys null",
yaml: signingKeyLine + "metrics:\n username: null\n password: null\n",
wantErrSubstrings: []string{
"allowlist_hosts", "https://example.com",
keyMetricsUsername, keyMetricsPassword, nullValueText,
},
},
{
name: "allowlist host with path",
yaml: signingKeyLine + "allowlist_hosts:\n - example.com/images\n",
wantErrSubstrings: []string{
"allowlist_hosts", "example.com/images",
},
},
{
name: "allowlist host with whitespace",
yaml: signingKeyLine + "allowlist_hosts:\n - \"exa mple.com\"\n",
wantErrSubstrings: []string{"allowlist_hosts", "exa mple.com"},
},
{
name: "allowlist entry not a string",
yaml: signingKeyLine + "allowlist_hosts:\n - 123\n",
wantErrSubstrings: []string{"allowlist_hosts", "123"},
},
{
name: "allowlist not a list",
yaml: signingKeyLine + "allowlist_hosts:\n key: value\n",
wantErrSubstrings: []string{"allowlist_hosts"},
},
{
name: "signing_key too short",
yaml: "signing_key: short\n",
wantErrSubstrings: []string{"signing_key"},
},
{
name: "signing_key missing",
yaml: "port: 8080\n",
wantErrSubstrings: []string{"signing_key"},
},
{
name: "state_dir explicitly empty",
yaml: signingKeyLine + "state_dir: \"\"\n",
wantErrSubstrings: []string{"state_dir"},
},
{
name: "sentry_dsn not a URL",
yaml: signingKeyLine + "sentry_dsn: \"not a url\"\n",
wantErrSubstrings: []string{"sentry_dsn", "not a url"},
},
{
name: "metrics username without password",
yaml: signingKeyLine + "metrics:\n username: bob\n",
wantErrSubstrings: []string{"metrics"},
},
{
name: "metrics password without username",
yaml: signingKeyLine + "metrics:\n password: hunter2\n",
wantErrSubstrings: []string{"metrics"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c, err := configFromYAML(t, tc.yaml)
if err == nil {
t.Fatalf("config with %s must abort startup, got config: %+v", tc.name, c)
}
t.Logf("got expected error: %v", err)
for _, want := range tc.wantErrSubstrings {
if !strings.Contains(err.Error(), want) {
t.Errorf("error %q does not mention %q", err.Error(), want)
}
}
})
}
}
@@ -300,99 +422,9 @@ func TestSetButInvalidValueAbortsStartup(t *testing.T) {
// startup naming the key. An explicit null is a SET value: it must
// never silently fall back to the default the way an omitted key does.
func TestExplicitNullValueAbortsStartup(t *testing.T) {
signingKeyLine := "signing_key: " + validTestSigningKey + "\n"
t.Parallel()
cases := []struct {
name string
yaml string
// wantErrSubstrings must all appear in the error message.
wantErrSubstrings []string
}{
{
name: "port explicit null",
yaml: signingKeyLine + "port: null\n",
wantErrSubstrings: []string{"port", "null"},
},
{
name: "port bare key no value",
yaml: signingKeyLine + "port:\n",
wantErrSubstrings: []string{"port", "null"},
},
{
name: "debug tilde null",
yaml: signingKeyLine + "debug: ~\n",
wantErrSubstrings: []string{"debug", "null"},
},
{
name: "maintenance_mode null",
yaml: signingKeyLine + "maintenance_mode: null\n",
wantErrSubstrings: []string{"maintenance_mode", "null"},
},
{
name: "allow_http null",
yaml: signingKeyLine + "allow_http: null\n",
wantErrSubstrings: []string{"allow_http", "null"},
},
{
name: "state_dir null",
yaml: signingKeyLine + "state_dir: null\n",
wantErrSubstrings: []string{"state_dir", "null"},
},
{
name: "db_url null",
yaml: signingKeyLine + "db_url: null\n",
wantErrSubstrings: []string{"db_url", "null"},
},
{
name: "sentry_dsn null",
yaml: signingKeyLine + "sentry_dsn: null\n",
wantErrSubstrings: []string{"sentry_dsn", "null"},
},
{
name: "upstream_connections_per_host null",
yaml: signingKeyLine + "upstream_connections_per_host: null\n",
wantErrSubstrings: []string{"upstream_connections_per_host", "null"},
},
{
name: "allowlist_hosts null",
yaml: signingKeyLine + "allowlist_hosts: null\n",
wantErrSubstrings: []string{"allowlist_hosts", "null"},
},
{
name: "signing_key null",
yaml: "signing_key: null\n",
wantErrSubstrings: []string{"signing_key", "null"},
},
{
name: "metrics null",
yaml: signingKeyLine + "metrics: null\n",
wantErrSubstrings: []string{"metrics", "null"},
},
{
name: "metrics subkeys null",
yaml: signingKeyLine + "metrics:\n username: null\n password: null\n",
wantErrSubstrings: []string{
"metrics.username", "metrics.password", "null",
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c, err := configFromYAML(t, tc.yaml)
if err == nil {
t.Fatalf("config with %s must abort startup, got config: %+v", tc.name, c)
}
t.Logf("got expected error: %v", err)
for _, want := range tc.wantErrSubstrings {
if !strings.Contains(err.Error(), want) {
t.Errorf("error %q does not mention %q", err.Error(), want)
}
}
})
}
runAbortCases(t, explicitNullValueCases())
}
// TestExplicitlyEmptyDBURLAbortsStartup verifies that db_url set to an
@@ -400,7 +432,9 @@ func TestExplicitNullValueAbortsStartup(t *testing.T) {
// a default, and defaults apply only to omitted keys. This matches
// state_dir, where an explicitly empty value already aborts.
func TestExplicitlyEmptyDBURLAbortsStartup(t *testing.T) {
yamlContent := "signing_key: " + validTestSigningKey + "\ndb_url: \"\"\n"
t.Parallel()
yamlContent := signingKeyLine + "db_url: \"\"\n"
c, err := configFromYAML(t, yamlContent)
if err == nil {
@@ -409,7 +443,7 @@ func TestExplicitlyEmptyDBURLAbortsStartup(t *testing.T) {
t.Logf("got expected error: %v", err)
if !strings.Contains(err.Error(), "db_url") {
if !strings.Contains(err.Error(), keyDBURL) {
t.Errorf("error %q does not name the offending key db_url", err.Error())
}
}
@@ -420,10 +454,12 @@ func TestExplicitlyEmptyDBURLAbortsStartup(t *testing.T) {
// host written in FQDN trailing-dot form (e.g. evil.com.) and
// effectively disable URL signing with a single character.
func TestAllowlistHostsRejectsDotOnlyEntries(t *testing.T) {
signingKeyLine := "signing_key: " + validTestSigningKey + "\n"
t.Parallel()
for _, entry := range []string{".", ".."} {
t.Run(entry, func(t *testing.T) {
t.Parallel()
yamlContent := signingKeyLine +
"allowlist_hosts:\n - \"" + entry + "\"\n"
@@ -435,7 +471,7 @@ func TestAllowlistHostsRejectsDotOnlyEntries(t *testing.T) {
t.Logf("got expected error: %v", err)
if !strings.Contains(err.Error(), "allowlist_hosts") {
if !strings.Contains(err.Error(), keyAllowlistHosts) {
t.Errorf("error %q does not name the offending key allowlist_hosts",
err.Error())
}
@@ -444,8 +480,9 @@ func TestAllowlistHostsRejectsDotOnlyEntries(t *testing.T) {
}
func TestUnknownTopLevelKeyAbortsStartup(t *testing.T) {
yamlContent := `signing_key: ` + validTestSigningKey + `
whitelist_hosts:
t.Parallel()
yamlContent := signingKeyLine + `whitelist_hosts:
- example.com
`
@@ -462,8 +499,9 @@ whitelist_hosts:
}
func TestUnknownMetricsSubkeyAbortsStartup(t *testing.T) {
yamlContent := `signing_key: ` + validTestSigningKey + `
metrics:
t.Parallel()
yamlContent := signingKeyLine + `metrics:
username: bob
password: hunter2
port: 9100
@@ -482,13 +520,17 @@ metrics:
}
func TestEnvSectionIsPermitted(t *testing.T) {
yamlContent := `signing_key: ` + validTestSigningKey + `
env:
t.Parallel()
yamlContent := signingKeyLine + `env:
PIXA_TEST_ENV_INJECTION: injected
`
if _, err := configFromYAML(t, yamlContent); err != nil {
t.Fatalf("env section must be permitted (smartconfig consumes it), got error: %v", err)
_, err := configFromYAML(t, yamlContent)
if err != nil {
t.Fatalf(
"env section must be permitted (smartconfig consumes it), got error: %v",
err)
}
}
@@ -496,7 +538,8 @@ func TestMalformedConfigFileAbortsStartup(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yml")
if err := os.WriteFile(configPath, []byte("port: [unclosed\n"), 0o600); err != nil {
err := os.WriteFile(configPath, []byte("port: [unclosed\n"), 0o600)
if err != nil {
t.Fatalf("failed to write malformed config: %v", err)
}
@@ -505,7 +548,7 @@ func TestMalformedConfigFileAbortsStartup(t *testing.T) {
t.Setenv("PIXA_CONFIG_PATH", "")
t.Chdir(tmpDir)
log := slog.New(slog.NewTextHandler(io.Discard, nil))
log := slog.New(slog.DiscardHandler)
sc, err := loadConfigFile(log, "pixa-test-nonexistent-app")
if err == nil {
@@ -516,10 +559,14 @@ func TestMalformedConfigFileAbortsStartup(t *testing.T) {
}
func TestEnsureStateDirCreatesDirectory(t *testing.T) {
t.Parallel()
stateDir := filepath.Join(t.TempDir(), "nested", "state")
c := &Config{StateDir: stateDir}
if err := c.ensureStateDirWritable(); err != nil {
err := c.ensureStateDirWritable()
if err != nil {
t.Fatalf("creatable state_dir must validate, got error: %v", err)
}
@@ -530,6 +577,8 @@ func TestEnsureStateDirCreatesDirectory(t *testing.T) {
}
func TestEnsureStateDirFailsOnUncreatablePath(t *testing.T) {
t.Parallel()
// A path below /dev/null can never be created, even when running
// as root (as in the Docker build).
c := &Config{StateDir: "/dev/null/pixa-state"}
@@ -541,7 +590,7 @@ func TestEnsureStateDirFailsOnUncreatablePath(t *testing.T) {
t.Logf("got expected error: %v", err)
if !strings.Contains(err.Error(), "state_dir") {
if !strings.Contains(err.Error(), keyStateDir) {
t.Errorf("error %q does not name the offending key state_dir", err.Error())
}
}