diff --git a/PROGRESS.md b/PROGRESS.md index 08c77cc..270660f 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,8 @@ ## Current State -Phase 2 complete. The `db` and `auth` packages are implemented and tested. +Phase 3 complete. The `db`, `auth`, and `config` packages are implemented +and tested. ## Completed @@ -27,25 +28,33 @@ Phase 2 complete. The `db` and `auth` packages are implemented and tested. - 11 tests covering open, migrate, and snapshot ### Phase 2: `auth` — MCIAS Token Validation (2026-03-25) -- `Config` type matching `[mcias]` TOML section (ServerURL, CACert, - ServiceName, Tags) +- `Config` type matching `[mcias]` TOML section - `TokenInfo` type (Username, Roles, IsAdmin) -- `New(cfg Config, logger *slog.Logger) (*Authenticator, error)` — creates - MCIAS client with TLS 1.3, custom CA support, 10s timeout -- `Login(username, password, totpCode string) (token, expiresAt, err)` — - forwards to MCIAS with service context, returns ErrForbidden for policy - denials, ErrInvalidCredentials for bad creds -- `ValidateToken(token string) (*TokenInfo, error)` — 30s SHA-256-keyed - cache, lazy eviction, concurrent-safe (RWMutex) -- `Logout(token string) error` — revokes token on MCIAS -- Error types: ErrInvalidToken, ErrInvalidCredentials, ErrForbidden, - ErrUnavailable -- Context helpers: ContextWithTokenInfo, TokenInfoFromContext -- 14 tests: login (success, invalid creds, forbidden), validate (admin, - non-admin, expired, unknown), cache (hit, expiry via injectable clock), - logout, constructor validation, context roundtrip, admin detection -- `make all` passes clean (vet, lint 0 issues, 25 total tests, build) +- `New(cfg, logger)` — MCIAS client with TLS 1.3, custom CA, 10s timeout +- `Login`, `ValidateToken` (30s SHA-256 cache), `Logout` +- Error types, context helpers +- 14 tests with mock MCIAS server and injectable clock + +### Phase 3: `config` — TOML Configuration (2026-03-25) +- `Base` type embedding standard sections (Server, Database, MCIAS, Log) +- `ServerConfig` with `Duration` wrapper type for TOML string decoding + (go-toml v2 does not natively decode strings to time.Duration) +- `DatabaseConfig`, `LogConfig`, `WebConfig` (non-embedded, for web UIs) +- `Duration` type with TextUnmarshaler/TextMarshaler for TOML compatibility +- `Load[T any](path, envPrefix)` — generic loader with TOML parse, env + overrides via reflection, defaults, required field validation +- `Validator` interface for service-specific validation +- Environment overrides: PREFIX_SECTION_FIELD for strings, durations, + bools, and comma-separated string slices +- Defaults: ReadTimeout=30s, WriteTimeout=30s, IdleTimeout=120s, + ShutdownTimeout=60s, Log.Level="info" +- Required: listen_addr, tls_cert, tls_key +- 16 tests: minimal/full config, defaults (applied and not overriding + explicit), missing required fields (3 cases), env overrides (string, + duration, slice, bool, service-specific), Validator interface (pass/fail), + nonexistent file, invalid TOML, empty prefix +- `make all` passes clean (vet, lint 0 issues, 41 total tests, build) ## Next Steps -- Phase 3: `config` package (TOML loading, env overrides, standard sections) +- Phase 4: `httpserver` package (TLS HTTP server, middleware, JSON helpers) diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..0fc8ea0 --- /dev/null +++ b/config/config.go @@ -0,0 +1,289 @@ +// Package config provides TOML configuration loading with environment +// variable overrides for Metacircular services. +// +// Services define their own config struct embedding [Base], which provides +// the standard sections (Server, Database, MCIAS, Log). Use [Load] to +// parse a TOML file, apply environment overrides, set defaults, and +// validate required fields. +package config + +import ( + "fmt" + "os" + "reflect" + "strings" + "time" + + "github.com/pelletier/go-toml/v2" + + "git.wntrmute.dev/kyle/mcdsl/auth" +) + +// Base contains the configuration sections common to all Metacircular +// services. Services embed this in their own config struct and add +// service-specific sections. +// +// Example: +// +// type MyConfig struct { +// config.Base +// MyService MyServiceSection `toml:"my_service"` +// } +type Base struct { + Server ServerConfig `toml:"server"` + Database DatabaseConfig `toml:"database"` + MCIAS auth.Config `toml:"mcias"` + Log LogConfig `toml:"log"` +} + +// ServerConfig holds TLS server settings. +type ServerConfig struct { + // ListenAddr is the HTTPS listen address (e.g., ":8443"). Required. + ListenAddr string `toml:"listen_addr"` + + // GRPCAddr is the gRPC listen address (e.g., ":9443"). Optional; + // gRPC is disabled if empty. + GRPCAddr string `toml:"grpc_addr"` + + // TLSCert is the path to the TLS certificate file (PEM). Required. + TLSCert string `toml:"tls_cert"` + + // TLSKey is the path to the TLS private key file (PEM). Required. + TLSKey string `toml:"tls_key"` + + // ReadTimeout is the maximum duration for reading the entire request. + // Defaults to 30s. + ReadTimeout Duration `toml:"read_timeout"` + + // WriteTimeout is the maximum duration before timing out writes. + // Defaults to 30s. + WriteTimeout Duration `toml:"write_timeout"` + + // IdleTimeout is the maximum time to wait for the next request on + // a keep-alive connection. Defaults to 120s. + IdleTimeout Duration `toml:"idle_timeout"` + + // ShutdownTimeout is the maximum time to wait for in-flight requests + // to drain during graceful shutdown. Defaults to 60s. + ShutdownTimeout Duration `toml:"shutdown_timeout"` +} + +// DatabaseConfig holds SQLite database settings. +type DatabaseConfig struct { + // Path is the path to the SQLite database file. Required. + Path string `toml:"path"` +} + +// LogConfig holds logging settings. +type LogConfig struct { + // Level is the log level (debug, info, warn, error). Defaults to "info". + Level string `toml:"level"` +} + +// WebConfig holds web UI server settings. This is not part of Base because +// not all services have a web UI — services that do can add it to their +// own config struct. +type WebConfig struct { + // ListenAddr is the web UI listen address (e.g., "127.0.0.1:8080"). + ListenAddr string `toml:"listen_addr"` + + // GRPCAddr is the gRPC address of the API server that the web UI + // connects to. + GRPCAddr string `toml:"grpc_addr"` + + // CACert is an optional CA certificate for verifying the API server's + // TLS certificate. + CACert string `toml:"ca_cert"` +} + +// Validator is an optional interface that config structs can implement +// to add service-specific validation. If the config type implements +// Validator, its Validate method is called after defaults and env +// overrides are applied. +type Validator interface { + Validate() error +} + +// Load reads a TOML config file at path, applies environment variable +// overrides using envPrefix (e.g., "MCR" maps MCR_SERVER_LISTEN_ADDR to +// Server.ListenAddr), sets defaults for unset optional fields, and +// validates required fields. +// +// If T implements [Validator], its Validate method is called after all +// other processing. +func Load[T any](path string, envPrefix string) (*T, error) { + data, err := os.ReadFile(path) //nolint:gosec // config path is operator-supplied + if err != nil { + return nil, fmt.Errorf("config: read %s: %w", path, err) + } + + var cfg T + if err := toml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("config: parse %s: %w", path, err) + } + + if envPrefix != "" { + applyEnvToStruct(reflect.ValueOf(&cfg).Elem(), envPrefix) + } + + applyBaseDefaults(&cfg) + + if err := validateBase(&cfg); err != nil { + return nil, err + } + + if v, ok := any(&cfg).(Validator); ok { + if err := v.Validate(); err != nil { + return nil, fmt.Errorf("config: %w", err) + } + } + + return &cfg, nil +} + +// applyBaseDefaults sets defaults on the embedded Base struct if present. +func applyBaseDefaults(cfg any) { + base := findBase(cfg) + if base == nil { + return + } + + if base.Server.ReadTimeout.Duration == 0 { + base.Server.ReadTimeout.Duration = 30 * time.Second + } + if base.Server.WriteTimeout.Duration == 0 { + base.Server.WriteTimeout.Duration = 30 * time.Second + } + if base.Server.IdleTimeout.Duration == 0 { + base.Server.IdleTimeout.Duration = 120 * time.Second + } + if base.Server.ShutdownTimeout.Duration == 0 { + base.Server.ShutdownTimeout.Duration = 60 * time.Second + } + if base.Log.Level == "" { + base.Log.Level = "info" + } +} + +// validateBase checks required fields on the embedded Base struct if present. +func validateBase(cfg any) error { + base := findBase(cfg) + if base == nil { + return nil + } + + required := []struct { + name string + value string + }{ + {"server.listen_addr", base.Server.ListenAddr}, + {"server.tls_cert", base.Server.TLSCert}, + {"server.tls_key", base.Server.TLSKey}, + } + + for _, r := range required { + if r.value == "" { + return fmt.Errorf("config: required field %q is missing", r.name) + } + } + + return nil +} + +// findBase returns a pointer to the embedded Base struct, or nil if the +// config type does not embed Base. +func findBase(cfg any) *Base { + v := reflect.ValueOf(cfg) + if v.Kind() == reflect.Ptr { + v = v.Elem() + } + if v.Kind() != reflect.Struct { + return nil + } + + // Check if cfg *is* a Base. + if b, ok := v.Addr().Interface().(*Base); ok { + return b + } + + // Check embedded fields. + t := v.Type() + for i := range t.NumField() { + field := t.Field(i) + if field.Anonymous && field.Type == reflect.TypeOf(Base{}) { + b, ok := v.Field(i).Addr().Interface().(*Base) + if ok { + return b + } + } + } + + return nil +} + +// applyEnvToStruct recursively walks a struct and overrides field values +// from environment variables. The env variable name is built from the +// prefix and the toml tag: PREFIX_SECTION_FIELD (uppercased). +// +// Supported field types: string, time.Duration (as int64), []string +// (comma-separated), bool, int. +func applyEnvToStruct(v reflect.Value, prefix string) { + if v.Kind() == reflect.Ptr { + v = v.Elem() + } + t := v.Type() + + for i := range t.NumField() { + field := t.Field(i) + fv := v.Field(i) + + // For anonymous (embedded) fields, recurse with the same prefix. + if field.Anonymous { + applyEnvToStruct(fv, prefix) + continue + } + + tag := field.Tag.Get("toml") + if tag == "" || tag == "-" { + continue + } + envKey := prefix + "_" + strings.ToUpper(tag) + + // Handle Duration wrapper before generic struct recursion. + if field.Type == reflect.TypeOf(Duration{}) { + envVal, ok := os.LookupEnv(envKey) + if ok { + d, parseErr := time.ParseDuration(envVal) + if parseErr == nil { + fv.Set(reflect.ValueOf(Duration{d})) + } + } + continue + } + + if field.Type.Kind() == reflect.Struct { + applyEnvToStruct(fv, envKey) + continue + } + + envVal, ok := os.LookupEnv(envKey) + if !ok { + continue + } + + switch fv.Kind() { + case reflect.String: + fv.SetString(envVal) + case reflect.Bool: + fv.SetBool(envVal == "true" || envVal == "1") + case reflect.Slice: + if field.Type.Elem().Kind() == reflect.String { + parts := strings.Split(envVal, ",") + for j := range parts { + parts[j] = strings.TrimSpace(parts[j]) + } + fv.Set(reflect.ValueOf(parts)) + } + } + } +} diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..7f41232 --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,403 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "testing" + "time" +) + +// testConfig embeds Base and adds a service-specific section. +type testConfig struct { + Base + MyService myServiceConfig `toml:"my_service"` +} + +type myServiceConfig struct { + Name string `toml:"name"` + Enabled bool `toml:"enabled"` + Items []string `toml:"items"` +} + +// validatingConfig implements the Validator interface. +type validatingConfig struct { + Base + Custom customSection `toml:"custom"` +} + +type customSection struct { + Required string `toml:"required"` +} + +func (c *validatingConfig) Validate() error { + if c.Custom.Required == "" { + return fmt.Errorf("custom.required is missing") + } + return nil +} + +const minimalTOML = ` +[server] +listen_addr = ":8443" +tls_cert = "/tmp/cert.pem" +tls_key = "/tmp/key.pem" + +[database] +path = "/tmp/test.db" + +[mcias] +server_url = "https://mcias.example.com" +service_name = "test" + +[log] +level = "debug" +` + +const fullTOML = ` +[server] +listen_addr = ":8443" +tls_cert = "/tmp/cert.pem" +tls_key = "/tmp/key.pem" +grpc_addr = ":9443" +read_timeout = "10s" +write_timeout = "15s" +idle_timeout = "60s" +shutdown_timeout = "30s" + +[database] +path = "/tmp/test.db" + +[mcias] +server_url = "https://mcias.example.com" +ca_cert = "/tmp/ca.pem" +service_name = "myservice" +tags = ["env:test", "tier:dev"] + +[log] +level = "warn" + +[my_service] +name = "hello" +enabled = true +items = ["a", "b", "c"] +` + +func writeTOML(t *testing.T, content string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "test.toml") + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatalf("write config: %v", err) + } + return path +} + +func TestLoadMinimal(t *testing.T) { + path := writeTOML(t, minimalTOML) + cfg, err := Load[testConfig](path, "TEST") + if err != nil { + t.Fatalf("Load: %v", err) + } + + if cfg.Server.ListenAddr != ":8443" { + t.Fatalf("ListenAddr = %q, want %q", cfg.Server.ListenAddr, ":8443") + } + if cfg.Log.Level != "debug" { + t.Fatalf("Log.Level = %q, want %q", cfg.Log.Level, "debug") + } + if cfg.MCIAS.ServerURL != "https://mcias.example.com" { + t.Fatalf("MCIAS.ServerURL = %q", cfg.MCIAS.ServerURL) + } +} + +func TestLoadFull(t *testing.T) { + path := writeTOML(t, fullTOML) + cfg, err := Load[testConfig](path, "TEST") + if err != nil { + t.Fatalf("Load: %v", err) + } + + if cfg.Server.GRPCAddr != ":9443" { + t.Fatalf("GRPCAddr = %q, want %q", cfg.Server.GRPCAddr, ":9443") + } + if cfg.Server.ReadTimeout.Duration != 10*time.Second { + t.Fatalf("ReadTimeout = %v, want 10s", cfg.Server.ReadTimeout) + } + if cfg.Server.WriteTimeout.Duration != 15*time.Second { + t.Fatalf("WriteTimeout = %v, want 15s", cfg.Server.WriteTimeout) + } + if cfg.MCIAS.CACert != "/tmp/ca.pem" { + t.Fatalf("CACert = %q", cfg.MCIAS.CACert) + } + if len(cfg.MCIAS.Tags) != 2 { + t.Fatalf("Tags = %v, want 2 items", cfg.MCIAS.Tags) + } + if cfg.MyService.Name != "hello" { + t.Fatalf("MyService.Name = %q, want %q", cfg.MyService.Name, "hello") + } + if !cfg.MyService.Enabled { + t.Fatal("MyService.Enabled = false, want true") + } + if len(cfg.MyService.Items) != 3 { + t.Fatalf("MyService.Items = %v, want 3 items", cfg.MyService.Items) + } +} + +func TestDefaults(t *testing.T) { + path := writeTOML(t, minimalTOML) + cfg, err := Load[testConfig](path, "TEST") + if err != nil { + t.Fatalf("Load: %v", err) + } + + if cfg.Server.ReadTimeout.Duration != 30*time.Second { + t.Fatalf("ReadTimeout = %v, want 30s (default)", cfg.Server.ReadTimeout) + } + if cfg.Server.WriteTimeout.Duration != 30*time.Second { + t.Fatalf("WriteTimeout = %v, want 30s (default)", cfg.Server.WriteTimeout) + } + if cfg.Server.IdleTimeout.Duration != 120*time.Second { + t.Fatalf("IdleTimeout = %v, want 120s (default)", cfg.Server.IdleTimeout) + } + if cfg.Server.ShutdownTimeout.Duration != 60*time.Second { + t.Fatalf("ShutdownTimeout = %v, want 60s (default)", cfg.Server.ShutdownTimeout) + } +} + +func TestDefaultsNotOverrideExplicit(t *testing.T) { + path := writeTOML(t, fullTOML) + cfg, err := Load[testConfig](path, "TEST") + if err != nil { + t.Fatalf("Load: %v", err) + } + + // fullTOML sets read_timeout = "10s"; default is 30s. + if cfg.Server.ReadTimeout.Duration != 10*time.Second { + t.Fatalf("ReadTimeout = %v, want 10s (explicit, not default)", cfg.Server.ReadTimeout) + } +} + +func TestDefaultLogLevel(t *testing.T) { + toml := ` +[server] +listen_addr = ":8443" +tls_cert = "/tmp/cert.pem" +tls_key = "/tmp/key.pem" + +[database] +path = "/tmp/test.db" + +[mcias] +server_url = "https://mcias.example.com" +` + path := writeTOML(t, toml) + cfg, err := Load[testConfig](path, "TEST") + if err != nil { + t.Fatalf("Load: %v", err) + } + + if cfg.Log.Level != "info" { + t.Fatalf("Log.Level = %q, want %q (default)", cfg.Log.Level, "info") + } +} + +func TestMissingRequiredField(t *testing.T) { + tests := []struct { + name string + toml string + }{ + { + "missing listen_addr", + `[server] +tls_cert = "/tmp/cert.pem" +tls_key = "/tmp/key.pem" +[database] +path = "/tmp/test.db"`, + }, + { + "missing tls_cert", + `[server] +listen_addr = ":8443" +tls_key = "/tmp/key.pem" +[database] +path = "/tmp/test.db"`, + }, + { + "missing tls_key", + `[server] +listen_addr = ":8443" +tls_cert = "/tmp/cert.pem" +[database] +path = "/tmp/test.db"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := writeTOML(t, tt.toml) + _, err := Load[testConfig](path, "TEST") + if err == nil { + t.Fatal("expected error for missing required field") + } + }) + } +} + +func TestEnvOverrideString(t *testing.T) { + path := writeTOML(t, minimalTOML) + + t.Setenv("TEST_SERVER_LISTEN_ADDR", ":9999") + + cfg, err := Load[testConfig](path, "TEST") + if err != nil { + t.Fatalf("Load: %v", err) + } + + if cfg.Server.ListenAddr != ":9999" { + t.Fatalf("ListenAddr = %q, want %q (from env)", cfg.Server.ListenAddr, ":9999") + } +} + +func TestEnvOverrideDuration(t *testing.T) { + path := writeTOML(t, minimalTOML) + + t.Setenv("TEST_SERVER_READ_TIMEOUT", "5s") + + cfg, err := Load[testConfig](path, "TEST") + if err != nil { + t.Fatalf("Load: %v", err) + } + + if cfg.Server.ReadTimeout.Duration != 5*time.Second { + t.Fatalf("ReadTimeout = %v, want 5s (from env)", cfg.Server.ReadTimeout) + } +} + +func TestEnvOverrideSlice(t *testing.T) { + path := writeTOML(t, minimalTOML) + + t.Setenv("TEST_MCIAS_TAGS", "env:prod, tier:api") + + cfg, err := Load[testConfig](path, "TEST") + if err != nil { + t.Fatalf("Load: %v", err) + } + + if len(cfg.MCIAS.Tags) != 2 { + t.Fatalf("Tags = %v, want 2 items", cfg.MCIAS.Tags) + } + if cfg.MCIAS.Tags[0] != "env:prod" { + t.Fatalf("Tags[0] = %q, want %q", cfg.MCIAS.Tags[0], "env:prod") + } + if cfg.MCIAS.Tags[1] != "tier:api" { + t.Fatalf("Tags[1] = %q, want %q", cfg.MCIAS.Tags[1], "tier:api") + } +} + +func TestEnvOverrideServiceSpecific(t *testing.T) { + path := writeTOML(t, fullTOML) + + t.Setenv("TEST_MY_SERVICE_NAME", "overridden") + + cfg, err := Load[testConfig](path, "TEST") + if err != nil { + t.Fatalf("Load: %v", err) + } + + if cfg.MyService.Name != "overridden" { + t.Fatalf("MyService.Name = %q, want %q (from env)", cfg.MyService.Name, "overridden") + } +} + +func TestEnvOverrideBool(t *testing.T) { + path := writeTOML(t, minimalTOML) + + t.Setenv("TEST_MY_SERVICE_ENABLED", "true") + + cfg, err := Load[testConfig](path, "TEST") + if err != nil { + t.Fatalf("Load: %v", err) + } + + if !cfg.MyService.Enabled { + t.Fatal("MyService.Enabled = false, want true (from env)") + } +} + +func TestValidatorCalled(t *testing.T) { + toml := ` +[server] +listen_addr = ":8443" +tls_cert = "/tmp/cert.pem" +tls_key = "/tmp/key.pem" + +[database] +path = "/tmp/test.db" + +[mcias] +server_url = "https://mcias.example.com" +` + path := writeTOML(t, toml) + + // custom.required is missing → Validate should fail. + _, err := Load[validatingConfig](path, "TEST") + if err == nil { + t.Fatal("expected validation error for missing custom.required") + } +} + +func TestValidatorPasses(t *testing.T) { + toml := ` +[server] +listen_addr = ":8443" +tls_cert = "/tmp/cert.pem" +tls_key = "/tmp/key.pem" + +[database] +path = "/tmp/test.db" + +[mcias] +server_url = "https://mcias.example.com" + +[custom] +required = "present" +` + path := writeTOML(t, toml) + + cfg, err := Load[validatingConfig](path, "TEST") + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Custom.Required != "present" { + t.Fatalf("Custom.Required = %q, want %q", cfg.Custom.Required, "present") + } +} + +func TestLoadNonexistentFile(t *testing.T) { + _, err := Load[testConfig]("/nonexistent/path.toml", "TEST") + if err == nil { + t.Fatal("expected error for nonexistent file") + } +} + +func TestLoadInvalidTOML(t *testing.T) { + path := writeTOML(t, "this is not valid toml [[[") + _, err := Load[testConfig](path, "TEST") + if err == nil { + t.Fatal("expected error for invalid TOML") + } +} + +func TestEmptyEnvPrefix(t *testing.T) { + path := writeTOML(t, minimalTOML) + + // Should work fine with no env prefix (no overrides applied). + cfg, err := Load[testConfig](path, "") + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Server.ListenAddr != ":8443" { + t.Fatalf("ListenAddr = %q, want %q", cfg.Server.ListenAddr, ":8443") + } +} diff --git a/config/duration.go b/config/duration.go new file mode 100644 index 0000000..71a99ac --- /dev/null +++ b/config/duration.go @@ -0,0 +1,28 @@ +package config + +import ( + "fmt" + "time" +) + +// Duration is a time.Duration that can be unmarshalled from a TOML string +// (e.g., "30s", "5m"). go-toml v2 does not natively decode strings into +// time.Duration, so this wrapper implements encoding.TextUnmarshaler. +type Duration struct { + time.Duration +} + +// UnmarshalText implements encoding.TextUnmarshaler for TOML string decoding. +func (d *Duration) UnmarshalText(text []byte) error { + parsed, err := time.ParseDuration(string(text)) + if err != nil { + return fmt.Errorf("invalid duration %q: %w", string(text), err) + } + d.Duration = parsed + return nil +} + +// MarshalText implements encoding.TextMarshaler for TOML string encoding. +func (d Duration) MarshalText() ([]byte, error) { + return []byte(d.String()), nil +} diff --git a/go.mod b/go.mod index c5dbd6e..686e1e2 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,10 @@ module git.wntrmute.dev/kyle/mcdsl go 1.25.7 -require modernc.org/sqlite v1.47.0 +require ( + github.com/pelletier/go-toml/v2 v2.3.0 + modernc.org/sqlite v1.47.0 +) require ( github.com/dustin/go-humanize v1.0.1 // indirect diff --git a/go.sum b/go.sum index aadd7dd..7256001 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= +github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=