399 lines
11 KiB
Go
399 lines
11 KiB
Go
package config
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
type JSONDecoder struct{}
|
|
type YAMLDecoder struct{}
|
|
type XMLDecoder struct{}
|
|
|
|
type parseState struct {
|
|
doc Document
|
|
nodes int
|
|
}
|
|
|
|
func newParseState() *parseState { return &parseState{doc: Document{Positions: map[string]Position{}}} }
|
|
func (p *parseState) enter(path string, pos Position, depth int) error {
|
|
p.nodes++
|
|
p.doc.Positions[path] = pos
|
|
if depth > maxConfigDepth || p.nodes > maxConfigNodes {
|
|
return p.doc.diagnostic(path, "configuration nesting or node limit exceeded", nil)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type sourcePositions struct{ starts []int }
|
|
|
|
func newSourcePositions(data []byte) sourcePositions {
|
|
p := sourcePositions{starts: []int{0}}
|
|
for i, c := range data {
|
|
if c == '\n' {
|
|
p.starts = append(p.starts, i+1)
|
|
}
|
|
}
|
|
return p
|
|
}
|
|
func (p sourcePositions) at(offset int64) Position {
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
i := sort.Search(len(p.starts), func(i int) bool { return int64(p.starts[i]) > offset }) - 1
|
|
return Position{Line: i + 1, Column: int(offset) - p.starts[i] + 1}
|
|
}
|
|
|
|
var yamlLine = regexp.MustCompile(`line ([0-9]+)`)
|
|
|
|
func yamlSyntax(err error) *Error {
|
|
d := &Error{Reason: "invalid YAML syntax", cause: err}
|
|
if m := yamlLine.FindStringSubmatch(err.Error()); len(m) == 2 {
|
|
d.Line, _ = strconv.Atoi(m[1])
|
|
d.Column = 1
|
|
}
|
|
return d
|
|
}
|
|
func (JSONDecoder) Decode(data []byte) (Document, error) {
|
|
p := newParseState()
|
|
if len(data) > maxConfigBytes {
|
|
return p.doc, &Error{Reason: "file exceeds 1 MiB"}
|
|
}
|
|
if !utf8.Valid(data) {
|
|
return p.doc, &Error{Reason: "configuration must be UTF-8"}
|
|
}
|
|
positions := newSourcePositions(data)
|
|
d := json.NewDecoder(bytes.NewReader(data))
|
|
d.UseNumber()
|
|
var parse func(string, int) (any, error)
|
|
parse = func(path string, depth int) (any, error) {
|
|
offset := d.InputOffset()
|
|
for offset < int64(len(data)) && strings.ContainsRune(" \t\r\n:,", rune(data[offset])) {
|
|
offset++
|
|
}
|
|
if err := p.enter(path, positions.at(offset), depth); err != nil {
|
|
return nil, err
|
|
}
|
|
token, err := d.Token()
|
|
if err != nil {
|
|
pos := positions.at(d.InputOffset())
|
|
var syntax *json.SyntaxError
|
|
if errors.As(err, &syntax) {
|
|
pos = positions.at(syntax.Offset - 1)
|
|
}
|
|
return nil, &Error{Field: path, Line: pos.Line, Column: pos.Column, Reason: "invalid JSON syntax", cause: err}
|
|
}
|
|
if delim, ok := token.(json.Delim); ok {
|
|
switch delim {
|
|
case '{':
|
|
out := map[string]any{}
|
|
for d.More() {
|
|
k, e := d.Token()
|
|
if e != nil {
|
|
return nil, p.doc.diagnostic(path, "invalid object key", e)
|
|
}
|
|
key, ok := k.(string)
|
|
if !ok {
|
|
return nil, p.doc.diagnostic(path, "object key must be a string", nil)
|
|
}
|
|
child := joinField(path, key)
|
|
if _, exists := out[key]; exists {
|
|
return nil, p.doc.diagnostic(child, "duplicate field", nil)
|
|
}
|
|
out[key], e = parse(child, depth+1)
|
|
if e != nil {
|
|
return nil, e
|
|
}
|
|
}
|
|
if _, err = d.Token(); err != nil {
|
|
return nil, p.doc.diagnostic(path, "unterminated object", err)
|
|
}
|
|
return out, nil
|
|
case '[':
|
|
out := []any{}
|
|
for d.More() {
|
|
v, e := parse(fmt.Sprintf("%s[%d]", path, len(out)), depth+1)
|
|
if e != nil {
|
|
return nil, e
|
|
}
|
|
out = append(out, v)
|
|
}
|
|
if _, err = d.Token(); err != nil {
|
|
return nil, p.doc.diagnostic(path, "unterminated array", err)
|
|
}
|
|
return out, nil
|
|
default:
|
|
return nil, p.doc.diagnostic(path, "unexpected JSON delimiter", nil)
|
|
}
|
|
}
|
|
return token, nil
|
|
}
|
|
var err error
|
|
p.doc.Value, err = parse("", 0)
|
|
if err == nil {
|
|
if _, e := d.Token(); e != io.EOF {
|
|
err = p.doc.diagnostic("", "exactly one JSON document required", e)
|
|
}
|
|
}
|
|
return p.doc, err
|
|
}
|
|
|
|
func (YAMLDecoder) Decode(data []byte) (Document, error) {
|
|
p := newParseState()
|
|
if len(data) > maxConfigBytes {
|
|
return p.doc, &Error{Reason: "file exceeds 1 MiB"}
|
|
}
|
|
if !utf8.Valid(data) {
|
|
return p.doc, &Error{Reason: "configuration must be UTF-8"}
|
|
}
|
|
d := yaml.NewDecoder(bytes.NewReader(data))
|
|
var root yaml.Node
|
|
if err := d.Decode(&root); err != nil {
|
|
return p.doc, yamlSyntax(err)
|
|
}
|
|
var extra yaml.Node
|
|
if err := d.Decode(&extra); err != io.EOF {
|
|
return p.doc, &Error{Reason: "exactly one YAML document required", cause: err}
|
|
}
|
|
var walk func(*yaml.Node, string, int) (any, error)
|
|
walk = func(n *yaml.Node, path string, depth int) (any, error) {
|
|
if err := p.enter(path, Position{n.Line, n.Column}, depth); err != nil {
|
|
return nil, err
|
|
}
|
|
switch n.Kind {
|
|
case yaml.MappingNode:
|
|
out := map[string]any{}
|
|
for i := 0; i < len(n.Content); i += 2 {
|
|
k, v := n.Content[i], n.Content[i+1]
|
|
if k.Tag != "!!str" {
|
|
return nil, p.doc.diagnostic(path, "mapping keys must be strings; merge keys are not supported", nil)
|
|
}
|
|
child := joinField(path, k.Value)
|
|
if _, ok := out[k.Value]; ok {
|
|
return nil, &Error{Field: child, Line: k.Line, Column: k.Column, Reason: "duplicate field"}
|
|
}
|
|
x, err := walk(v, child, depth+1)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out[k.Value] = x
|
|
}
|
|
return out, nil
|
|
case yaml.SequenceNode:
|
|
out := make([]any, len(n.Content))
|
|
for i, c := range n.Content {
|
|
var err error
|
|
out[i], err = walk(c, fmt.Sprintf("%s[%d]", path, i), depth+1)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return out, nil
|
|
case yaml.AliasNode:
|
|
return nil, p.doc.diagnostic(path, "YAML aliases are not supported", nil)
|
|
case yaml.ScalarNode:
|
|
switch n.Tag {
|
|
case "!!str", "!!timestamp":
|
|
return n.Value, nil
|
|
case "!!null":
|
|
return nil, nil
|
|
case "!!bool", "!!int", "!!float":
|
|
var v any
|
|
if err := n.Decode(&v); err != nil {
|
|
return nil, p.doc.diagnostic(path, "invalid YAML scalar", err)
|
|
}
|
|
raw, err := json.Marshal(v)
|
|
if err != nil {
|
|
return nil, p.doc.diagnostic(path, "number must be finite", err)
|
|
}
|
|
var out any
|
|
dec := json.NewDecoder(bytes.NewReader(raw))
|
|
dec.UseNumber()
|
|
if err = dec.Decode(&out); err != nil {
|
|
return nil, p.doc.diagnostic(path, "invalid YAML scalar", err)
|
|
}
|
|
return out, nil
|
|
default:
|
|
return nil, p.doc.diagnostic(path, "unsupported YAML tag", nil)
|
|
}
|
|
}
|
|
return nil, p.doc.diagnostic(path, "unsupported YAML node", nil)
|
|
}
|
|
if len(root.Content) != 1 {
|
|
return p.doc, &Error{Reason: "one nonempty YAML document required"}
|
|
}
|
|
var err error
|
|
p.doc.Value, err = walk(root.Content[0], "", 0)
|
|
return p.doc, err
|
|
}
|
|
|
|
// XML uses a config root, element field names, type="array" with item children,
|
|
// type="object" for empty objects, and type="null" for explicit null. Scalar
|
|
// text is bound using the destination type; optional number/bool/string types
|
|
// let dynamic any values preserve their intended JSON type.
|
|
func (XMLDecoder) Decode(data []byte) (Document, error) {
|
|
p := newParseState()
|
|
if len(data) > maxConfigBytes {
|
|
return p.doc, &Error{Reason: "file exceeds 1 MiB"}
|
|
}
|
|
if !utf8.Valid(data) {
|
|
return p.doc, &Error{Reason: "configuration must be UTF-8"}
|
|
}
|
|
d := xml.NewDecoder(bytes.NewReader(data))
|
|
var parse func(xml.StartElement, string, int, Position) (any, error)
|
|
parse = func(start xml.StartElement, path string, depth int, pos Position) (any, error) {
|
|
if err := p.enter(path, pos, depth); err != nil {
|
|
return nil, err
|
|
}
|
|
if start.Name.Space != "" {
|
|
return nil, p.doc.diagnostic(path, "XML namespaces are not supported", nil)
|
|
}
|
|
kind := ""
|
|
attrSeen := false
|
|
for _, a := range start.Attr {
|
|
if a.Name.Space != "" || a.Name.Local != "type" || attrSeen {
|
|
return nil, p.doc.diagnostic(path, "only one XML type attribute is supported", nil)
|
|
}
|
|
kind = a.Value
|
|
attrSeen = true
|
|
}
|
|
if depth == 0 && kind == "" {
|
|
kind = "object"
|
|
}
|
|
switch kind {
|
|
case "", "object", "array", "null", "string", "number", "bool":
|
|
default:
|
|
return nil, p.doc.diagnostic(path, "unsupported XML type", nil)
|
|
}
|
|
children := map[string]any{}
|
|
array := []any{}
|
|
var text strings.Builder
|
|
for {
|
|
line, column := d.InputPos()
|
|
token, err := d.Token()
|
|
if err != nil {
|
|
return nil, &Error{Field: path, Line: line, Column: column, Reason: "invalid XML syntax", cause: err}
|
|
}
|
|
switch x := token.(type) {
|
|
case xml.StartElement:
|
|
if kind != "" && kind != "object" && kind != "array" {
|
|
return nil, p.doc.diagnostic(path, "scalar XML element cannot contain children", nil)
|
|
}
|
|
child := joinField(path, x.Name.Local)
|
|
if kind == "array" {
|
|
if x.Name.Local != "item" {
|
|
return nil, p.doc.diagnostic(path, "XML array children must be item elements", nil)
|
|
}
|
|
child = fmt.Sprintf("%s[%d]", path, len(array))
|
|
} else if _, ok := children[x.Name.Local]; ok {
|
|
return nil, p.doc.diagnostic(child, "duplicate field; use type=\"array\" for lists", nil)
|
|
}
|
|
v, e := parse(x, child, depth+1, Position{line, column})
|
|
if e != nil {
|
|
return nil, e
|
|
}
|
|
if kind == "array" {
|
|
array = append(array, v)
|
|
} else {
|
|
children[x.Name.Local] = v
|
|
}
|
|
case xml.CharData:
|
|
text.Write([]byte(x))
|
|
case xml.Comment:
|
|
case xml.EndElement:
|
|
raw := text.String()
|
|
if len(children) > 0 || kind == "array" || kind == "object" {
|
|
if strings.TrimSpace(raw) != "" {
|
|
return nil, p.doc.diagnostic(path, "mixed XML text and elements are not supported", nil)
|
|
}
|
|
if kind == "array" {
|
|
return array, nil
|
|
}
|
|
return children, nil
|
|
}
|
|
switch kind {
|
|
case "null":
|
|
if strings.TrimSpace(raw) != "" {
|
|
return nil, p.doc.diagnostic(path, "null element must be empty", nil)
|
|
}
|
|
return nil, nil
|
|
case "string":
|
|
return raw, nil
|
|
case "number":
|
|
var n any
|
|
dec := json.NewDecoder(strings.NewReader(strings.TrimSpace(raw)))
|
|
dec.UseNumber()
|
|
if err := dec.Decode(&n); err != nil {
|
|
return nil, p.doc.diagnostic(path, "invalid number", err)
|
|
}
|
|
if _, ok := n.(json.Number); !ok {
|
|
return nil, p.doc.diagnostic(path, "expected number", nil)
|
|
}
|
|
if _, e := dec.Token(); e != io.EOF {
|
|
return nil, p.doc.diagnostic(path, "invalid number", e)
|
|
}
|
|
return n, nil
|
|
case "bool":
|
|
b, e := strconv.ParseBool(strings.TrimSpace(raw))
|
|
if e != nil {
|
|
return nil, p.doc.diagnostic(path, "expected boolean", e)
|
|
}
|
|
return b, nil
|
|
}
|
|
return Text(raw), nil
|
|
default:
|
|
return nil, p.doc.diagnostic(path, "XML directives and processing instructions are not supported", nil)
|
|
}
|
|
}
|
|
}
|
|
found := false
|
|
prolog := false
|
|
for {
|
|
line, column := d.InputPos()
|
|
token, err := d.Token()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return p.doc, &Error{Line: line, Column: column, Reason: "invalid XML syntax", cause: err}
|
|
}
|
|
switch x := token.(type) {
|
|
case xml.StartElement:
|
|
if found || x.Name.Local != "config" {
|
|
return p.doc, &Error{Line: line, Column: column, Reason: "exactly one config root required"}
|
|
}
|
|
found = true
|
|
p.doc.Value, err = parse(x, "", 0, Position{line, column})
|
|
if err != nil {
|
|
return p.doc, err
|
|
}
|
|
case xml.CharData:
|
|
if strings.TrimSpace(string(x)) != "" {
|
|
return p.doc, &Error{Reason: "unexpected text outside XML root"}
|
|
}
|
|
case xml.Comment:
|
|
case xml.ProcInst:
|
|
if found || prolog || x.Target != "xml" {
|
|
return p.doc, &Error{Reason: "XML processing instructions are not supported"}
|
|
}
|
|
prolog = true
|
|
default:
|
|
return p.doc, &Error{Reason: "XML directives are not supported"}
|
|
}
|
|
}
|
|
if !found {
|
|
return p.doc, &Error{Reason: "config root required"}
|
|
}
|
|
return p.doc, nil
|
|
}
|