275 lines
8.9 KiB
Go
275 lines
8.9 KiB
Go
// Package yaml adapts the private upstream parser to ConfigKit's document model.
|
|
package yaml
|
|
|
|
import (
|
|
"errors"
|
|
"io"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"go.yaml.in/yaml/v4"
|
|
"go.yaml.in/yaml/v4/plugin/limit"
|
|
|
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
|
)
|
|
|
|
const (
|
|
codeInvalid = "yaml.invalid"
|
|
codeEmpty = "yaml.empty"
|
|
codeMultipleDocuments = "yaml.multiple_documents"
|
|
codeRootType = "yaml.root_type"
|
|
codeDuplicateKey = "yaml.duplicate_key"
|
|
codeTag = "yaml.tag"
|
|
codeAnchor = "yaml.anchor"
|
|
codeAlias = "yaml.alias"
|
|
codeMergeKey = "yaml.merge_key"
|
|
codeScalar = "yaml.scalar"
|
|
codeDepthLimit = "yaml.depth_limit"
|
|
codeNodeLimit = "yaml.node_limit"
|
|
)
|
|
|
|
var (
|
|
jsonNumberPattern = regexp.MustCompile(`^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?$`)
|
|
decimalLikePattern = regexp.MustCompile(`^[+-]?[0-9][0-9_]*(\.[0-9_]*)?([eE][+-]?[0-9_]*)?$`)
|
|
prefixedNumberPattern = regexp.MustCompile(`^[+-]?0[xXoO][0-9A-Fa-f_]+$`)
|
|
leadingDecimalPattern = regexp.MustCompile(`^[+-]?\.[0-9_]+$`)
|
|
nonFiniteNumberPattern = regexp.MustCompile(`(?i)^[+-]?\.(inf|nan)$`)
|
|
errParserDepth = errors.New("configkit yaml parser depth limit")
|
|
)
|
|
|
|
// Limits controls private parser and translated-tree resource bounds.
|
|
type Limits struct {
|
|
Depth int
|
|
Nodes int
|
|
ParserDepth int
|
|
}
|
|
|
|
// Issue is a parser-independent, value-free YAML diagnostic.
|
|
type Issue struct {
|
|
Code string
|
|
Message string
|
|
Line int
|
|
Column int
|
|
Related []document.Location
|
|
}
|
|
|
|
// Parse loads exactly one policy-valid mapping document.
|
|
func Parse(input io.Reader, sourceID string, limits Limits) (*document.Node, []Issue) {
|
|
parserDepth := limits.ParserDepth
|
|
if parserDepth <= 0 {
|
|
parserDepth = limits.Depth + 2
|
|
}
|
|
depthExceeded := false
|
|
plugin := limit.New(limit.DepthFunc(func(depth int, _ *limit.DepthContext) error {
|
|
if depth > parserDepth {
|
|
depthExceeded = true
|
|
return errParserDepth
|
|
}
|
|
return nil
|
|
}))
|
|
loader, err := yaml.NewLoader(input, yaml.WithPlugin(plugin))
|
|
if err != nil {
|
|
return nil, []Issue{{Code: codeInvalid, Message: "YAML parser could not be initialized"}}
|
|
}
|
|
|
|
var parsed yaml.Node
|
|
if err := loader.Load(&parsed); err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
return nil, []Issue{{Code: codeEmpty, Message: "configuration input is empty"}}
|
|
}
|
|
return nil, []Issue{parserIssue(err, depthExceeded)}
|
|
}
|
|
var trailing yaml.Node
|
|
if err := loader.Load(&trailing); err == nil {
|
|
return nil, []Issue{{Code: codeMultipleDocuments, Message: "configuration input must contain exactly one document", Line: trailing.Line, Column: trailing.Column}}
|
|
} else if !errors.Is(err, io.EOF) {
|
|
return nil, []Issue{parserIssue(err, depthExceeded)}
|
|
}
|
|
|
|
root := documentRoot(&parsed)
|
|
if root == nil || (root.Kind == yaml.ScalarNode && root.Value == "" && root.ShortTag() == "!!null") {
|
|
return nil, []Issue{{Code: codeEmpty, Message: "configuration document is empty", Line: parsed.Line, Column: parsed.Column}}
|
|
}
|
|
if root.Kind != yaml.MappingNode {
|
|
return nil, []Issue{{Code: codeRootType, Message: "configuration document root must be a mapping", Line: root.Line, Column: root.Column}}
|
|
}
|
|
|
|
state := translationState{sourceID: sourceID, limits: limits}
|
|
translated, issue := state.translate(root, 0)
|
|
if issue != nil {
|
|
return nil, []Issue{*issue}
|
|
}
|
|
return translated, nil
|
|
}
|
|
|
|
type translationState struct {
|
|
sourceID string
|
|
limits Limits
|
|
nodes int
|
|
}
|
|
|
|
func (s *translationState) translate(node *yaml.Node, depth int) (*document.Node, *Issue) {
|
|
if depth > s.limits.Depth {
|
|
return nil, nodeIssue(codeDepthLimit, "configuration exceeds the nesting depth limit", node)
|
|
}
|
|
if node.Style&yaml.TaggedStyle != 0 {
|
|
return nil, nodeIssue(codeTag, "explicit YAML tags are not supported", node)
|
|
}
|
|
if node.Anchor != "" {
|
|
return nil, nodeIssue(codeAnchor, "YAML anchors are not supported", node)
|
|
}
|
|
if node.Kind == yaml.AliasNode || node.Alias != nil {
|
|
return nil, nodeIssue(codeAlias, "YAML aliases are not supported", node)
|
|
}
|
|
if issue := s.chargeNode(node); issue != nil {
|
|
return nil, issue
|
|
}
|
|
|
|
location := document.Location{SourceID: s.sourceID, Line: node.Line, Column: node.Column}
|
|
switch node.Kind {
|
|
case yaml.MappingNode:
|
|
if len(node.Content)%2 != 0 {
|
|
return nil, nodeIssue(codeInvalid, "mapping structure is invalid", node)
|
|
}
|
|
result := &document.Node{Kind: document.Mapping, Location: location, Mapping: make([]document.Entry, 0, len(node.Content)/2)}
|
|
seen := make(map[string]document.Location, len(node.Content)/2)
|
|
for i := 0; i < len(node.Content); i += 2 {
|
|
keyNode, valueNode := node.Content[i], node.Content[i+1]
|
|
if issue := s.chargeNode(keyNode); issue != nil {
|
|
return nil, issue
|
|
}
|
|
key, issue := mappingKey(keyNode)
|
|
if issue != nil {
|
|
return nil, issue
|
|
}
|
|
if key == "<<" {
|
|
return nil, nodeIssue(codeMergeKey, "YAML merge keys are not supported", keyNode)
|
|
}
|
|
keyLocation := document.Location{SourceID: s.sourceID, Line: keyNode.Line, Column: keyNode.Column}
|
|
if previous, ok := seen[key]; ok {
|
|
issue := nodeIssue(codeDuplicateKey, "mapping key is defined more than once", keyNode)
|
|
issue.Related = []document.Location{previous}
|
|
return nil, issue
|
|
}
|
|
seen[key] = keyLocation
|
|
value, issue := s.translate(valueNode, depth+1)
|
|
if issue != nil {
|
|
return nil, issue
|
|
}
|
|
result.Mapping = append(result.Mapping, document.Entry{
|
|
Key: key,
|
|
KeyLocation: keyLocation,
|
|
ValueLocation: value.Location,
|
|
Value: value,
|
|
})
|
|
}
|
|
return result, nil
|
|
case yaml.SequenceNode:
|
|
result := &document.Node{Kind: document.Sequence, Location: location, Sequence: make([]*document.Node, 0, len(node.Content))}
|
|
for _, child := range node.Content {
|
|
translated, issue := s.translate(child, depth+1)
|
|
if issue != nil {
|
|
return nil, issue
|
|
}
|
|
result.Sequence = append(result.Sequence, translated)
|
|
}
|
|
return result, nil
|
|
case yaml.ScalarNode:
|
|
kind, text, issue := scalar(node)
|
|
if issue != nil {
|
|
return nil, issue
|
|
}
|
|
return &document.Node{Kind: kind, Location: location, Text: text}, nil
|
|
default:
|
|
return nil, nodeIssue(codeInvalid, "YAML node kind is not supported", node)
|
|
}
|
|
}
|
|
|
|
func (s *translationState) chargeNode(node *yaml.Node) *Issue {
|
|
s.nodes++
|
|
if s.limits.Nodes >= 0 && s.nodes > s.limits.Nodes {
|
|
return nodeIssue(codeNodeLimit, "configuration exceeds the node limit", node)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func mappingKey(node *yaml.Node) (string, *Issue) {
|
|
if node.Kind == yaml.AliasNode || node.Alias != nil {
|
|
return "", nodeIssue(codeAlias, "YAML aliases are not supported", node)
|
|
}
|
|
if node.Kind != yaml.ScalarNode {
|
|
return "", nodeIssue(codeScalar, "mapping keys must be strings", node)
|
|
}
|
|
if node.Style&yaml.TaggedStyle != 0 {
|
|
return "", nodeIssue(codeTag, "explicit YAML tags are not supported", node)
|
|
}
|
|
if node.Anchor != "" {
|
|
return "", nodeIssue(codeAnchor, "YAML anchors are not supported", node)
|
|
}
|
|
kind, text, issue := scalar(node)
|
|
if issue != nil {
|
|
return "", issue
|
|
}
|
|
if kind != document.String {
|
|
return "", nodeIssue(codeScalar, "mapping keys must be strings", node)
|
|
}
|
|
return text, nil
|
|
}
|
|
|
|
func scalar(node *yaml.Node) (document.Kind, string, *Issue) {
|
|
if node.Style&(yaml.DoubleQuotedStyle|yaml.SingleQuotedStyle|yaml.LiteralStyle|yaml.FoldedStyle) != 0 {
|
|
return document.String, node.Value, nil
|
|
}
|
|
value := node.Value
|
|
switch value {
|
|
case "":
|
|
if node.ShortTag() == "!!null" {
|
|
return document.Null, "", nil
|
|
}
|
|
case "null", "~":
|
|
return document.Null, "", nil
|
|
case "true", "false":
|
|
return document.Boolean, value, nil
|
|
}
|
|
lower := strings.ToLower(value)
|
|
if lower == "true" || lower == "false" || lower == "null" || lower == "yes" || lower == "no" || lower == "on" || lower == "off" {
|
|
return 0, "", nodeIssue(codeScalar, "plain scalar uses an unsupported reserved form", node)
|
|
}
|
|
if jsonNumberPattern.MatchString(value) {
|
|
return document.Number, value, nil
|
|
}
|
|
if decimalLikePattern.MatchString(value) || prefixedNumberPattern.MatchString(value) ||
|
|
leadingDecimalPattern.MatchString(value) || nonFiniteNumberPattern.MatchString(value) {
|
|
return 0, "", nodeIssue(codeScalar, "plain scalar uses an unsupported numeric form", node)
|
|
}
|
|
if tag := node.ShortTag(); tag == "!!int" || tag == "!!float" {
|
|
return 0, "", nodeIssue(codeScalar, "plain scalar uses an unsupported numeric form", node)
|
|
}
|
|
return document.String, value, nil
|
|
}
|
|
|
|
func documentRoot(node *yaml.Node) *yaml.Node {
|
|
if node.Kind == yaml.DocumentNode && len(node.Content) == 1 {
|
|
return node.Content[0]
|
|
}
|
|
return node
|
|
}
|
|
|
|
func parserIssue(err error, depthExceeded bool) Issue {
|
|
code := codeInvalid
|
|
if depthExceeded || errors.Is(err, errParserDepth) {
|
|
code = codeDepthLimit
|
|
}
|
|
issue := Issue{Code: code, Message: "configuration contains invalid YAML"}
|
|
var loadError *yaml.LoadError
|
|
if errors.As(err, &loadError) {
|
|
issue.Line = loadError.Mark.Line
|
|
issue.Column = loadError.Mark.Column
|
|
}
|
|
return issue
|
|
}
|
|
|
|
func nodeIssue(code, message string, node *yaml.Node) *Issue {
|
|
return &Issue{Code: code, Message: message, Line: node.Line, Column: node.Column}
|
|
}
|