Add config loader with strict stdlib-only TOML subset parser
Parse keyproxy.toml (listen address, [auth] token_ref, and the mpk-<name>
ref map) using a deliberate TOML subset implemented with the standard
library only. The surface is strict: unknown keys, malformed refs, and
backend-specific misconfigurations (e.g. env refs setting key, file refs
missing key) fail loudly at startup. The bearer-token ref must resolve
through the file backend. Parse errors carry line numbers, never line
contents.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
This commit is contained in:
@@ -0,0 +1,157 @@
|
|||||||
|
// Package config loads keyproxy.toml: the listen address, the bearer
|
||||||
|
// auth ref, and the ref map (mpk-<name> -> backend/source/key). The
|
||||||
|
// config holds locations only — never material; keyproxy.toml.example is
|
||||||
|
// tracked, the real file is gitignored.
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.knownelement.com/ukrrs/mopac-keyproxy/internal/backend"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultListen is the loopback bind address used when [listen] is unset.
|
||||||
|
const DefaultListen = "127.0.0.1:8082"
|
||||||
|
|
||||||
|
// knownBackends are the backend names the loader accepts.
|
||||||
|
var knownBackends = map[string]bool{
|
||||||
|
"file": true,
|
||||||
|
"env": true,
|
||||||
|
"bitwarden": true,
|
||||||
|
"vault": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// refPattern is the shape every consumer-facing ref must have
|
||||||
|
// (mpk-<name>, lowercase/digits/hyphens). Because the pattern is
|
||||||
|
// enforced before any echo, log lines and error bodies can name refs
|
||||||
|
// safely: an mpk- ref is a placeholder, by construction not material.
|
||||||
|
var refPattern = regexp.MustCompile(`^mpk-[a-z0-9][a-z0-9-]*$`)
|
||||||
|
|
||||||
|
// ValidRef reports whether name is a well-formed mpk- ref.
|
||||||
|
func ValidRef(name string) bool { return refPattern.MatchString(name) }
|
||||||
|
|
||||||
|
// Config is the parsed keyproxy.toml.
|
||||||
|
type Config struct {
|
||||||
|
Listen string // bind address, default 127.0.0.1:8082
|
||||||
|
AuthTokenRef string // [auth] token_ref; must be a file-backend ref
|
||||||
|
Refs map[string]backend.Ref // ref name -> backend ref
|
||||||
|
RefNames []string // sorted ref names
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads, parses and validates the config at path.
|
||||||
|
func Load(path string) (*Config, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read config: %w", err)
|
||||||
|
}
|
||||||
|
doc, err := parseTOML(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", path, err)
|
||||||
|
}
|
||||||
|
cfg := &Config{Refs: map[string]backend.Ref{}}
|
||||||
|
|
||||||
|
// Strict surface: unknown keys fail loudly (a misconfigured file must
|
||||||
|
// not start on typo'd best effort).
|
||||||
|
for _, k := range doc.Keys() {
|
||||||
|
switch k {
|
||||||
|
case "listen", "auth", "refs":
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("%s: unknown top-level key %q (allowed: listen, auth, refs)", path, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if s, ok := doc.Str("listen"); ok {
|
||||||
|
cfg.Listen = s
|
||||||
|
} else {
|
||||||
|
cfg.Listen = DefaultListen
|
||||||
|
}
|
||||||
|
|
||||||
|
auth := doc.Table("auth")
|
||||||
|
for _, k := range auth.Keys() {
|
||||||
|
if k != "token_ref" {
|
||||||
|
return nil, fmt.Errorf("%s: [auth]: unknown key %q (allowed: token_ref)", path, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cfg.AuthTokenRef, _ = auth.Str("token_ref")
|
||||||
|
if cfg.AuthTokenRef == "" {
|
||||||
|
return nil, fmt.Errorf("[auth]: token_ref is required (bearer token for /v1/resolve, resolved through the file backend)")
|
||||||
|
}
|
||||||
|
|
||||||
|
refs := doc.Table("refs")
|
||||||
|
if len(refs) == 0 {
|
||||||
|
return nil, fmt.Errorf("[refs]: no refs configured")
|
||||||
|
}
|
||||||
|
for _, name := range refs.Keys() {
|
||||||
|
ref, err := parseRef(name, refs.Table(name))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Refs[name] = ref
|
||||||
|
cfg.RefNames = append(cfg.RefNames, name)
|
||||||
|
}
|
||||||
|
sort.Strings(cfg.RefNames)
|
||||||
|
|
||||||
|
authRef, ok := cfg.Refs[cfg.AuthTokenRef]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("[auth]: token_ref %q is not a configured ref", cfg.AuthTokenRef)
|
||||||
|
}
|
||||||
|
if authRef.Backend != "file" {
|
||||||
|
return nil, fmt.Errorf("[auth]: token_ref %q must use the file backend (auth is bootstrapped from a 0600 env file), got %q", cfg.AuthTokenRef, authRef.Backend)
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRef validates one [refs."mpk-<name>"] table.
|
||||||
|
func parseRef(name string, t Doc) (backend.Ref, error) {
|
||||||
|
if !ValidRef(name) {
|
||||||
|
return backend.Ref{}, fmt.Errorf(`[refs."%s"]: ref name must match mpk-<name> (lowercase alphanumerics and hyphens)`, name)
|
||||||
|
}
|
||||||
|
ref := backend.Ref{Name: name}
|
||||||
|
ref.Backend, _ = t.Str("backend")
|
||||||
|
if !knownBackends[ref.Backend] {
|
||||||
|
return backend.Ref{}, fmt.Errorf(`[refs."%s"]: backend must be one of file, env, bitwarden, vault`, name)
|
||||||
|
}
|
||||||
|
ref.Source, _ = t.Str("source")
|
||||||
|
ref.Key, _ = t.Str("key")
|
||||||
|
ref.Mode, _ = t.Str("mode")
|
||||||
|
if ref.Mode == "" {
|
||||||
|
ref.Mode = "0600"
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, k := range t.Keys() {
|
||||||
|
switch k {
|
||||||
|
case "backend", "source", "key", "mode":
|
||||||
|
default:
|
||||||
|
return backend.Ref{}, fmt.Errorf(`[refs."%s"]: unknown key %q (allowed: backend, source, key, mode)`, name, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ref.Backend {
|
||||||
|
case "file":
|
||||||
|
if ref.Source == "" {
|
||||||
|
return backend.Ref{}, fmt.Errorf(`[refs."%s"]: file backend requires source (path to a 0600 env file)`, name)
|
||||||
|
}
|
||||||
|
if ref.Key == "" {
|
||||||
|
return backend.Ref{}, fmt.Errorf(`[refs."%s"]: file backend requires key (KEY inside the env file)`, name)
|
||||||
|
}
|
||||||
|
if m, err := strconv.ParseUint(strings.TrimPrefix(ref.Mode, "0o"), 8, 32); err != nil || os.FileMode(m)&os.ModePerm == 0 {
|
||||||
|
return backend.Ref{}, fmt.Errorf(`[refs."%s"]: mode must be an octal permission mask like "0600"`, name)
|
||||||
|
}
|
||||||
|
case "env":
|
||||||
|
if ref.Source == "" {
|
||||||
|
return backend.Ref{}, fmt.Errorf(`[refs."%s"]: env backend requires source (environment variable name)`, name)
|
||||||
|
}
|
||||||
|
if ref.Key != "" {
|
||||||
|
return backend.Ref{}, fmt.Errorf(`[refs."%s"]: env backend must not set key (source IS the variable name)`, name)
|
||||||
|
}
|
||||||
|
if _, ok := t.Str("mode"); ok {
|
||||||
|
return backend.Ref{}, fmt.Errorf(`[refs."%s"]: mode applies to the file backend only`, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ref, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const validConfig = `
|
||||||
|
listen = "127.0.0.1:9999"
|
||||||
|
|
||||||
|
[auth]
|
||||||
|
token_ref = "mpk-self"
|
||||||
|
|
||||||
|
[refs."mpk-self"]
|
||||||
|
backend = "file"
|
||||||
|
source = "/tmp/keyproxy.env"
|
||||||
|
key = "KEYPROXY_TOKEN"
|
||||||
|
|
||||||
|
[refs."mpk-example"]
|
||||||
|
backend = "file"
|
||||||
|
source = "/tmp/example.env"
|
||||||
|
key = "EXAMPLE_API_KEY"
|
||||||
|
|
||||||
|
[refs."mpk-example-env"]
|
||||||
|
backend = "env"
|
||||||
|
source = "EXAMPLE_API_KEY"
|
||||||
|
|
||||||
|
[refs."mpk-stub-bw"]
|
||||||
|
backend = "bitwarden"
|
||||||
|
source = "sm://p/example"
|
||||||
|
key = "EXAMPLE_API_KEY"
|
||||||
|
|
||||||
|
[refs."mpk-stub-vault"]
|
||||||
|
backend = "vault"
|
||||||
|
`
|
||||||
|
|
||||||
|
func writeConfig(t *testing.T, content string) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), "keyproxy.toml")
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadValid(t *testing.T) {
|
||||||
|
cfg, err := Load(writeConfig(t, validConfig))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Listen != "127.0.0.1:9999" {
|
||||||
|
t.Fatalf("listen = %q", cfg.Listen)
|
||||||
|
}
|
||||||
|
if cfg.AuthTokenRef != "mpk-self" {
|
||||||
|
t.Fatalf("auth token ref = %q", cfg.AuthTokenRef)
|
||||||
|
}
|
||||||
|
if got := strings.Join(cfg.RefNames, ","); got != "mpk-example,mpk-example-env,mpk-self,mpk-stub-bw,mpk-stub-vault" {
|
||||||
|
t.Fatalf("ref names = %q", got)
|
||||||
|
}
|
||||||
|
file := cfg.Refs["mpk-example"]
|
||||||
|
if file.Backend != "file" || file.Source != "/tmp/example.env" || file.Key != "EXAMPLE_API_KEY" || file.Mode != "0600" {
|
||||||
|
t.Fatalf("file ref parsed wrong: %+v", file)
|
||||||
|
}
|
||||||
|
if cfg.Refs["mpk-stub-bw"].Backend != "bitwarden" {
|
||||||
|
t.Fatalf("stub ref parsed wrong: %+v", cfg.Refs["mpk-stub-bw"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadDefaults(t *testing.T) {
|
||||||
|
cfg, err := Load(writeConfig(t, strings.Replace(validConfig, `listen = "127.0.0.1:9999"`, "", 1)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Listen != DefaultListen {
|
||||||
|
t.Fatalf("default listen = %q, want %q", cfg.Listen, DefaultListen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadErrors(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(string) string
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "missing file",
|
||||||
|
mutate: func(string) string { return "" },
|
||||||
|
wantErr: "read config",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bad ref name",
|
||||||
|
mutate: func(s string) string { return strings.Replace(s, `mpk-example"`, `mpk_ExAMPLE"`, 1) },
|
||||||
|
wantErr: "must match mpk-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown backend",
|
||||||
|
mutate: func(s string) string { return strings.Replace(s, `backend = "env"`, `backend = "s3"`, 1) },
|
||||||
|
wantErr: "backend must be one of",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "file ref without source",
|
||||||
|
mutate: func(s string) string { return strings.Replace(s, `source = "/tmp/example.env"`, "", 1) },
|
||||||
|
wantErr: "file backend requires source",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "file ref without key",
|
||||||
|
mutate: func(s string) string { return strings.Replace(s, `key = "EXAMPLE_API_KEY"`, "", 1) },
|
||||||
|
wantErr: "file backend requires key",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "file ref with junk mode",
|
||||||
|
mutate: func(s string) string { return strings.Replace(s, `key = "EXAMPLE_API_KEY"`, `key = "EXAMPLE_API_KEY"`+"\n"+`mode = "readable"`, 1) },
|
||||||
|
wantErr: "mode must be an octal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "env ref with key",
|
||||||
|
mutate: func(s string) string { return strings.Replace(s, `source = "EXAMPLE_API_KEY"`, `source = "EXAMPLE_API_KEY"`+"\n"+`key = "X"`, 1) },
|
||||||
|
wantErr: "env backend must not set key",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "env ref with mode",
|
||||||
|
mutate: func(s string) string { return strings.Replace(s, `source = "EXAMPLE_API_KEY"`, `source = "EXAMPLE_API_KEY"`+"\n"+`mode = "0644"`, 1) },
|
||||||
|
wantErr: "mode applies to the file backend only",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown key inside ref table",
|
||||||
|
mutate: func(s string) string { return strings.Replace(s, `key = "EXAMPLE_API_KEY"`, `key = "EXAMPLE_API_KEY"`+"\n"+`ttl = "5m"`, 1) },
|
||||||
|
wantErr: "unknown key",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no auth section",
|
||||||
|
mutate: func(s string) string { return strings.Replace(s, "token_ref = \"mpk-self\"", "", 1) },
|
||||||
|
wantErr: "token_ref is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "auth ref not configured",
|
||||||
|
mutate: func(s string) string { return strings.Replace(s, `token_ref = "mpk-self"`, `token_ref = "mpk-ghost"`, 1) },
|
||||||
|
wantErr: "not a configured ref",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "auth ref not file backend",
|
||||||
|
mutate: func(s string) string {
|
||||||
|
return strings.Replace(s, `token_ref = "mpk-self"`, `token_ref = "mpk-example-env"`, 1)
|
||||||
|
},
|
||||||
|
wantErr: "must use the file backend",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no refs at all",
|
||||||
|
mutate: func(s string) string { return "listen = \"127.0.0.1:9999\"\n[auth]\ntoken_ref = \"mpk-self\"\n" },
|
||||||
|
wantErr: "no refs configured",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown top-level key",
|
||||||
|
mutate: func(s string) string { return "ttl = \"5m\"\n" + s },
|
||||||
|
wantErr: "unknown top-level key",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown auth key",
|
||||||
|
mutate: func(s string) string { return strings.Replace(s, "token_ref = \"mpk-self\"", "token_ref = \"mpk-self\"\nrotation = \"7d\"", 1) },
|
||||||
|
wantErr: "unknown key",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
content := tt.mutate(validConfig)
|
||||||
|
path := writeConfig(t, content)
|
||||||
|
if content == "" {
|
||||||
|
path = filepath.Join(t.TempDir(), "absent.toml")
|
||||||
|
}
|
||||||
|
cfg, err := Load(path)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("want error %q, got config %+v", tt.wantErr, cfg)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||||
|
t.Fatalf("error %q does not contain %q", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidRef(t *testing.T) {
|
||||||
|
valid := []string{"mpk-a", "mpk-redmine", "mpk-a-1", "mpk-9"}
|
||||||
|
invalid := []string{"", "redmine", "MPK-a", "mpk-", "mpk--a", "mpk_a", "mpk-a_b", "mpk-a.b", "mpk-a b", "sk-live-pasted-secret"}
|
||||||
|
for _, r := range valid {
|
||||||
|
if !ValidRef(r) {
|
||||||
|
t.Errorf("ValidRef(%q) = false, want true", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, r := range invalid {
|
||||||
|
if ValidRef(r) {
|
||||||
|
t.Errorf("ValidRef(%q) = true, want false", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Doc is the parsed representation of keyproxy.toml: nested tables are
|
||||||
|
// Doc values; leaves are string, int64 or bool. This is a deliberate
|
||||||
|
// stdlib-only TOML subset (tables with quoted keys, basic strings with
|
||||||
|
// escapes, integers, booleans, comments). Anything richer is a parse
|
||||||
|
// error so a misconfigured file fails loudly instead of silently.
|
||||||
|
type Doc map[string]any
|
||||||
|
|
||||||
|
// Table returns the nested table at the key path, or an empty doc.
|
||||||
|
func (d Doc) Table(keys ...string) Doc {
|
||||||
|
cur := any(d)
|
||||||
|
for _, k := range keys {
|
||||||
|
m, ok := cur.(Doc)
|
||||||
|
if !ok {
|
||||||
|
return Doc{}
|
||||||
|
}
|
||||||
|
cur = m[k]
|
||||||
|
}
|
||||||
|
if m, ok := cur.(Doc); ok {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
return Doc{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Str returns the string leaf at key.
|
||||||
|
func (d Doc) Str(key string) (string, bool) {
|
||||||
|
v, ok := d[key].(string)
|
||||||
|
return v, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Int returns the int64 leaf at key.
|
||||||
|
func (d Doc) Int(key string) (int64, bool) {
|
||||||
|
v, ok := d[key].(int64)
|
||||||
|
return v, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bool returns the bool leaf at key.
|
||||||
|
func (d Doc) Bool(key string) (bool, bool) {
|
||||||
|
v, ok := d[key].(bool)
|
||||||
|
return v, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keys lists the doc's keys, sorted.
|
||||||
|
func (d Doc) Keys() []string {
|
||||||
|
out := make([]string, 0, len(d))
|
||||||
|
for k := range d {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTOML parses the TOML subset into a Doc. Errors carry line
|
||||||
|
// numbers, never line contents (a config file may sit next to secrets
|
||||||
|
// someone pasted in by mistake).
|
||||||
|
func parseTOML(data []byte) (Doc, error) {
|
||||||
|
root := Doc{}
|
||||||
|
cur := root
|
||||||
|
for i, raw := range strings.Split(string(data), "\n") {
|
||||||
|
lineno := i + 1
|
||||||
|
line := stripComment(raw)
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "[") {
|
||||||
|
if !strings.HasSuffix(line, "]") {
|
||||||
|
return nil, fmt.Errorf("line %d: malformed table header", lineno)
|
||||||
|
}
|
||||||
|
path, err := splitKeyPath(strings.TrimSpace(line[1 : len(line)-1]))
|
||||||
|
if err != nil || len(path) == 0 {
|
||||||
|
return nil, fmt.Errorf("line %d: malformed table header", lineno)
|
||||||
|
}
|
||||||
|
cur = root
|
||||||
|
for _, seg := range path {
|
||||||
|
next, ok := cur[seg].(Doc)
|
||||||
|
if !ok {
|
||||||
|
if _, exists := cur[seg]; exists {
|
||||||
|
return nil, fmt.Errorf("line %d: table conflicts with value key %q", lineno, seg)
|
||||||
|
}
|
||||||
|
next = Doc{}
|
||||||
|
cur[seg] = next
|
||||||
|
}
|
||||||
|
cur = next
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
eq := strings.IndexByte(line, '=')
|
||||||
|
if eq < 1 {
|
||||||
|
return nil, fmt.Errorf("line %d: expected key = value", lineno)
|
||||||
|
}
|
||||||
|
key := strings.TrimSpace(line[:eq])
|
||||||
|
key = strings.Trim(key, `"'`)
|
||||||
|
if key == "" || strings.ContainsAny(key, `".[]`) {
|
||||||
|
return nil, fmt.Errorf("line %d: malformed key", lineno)
|
||||||
|
}
|
||||||
|
value := strings.TrimSpace(line[eq+1:])
|
||||||
|
parsed, err := parseValue(value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("line %d: %v", lineno, err)
|
||||||
|
}
|
||||||
|
if _, dup := cur[key]; dup {
|
||||||
|
return nil, fmt.Errorf("line %d: duplicate key %q", lineno, key)
|
||||||
|
}
|
||||||
|
cur[key] = parsed
|
||||||
|
}
|
||||||
|
return root, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitKeyPath splits a.b."c.d" into [a b c.d]; each segment is bare or
|
||||||
|
// double-quoted.
|
||||||
|
func splitKeyPath(s string) ([]string, error) {
|
||||||
|
var segs []string
|
||||||
|
for i := 0; i < len(s); {
|
||||||
|
for i < len(s) && (s[i] == ' ' || s[i] == '\t') {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if i >= len(s) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
switch s[i] {
|
||||||
|
case '"':
|
||||||
|
j := i + 1
|
||||||
|
for j < len(s) && s[j] != '"' {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
if j >= len(s) {
|
||||||
|
return nil, fmt.Errorf("unterminated quoted key")
|
||||||
|
}
|
||||||
|
segs = append(segs, s[i+1:j])
|
||||||
|
i = j + 1
|
||||||
|
default:
|
||||||
|
j := i
|
||||||
|
for j < len(s) && s[j] != '.' {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
seg := strings.TrimSpace(s[i:j])
|
||||||
|
if seg == "" {
|
||||||
|
return nil, fmt.Errorf("empty key segment")
|
||||||
|
}
|
||||||
|
segs = append(segs, seg)
|
||||||
|
i = j
|
||||||
|
}
|
||||||
|
for i < len(s) && (s[i] == ' ' || s[i] == '\t') {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if i < len(s) {
|
||||||
|
if s[i] != '.' {
|
||||||
|
return nil, fmt.Errorf("unexpected character in table header")
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return segs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseValue parses a basic string, integer or boolean.
|
||||||
|
func parseValue(v string) (any, error) {
|
||||||
|
if v == "" {
|
||||||
|
return nil, fmt.Errorf("missing value")
|
||||||
|
}
|
||||||
|
switch v[0] {
|
||||||
|
case '"':
|
||||||
|
s, rest, err := parseBasicString(v)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(rest) != "" {
|
||||||
|
return nil, fmt.Errorf("trailing characters after string value")
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
case '\'':
|
||||||
|
end := strings.IndexByte(v[1:], '\'')
|
||||||
|
if end < 0 {
|
||||||
|
return nil, fmt.Errorf("unterminated literal string")
|
||||||
|
}
|
||||||
|
s := v[1 : 1+end]
|
||||||
|
if strings.TrimSpace(v[2+end:]) != "" {
|
||||||
|
return nil, fmt.Errorf("trailing characters after string value")
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
switch v {
|
||||||
|
case "true":
|
||||||
|
return true, nil
|
||||||
|
case "false":
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseInt(v, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unsupported value (expected string, int or bool)")
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseBasicString parses one "..." basic string with \\ \" \n \t \r
|
||||||
|
// escapes; it returns the string and the unparsed remainder.
|
||||||
|
func parseBasicString(v string) (string, string, error) {
|
||||||
|
var b strings.Builder
|
||||||
|
for i := 1; i < len(v); i++ {
|
||||||
|
switch v[i] {
|
||||||
|
case '"':
|
||||||
|
return b.String(), v[i+1:], nil
|
||||||
|
case '\\':
|
||||||
|
if i+1 >= len(v) {
|
||||||
|
return "", "", fmt.Errorf("dangling escape in string")
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
switch v[i] {
|
||||||
|
case '\\':
|
||||||
|
b.WriteByte('\\')
|
||||||
|
case '"':
|
||||||
|
b.WriteByte('"')
|
||||||
|
case 'n':
|
||||||
|
b.WriteByte('\n')
|
||||||
|
case 't':
|
||||||
|
b.WriteByte('\t')
|
||||||
|
case 'r':
|
||||||
|
b.WriteByte('\r')
|
||||||
|
default:
|
||||||
|
return "", "", fmt.Errorf("unsupported escape in string")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
b.WriteByte(v[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", "", fmt.Errorf("unterminated string")
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripComment removes a trailing # comment that sits outside quotes.
|
||||||
|
func stripComment(line string) string {
|
||||||
|
var quote byte
|
||||||
|
for i := 0; i < len(line); i++ {
|
||||||
|
c := line[i]
|
||||||
|
switch {
|
||||||
|
case quote != 0:
|
||||||
|
if c == quote {
|
||||||
|
quote = 0
|
||||||
|
}
|
||||||
|
case c == '"' || c == '\'':
|
||||||
|
quote = c
|
||||||
|
case c == '#':
|
||||||
|
return line[:i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return line
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseTOML(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want map[string]any // dotted paths -> expected values
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "scalars and comments",
|
||||||
|
in: "# top\nlisten = \"127.0.0.1:8082\" # inline\nflag = true\nnum = 42\n",
|
||||||
|
want: map[string]any{"listen": "127.0.0.1:8082", "flag": true, "num": int64(42)},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tables with quoted dotted keys",
|
||||||
|
in: "[refs.\"mpk-redmine\"]\nbackend = \"file\"\n\n[refs.\"mpk-env\"]\nbackend = \"env\"\n",
|
||||||
|
want: map[string]any{"refs.mpk-redmine.backend": "file", "refs.mpk-env.backend": "env"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "escapes in basic strings",
|
||||||
|
in: "a = \"x\\ty\\\"z\"\n",
|
||||||
|
want: map[string]any{"a": "x\ty\"z"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "literal strings keep backslashes",
|
||||||
|
in: "a = 'C:\\path'\n",
|
||||||
|
want: map[string]any{"a": `C:\path`},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "duplicate key",
|
||||||
|
in: "a = \"1\"\na = \"2\"\n",
|
||||||
|
wantErr: "duplicate key",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unterminated string",
|
||||||
|
in: "a = \"oops\n",
|
||||||
|
wantErr: "unterminated string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unsupported richer syntax fails loudly",
|
||||||
|
in: "a = [\"x\", \"y\"]\n",
|
||||||
|
wantErr: "unsupported value",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "malformed table header",
|
||||||
|
in: "[refs\n",
|
||||||
|
wantErr: "malformed table header",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bare line without equals",
|
||||||
|
in: "garbage\n",
|
||||||
|
wantErr: "expected key = value",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "table conflicts with scalar",
|
||||||
|
in: "refs = \"x\"\n[refs.\"mpk-a\"]\nbackend = \"file\"\n",
|
||||||
|
wantErr: "conflicts",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
doc, err := parseTOML([]byte(tt.in))
|
||||||
|
if tt.wantErr != "" {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("want error %q, got doc %v", tt.wantErr, doc)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||||
|
t.Fatalf("error %q does not contain %q", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
for path, want := range tt.want {
|
||||||
|
segs := strings.Split(path, ".")
|
||||||
|
cur := any(doc)
|
||||||
|
for _, seg := range segs {
|
||||||
|
m, ok := cur.(Doc)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("path %s: %v is not a table", path, cur)
|
||||||
|
}
|
||||||
|
cur = m[seg]
|
||||||
|
}
|
||||||
|
if cur != want {
|
||||||
|
t.Fatalf("path %s: got %#v, want %#v", path, cur, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user