Files
mrcharles 7bec2a1905 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
2026-08-28 22:30:50 -05:00

256 lines
5.7 KiB
Go

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
}