Hand-reviewed rather than mechanical, because two of these functions carried cleanup that depended on the shape being replaced. internal/imgcache/storage.go, writeIfAbsent: dropped the named result and the deferred temp-file cleanup that read it (nonamedreturns), in favour of an explicit os.Remove(tmpPath) on each failing path. The deferred form removed tmpPath whenever the function returned a non-nil error, which is reachable on exactly three paths once the temp file exists: Write, Close and Rename. Each of those now unlinks explicitly, in the same order relative to tmpFile.Close(). The paths that must NOT unlink are unchanged and still cannot: the content-already-present early return, a MkdirAll failure and a CreateTemp failure all happen before tmpPath exists, and the success path renames the temp file away. This is the same cleanup shape MetadataStorage.Store and VariantStorage.Store already use in this file. StoreHashed likewise loses its named results. internal/imgcache/eviction.go: converted 26 inline assignments. In evictSourceBlob the conversions reuse the function-scope err that the transaction already used; the rollback defer does not read it, and the ordering of the delete transaction, its commit, the sidecar deletes and the blob unlink is untouched. In the rows.Next() loops the scan error is declared inside the loop body and rows.Err() is checked after it, as before. internal/config: the remaining conversions are in straight-line code with no defer or named result. No behavior changes. make test (with -race, per script/test) is green, including TestEvictSourceBlobExcludesConcurrentStoreOfIdenticalContent, which exercises the commit-to-unlink window this cleanup protects.
775 lines
21 KiB
Go
775 lines
21 KiB
Go
// Package config provides application configuration using smartconfig.
|
|
package config
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"math"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"git.eeqj.de/sneak/smartconfig"
|
|
"go.uber.org/fx"
|
|
"sneak.berlin/go/pixa/internal/globals"
|
|
"sneak.berlin/go/pixa/internal/logger"
|
|
)
|
|
|
|
// Default configuration values.
|
|
const (
|
|
DefaultPort = 8080
|
|
DefaultStateDir = "/var/lib/pixa"
|
|
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")
|
|
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
|
|
}
|
|
|
|
// Config holds application configuration values.
|
|
type Config struct {
|
|
Debug bool
|
|
MaintenanceMode bool
|
|
MetricsPassword string
|
|
MetricsUsername string
|
|
Port int
|
|
SentryDSN string
|
|
StateDir string
|
|
DBURL string
|
|
|
|
// Image proxy settings
|
|
SigningKey string // HMAC signing key for URL signatures
|
|
AllowlistHosts []string // Hosts that don't require signatures
|
|
AllowHTTP bool // Allow non-TLS upstream (testing only)
|
|
UpstreamConnectionsPerHost int // Max concurrent connections per upstream host
|
|
|
|
// CacheMaxBytes is the disk cache size limit in bytes. Zero
|
|
// disables the disk cache entirely. When cache_max_bytes is
|
|
// omitted from the configuration, this holds the computed default
|
|
// (75% of free space on the filesystem containing
|
|
// <state_dir>/cache/, floored at DefaultCacheMaxBytesFloor).
|
|
CacheMaxBytes int64
|
|
|
|
// cacheMaxBytesExplicit records whether cache_max_bytes was
|
|
// explicitly set in the configuration file. Explicit values are
|
|
// used exactly as given; only an omitted key gets the computed
|
|
// default (and its floor) in resolveCacheMaxBytes.
|
|
cacheMaxBytesExplicit bool
|
|
}
|
|
|
|
// New creates a new Config instance by loading configuration from file.
|
|
func New(_ fx.Lifecycle, params Params) (*Config, error) {
|
|
log := params.Logger.Get()
|
|
name := params.Globals.Appname
|
|
|
|
sc, err := loadConfigFile(log, name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if sc == nil {
|
|
log.Info("no config file found, using defaults")
|
|
}
|
|
|
|
c, err := newFromSmartConfig(sc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
err = c.ensureStateDirWritable()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
err = c.resolveCacheMaxBytes(log, defaultFreeSpaceProbe)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if c.Debug {
|
|
params.Logger.EnableDebugLogging()
|
|
}
|
|
|
|
return c, nil
|
|
}
|
|
|
|
// newFromSmartConfig constructs a Config from a loaded smartconfig
|
|
// instance and validates it. A nil sc means no config file was found,
|
|
// in which case every option takes its default value. A key that is
|
|
// present but unparseable or invalid is an error: defaults apply only
|
|
// to omitted keys, never to invalid explicit values.
|
|
func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
|
if sc != nil {
|
|
err := validateKnownKeys(sc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
err = validateAllowlistHostsValue(sc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
loader := &strictLoader{sc: sc}
|
|
|
|
c := &Config{
|
|
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(
|
|
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(keyCacheMaxBytes); present {
|
|
c.cacheMaxBytesExplicit = true
|
|
}
|
|
}
|
|
|
|
// 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(keyDBURL, "")
|
|
if c.DBURL == "" && loader.err == nil {
|
|
if sc != nil {
|
|
if _, present := sc.Get(keyDBURL); present {
|
|
return nil, fmt.Errorf(
|
|
"config key %q: %w; omit the key to derive it from state_dir",
|
|
keyDBURL, errValueEmpty)
|
|
}
|
|
}
|
|
|
|
c.DBURL = fmt.Sprintf("file:%s/state.sqlite3?_journal_mode=WAL", c.StateDir)
|
|
}
|
|
|
|
if loader.err != nil {
|
|
return nil, loader.err
|
|
}
|
|
|
|
err := c.validate()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return c, nil
|
|
}
|
|
|
|
// validateKnownKeys rejects configuration files containing keys the
|
|
// application does not understand, so typos fail at startup instead of
|
|
// being silently ignored, and rejects keys that are explicitly set to
|
|
// null: a null is a SET value, never an omission, so it must not
|
|
// silently take the default. The env section is permitted because
|
|
// smartconfig consumes it for environment variable injection.
|
|
func validateKnownKeys(sc *smartconfig.Config) error {
|
|
var unknown, nullKeys []string
|
|
|
|
for key, value := range sc.Data() {
|
|
if !isKnownConfigKey(key) {
|
|
unknown = append(unknown, key)
|
|
|
|
continue
|
|
}
|
|
|
|
if value == nil {
|
|
nullKeys = append(nullKeys, key)
|
|
|
|
continue
|
|
}
|
|
|
|
if key == keyMetrics {
|
|
metricsMap, ok := value.(map[string]any)
|
|
if !ok {
|
|
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, keyMetrics+"."+subkey)
|
|
|
|
continue
|
|
}
|
|
|
|
if subvalue == nil {
|
|
nullKeys = append(nullKeys, keyMetrics+"."+subkey)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(unknown) > 0 {
|
|
sort.Strings(unknown)
|
|
|
|
return fmt.Errorf("%w: %s", errUnknownConfigKeys, strings.Join(unknown, ", "))
|
|
}
|
|
|
|
if len(nullKeys) > 0 {
|
|
sort.Strings(nullKeys)
|
|
|
|
if len(nullKeys) == 1 {
|
|
return errNullConfigValue(nullKeys[0])
|
|
}
|
|
|
|
return fmt.Errorf("config keys %s: %w",
|
|
strings.Join(nullKeys, ", "), errValuesNull)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// errNullConfigValue reports a config key that is explicitly set to
|
|
// 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: %w", key, errValueNull)
|
|
}
|
|
|
|
// isKnownConfigKey reports whether key is a permitted top-level
|
|
// configuration key.
|
|
func isKnownConfigKey(key string) bool {
|
|
switch key {
|
|
case keyDebug, keyMaintenanceMode, keyPort, keyStateDir, keySentryDSN,
|
|
keyDBURL, keyMetrics, keySigningKey, keyAllowlistHosts, keyAllowHTTP,
|
|
keyUpstreamConnectionsPerHost, keyCacheMaxBytes, "env":
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// ensureStateDirWritable verifies at startup that StateDir can be
|
|
// created and written to, so a misconfigured path aborts startup
|
|
// instead of failing later at first use.
|
|
func (c *Config) ensureStateDirWritable() error {
|
|
const stateDirPerms = 0o750
|
|
|
|
err := os.MkdirAll(c.StateDir, stateDirPerms)
|
|
if err != nil {
|
|
return fmt.Errorf("config key %q: cannot create directory %q: %w",
|
|
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",
|
|
keyStateDir, c.StateDir, err)
|
|
}
|
|
|
|
probePath := probe.Name()
|
|
|
|
err = probe.Close()
|
|
if err != nil {
|
|
return fmt.Errorf("config key %q: cannot close probe file %q: %w",
|
|
keyStateDir, probePath, err)
|
|
}
|
|
|
|
err = os.Remove(probePath)
|
|
if err != nil {
|
|
return fmt.Errorf("config key %q: cannot remove probe file %q: %w",
|
|
keyStateDir, probePath, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validate checks that all required configuration values are set and
|
|
// that every value is within its valid range.
|
|
func (c *Config) validate() error {
|
|
// The signing key value is never echoed in error messages.
|
|
if c.SigningKey == "" {
|
|
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: %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 %w 1-%d",
|
|
keyPort, c.Port, errPortOutOfRange, maxPort)
|
|
}
|
|
|
|
if c.UpstreamConnectionsPerHost < 1 {
|
|
return fmt.Errorf("config key %q: value %d %w",
|
|
keyUpstreamConnectionsPerHost, c.UpstreamConnectionsPerHost,
|
|
errTooFewConnections)
|
|
}
|
|
|
|
if c.StateDir == "" {
|
|
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)
|
|
}
|
|
|
|
for _, host := range c.AllowlistHosts {
|
|
err := validateAllowlistHost(host)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
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 %w",
|
|
keySentryDSN, c.SentryDSN, errNotAValidURL)
|
|
}
|
|
}
|
|
|
|
if (c.MetricsUsername == "") != (c.MetricsPassword == "") {
|
|
return fmt.Errorf("config keys %q and %q %w",
|
|
keyMetricsUsername, keyMetricsPassword, errMustBeSetTogether)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateAllowlistHost checks that an allowlist_hosts entry is a bare
|
|
// hostname, optionally with a leading dot for suffix matching. URLs,
|
|
// paths, and whitespace indicate a misconfigured entry. An entry with
|
|
// no hostname labels (such as ".") is rejected: the allowlist matcher
|
|
// treats a leading dot as a suffix pattern, so a bare "." would match
|
|
// any upstream host written in FQDN trailing-dot form and effectively
|
|
// disable URL signing.
|
|
func validateAllowlistHost(host string) error {
|
|
if strings.Contains(host, "://") || strings.ContainsAny(host, "/ \t") {
|
|
return fmt.Errorf("config key %q: entry %q %w",
|
|
keyAllowlistHosts, host, errNotBareHostname)
|
|
}
|
|
|
|
if strings.Trim(host, ".") == "" {
|
|
return fmt.Errorf("config key %q: entry %q %w",
|
|
keyAllowlistHosts, host, errNoHostnameLabels)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// 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 != "" {
|
|
sc, err := smartconfig.NewFromConfigPath(envPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load config from %s: %w", envPath, err)
|
|
}
|
|
|
|
log.Info("loaded config file", "path", envPath)
|
|
|
|
return sc, nil
|
|
}
|
|
|
|
// Try loading config from standard locations
|
|
configPaths := []string{
|
|
fmt.Sprintf("/etc/%s/config.yml", appName),
|
|
fmt.Sprintf("/etc/%s/config.yaml", appName),
|
|
filepath.Join(os.Getenv("HOME"), ".config", appName, "config.yml"),
|
|
filepath.Join(os.Getenv("HOME"), ".config", appName, "config.yaml"),
|
|
"config.yml",
|
|
"config.yaml",
|
|
}
|
|
|
|
for _, path := range configPaths {
|
|
cleanPath := filepath.Clean(path)
|
|
|
|
_, 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)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse config file %s: %w", path, err)
|
|
}
|
|
|
|
log.Info("loaded config file", "path", path)
|
|
|
|
return sc, nil
|
|
}
|
|
}
|
|
|
|
return nil, nil //nolint:nilnil // nil config is valid (use defaults)
|
|
}
|
|
|
|
// strictLoader accumulates the first error encountered while reading
|
|
// typed values out of a smartconfig instance, so Config construction
|
|
// can stay a single struct literal.
|
|
type strictLoader struct {
|
|
sc *smartconfig.Config
|
|
err error
|
|
}
|
|
|
|
func (l *strictLoader) stringVal(key, defaultVal string) string {
|
|
if l.err != nil {
|
|
return ""
|
|
}
|
|
|
|
val, err := getString(l.sc, key, defaultVal)
|
|
if err != nil {
|
|
l.err = err
|
|
}
|
|
|
|
return val
|
|
}
|
|
|
|
func (l *strictLoader) intVal(key string, defaultVal int) int {
|
|
if l.err != nil {
|
|
return 0
|
|
}
|
|
|
|
val, err := getInt(l.sc, key, defaultVal)
|
|
if err != nil {
|
|
l.err = err
|
|
}
|
|
|
|
return val
|
|
}
|
|
|
|
func (l *strictLoader) int64Val(key string, defaultVal int64) int64 {
|
|
if l.err != nil {
|
|
return 0
|
|
}
|
|
|
|
val, err := getInt64(l.sc, key, defaultVal)
|
|
if err != nil {
|
|
l.err = err
|
|
}
|
|
|
|
return val
|
|
}
|
|
|
|
func (l *strictLoader) boolVal(key string, defaultVal bool) bool {
|
|
if l.err != nil {
|
|
return false
|
|
}
|
|
|
|
val, err := getBool(l.sc, key, defaultVal)
|
|
if err != nil {
|
|
l.err = err
|
|
}
|
|
|
|
return val
|
|
}
|
|
|
|
// getString returns the string value for key, or defaultVal if the key
|
|
// is omitted. A present value that is not a string, or is explicitly
|
|
// null, is an error.
|
|
func getString(sc *smartconfig.Config, key, defaultVal string) (string, error) {
|
|
if sc == nil {
|
|
return defaultVal, nil
|
|
}
|
|
|
|
raw, ok := sc.Get(key)
|
|
if !ok {
|
|
return defaultVal, nil
|
|
}
|
|
|
|
if raw == nil {
|
|
return "", errNullConfigValue(key)
|
|
}
|
|
|
|
str, ok := raw.(string)
|
|
if !ok {
|
|
return "", fmt.Errorf("config key %q: value %v (%T) is %w",
|
|
key, raw, raw, errNotAString)
|
|
}
|
|
|
|
return str, nil
|
|
}
|
|
|
|
// getInt returns the integer value for key, or defaultVal if the key is
|
|
// omitted. A present value that is not a whole number, or is explicitly
|
|
// null, is an error; fractional values are never truncated.
|
|
func getInt(sc *smartconfig.Config, key string, defaultVal int) (int, error) {
|
|
if sc == nil {
|
|
return defaultVal, nil
|
|
}
|
|
|
|
raw, ok := sc.Get(key)
|
|
if !ok {
|
|
return defaultVal, nil
|
|
}
|
|
|
|
if raw == nil {
|
|
return 0, errNullConfigValue(key)
|
|
}
|
|
|
|
switch val := raw.(type) {
|
|
case int:
|
|
return val, nil
|
|
case int64:
|
|
return int(val), nil
|
|
case float64:
|
|
if val != math.Trunc(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 %w",
|
|
key, val, errNotAnInteger)
|
|
}
|
|
|
|
return parsed, nil
|
|
default:
|
|
return 0, fmt.Errorf("config key %q: value %v (%T) is %w",
|
|
key, raw, raw, errNotAnInteger)
|
|
}
|
|
}
|
|
|
|
// getInt64 returns the 64-bit integer value for key, or defaultVal if
|
|
// the key is omitted. A present value that is not a whole number, or
|
|
// is explicitly null, is an error; fractional values are never
|
|
// truncated and out-of-range values are never clamped.
|
|
func getInt64(sc *smartconfig.Config, key string, defaultVal int64) (int64, error) {
|
|
if sc == nil {
|
|
return defaultVal, nil
|
|
}
|
|
|
|
raw, ok := sc.Get(key)
|
|
if !ok {
|
|
return defaultVal, nil
|
|
}
|
|
|
|
if raw == nil {
|
|
return 0, errNullConfigValue(key)
|
|
}
|
|
|
|
switch val := raw.(type) {
|
|
case int:
|
|
return int64(val), nil
|
|
case int64:
|
|
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 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 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 parsed, nil
|
|
default:
|
|
return 0, fmt.Errorf("config key %q: value %v (%T) is not an integer",
|
|
key, raw, raw)
|
|
}
|
|
}
|
|
|
|
// getBool returns the boolean value for key, or defaultVal if the key
|
|
// is omitted. A present value that is not a boolean (or a ParseBool-able
|
|
// string), or is explicitly null, is an error; numbers are not accepted
|
|
// as booleans.
|
|
func getBool(sc *smartconfig.Config, key string, defaultVal bool) (bool, error) {
|
|
if sc == nil {
|
|
return defaultVal, nil
|
|
}
|
|
|
|
raw, ok := sc.Get(key)
|
|
if !ok {
|
|
return defaultVal, nil
|
|
}
|
|
|
|
if raw == nil {
|
|
return false, errNullConfigValue(key)
|
|
}
|
|
|
|
switch val := raw.(type) {
|
|
case bool:
|
|
return val, nil
|
|
case string:
|
|
parsed, err := strconv.ParseBool(strings.TrimSpace(val))
|
|
if err != nil {
|
|
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 %w",
|
|
key, raw, raw, errNotABoolean)
|
|
}
|
|
}
|
|
|
|
// validateAllowlistHostsValue checks the raw shape of the
|
|
// allowlist_hosts value before the lenient extraction in getStringSlice
|
|
// runs: an explicitly null value, a value that is not a list of strings
|
|
// (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 {
|
|
raw, ok := sc.Get(keyAllowlistHosts)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
if raw == nil {
|
|
return errNullConfigValue(keyAllowlistHosts)
|
|
}
|
|
|
|
switch val := raw.(type) {
|
|
case []any:
|
|
for _, item := range val {
|
|
str, ok := item.(string)
|
|
if !ok {
|
|
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: %w",
|
|
keyAllowlistHosts, errEmptyListEntry)
|
|
}
|
|
}
|
|
case string:
|
|
if strings.TrimSpace(val) == "" {
|
|
return nil
|
|
}
|
|
|
|
for part := range strings.SplitSeq(val, ",") {
|
|
if strings.TrimSpace(part) == "" {
|
|
return fmt.Errorf("config key %q: value %q %w",
|
|
keyAllowlistHosts, val, errEmptyEntry)
|
|
}
|
|
}
|
|
default:
|
|
return fmt.Errorf("config key %q: value %v (%T) is %w",
|
|
keyAllowlistHosts, raw, raw, errNotAStringList)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// 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(keyAllowlistHosts)
|
|
if !ok || val == nil {
|
|
return nil
|
|
}
|
|
|
|
// Handle YAML list format
|
|
if slice, ok := val.([]any); ok {
|
|
result := make([]string, 0, len(slice))
|
|
for _, item := range slice {
|
|
if str, ok := item.(string); ok {
|
|
trimmed := strings.TrimSpace(str)
|
|
if trimmed != "" {
|
|
result = append(result, trimmed)
|
|
}
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// Fall back to comma-separated string for backwards compatibility
|
|
if str, ok := val.(string); ok && str != "" {
|
|
parts := strings.Split(str, ",")
|
|
result := make([]string, 0, len(parts))
|
|
|
|
for _, part := range parts {
|
|
trimmed := strings.TrimSpace(part)
|
|
if trimmed != "" {
|
|
result = append(result, trimmed)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
return nil
|
|
}
|