All checks were successful
check / check (push) Successful in 1m44s
The stricter canonical .golangci.yml surfaced 81 findings in the config validation code merged from main (#53). Fix them all with no behavior change: static sentinel errors wrapped with %w preserving the existing messages (err113), config key name constants (goconst), t.Parallel() throughout except the Setenv/Chdir test (paralleltest), white-box test renamed to config_validation_internal_test.go (testpackage), case tables extracted into builder functions plus a shared runAbortCases helper (funlen/dupl/gochecknoglobals), plain error assignments (noinlineerr), any instead of interface{} and strings.SplitSeq (modernize), slog.DiscardHandler (sloglint), 88-col wrapping (lll), and removal of two stale nolint:gosec directives (nolintlint).
677 lines
18 KiB
Go
677 lines
18 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"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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),
|
|
}
|
|
|
|
// 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, "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)
|
|
}
|
|
|
|
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) 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)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|