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 }