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 }