Add bounded sources and strict YAML parsing
This commit is contained in:
62
internal/document/document.go
Normal file
62
internal/document/document.go
Normal file
@@ -0,0 +1,62 @@
|
||||
// Package document defines ConfigKit's parser-independent configuration tree.
|
||||
package document
|
||||
|
||||
// Kind identifies a configuration node category.
|
||||
type Kind uint8
|
||||
|
||||
const (
|
||||
Mapping Kind = iota + 1
|
||||
Sequence
|
||||
String
|
||||
Boolean
|
||||
Number
|
||||
Null
|
||||
)
|
||||
|
||||
// Location identifies a source coordinate. Root depth and unavailable coordinates
|
||||
// are represented independently by callers; line and column are one-based when set.
|
||||
type Location struct {
|
||||
SourceID string
|
||||
Line int
|
||||
Column int
|
||||
}
|
||||
|
||||
// Entry is an ordered mapping entry with separate key and value locations.
|
||||
type Entry struct {
|
||||
Key string
|
||||
KeyLocation Location
|
||||
ValueLocation Location
|
||||
Value *Node
|
||||
}
|
||||
|
||||
// Node is ConfigKit's owned configuration representation. Number Text retains its
|
||||
// exact validated decimal spelling; Boolean Text is "true" or "false".
|
||||
type Node struct {
|
||||
Kind Kind
|
||||
Location Location
|
||||
Text string
|
||||
Mapping []Entry
|
||||
Sequence []*Node
|
||||
}
|
||||
|
||||
// Clone returns a detached copy of a node tree.
|
||||
func Clone(node *Node) *Node {
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *node
|
||||
if node.Mapping != nil {
|
||||
cloned.Mapping = make([]Entry, len(node.Mapping))
|
||||
for i, entry := range node.Mapping {
|
||||
cloned.Mapping[i] = entry
|
||||
cloned.Mapping[i].Value = Clone(entry.Value)
|
||||
}
|
||||
}
|
||||
if node.Sequence != nil {
|
||||
cloned.Sequence = make([]*Node, len(node.Sequence))
|
||||
for i, child := range node.Sequence {
|
||||
cloned.Sequence[i] = Clone(child)
|
||||
}
|
||||
}
|
||||
return &cloned
|
||||
}
|
||||
274
internal/yaml/parse.go
Normal file
274
internal/yaml/parse.go
Normal file
@@ -0,0 +1,274 @@
|
||||
// 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}
|
||||
}
|
||||
200
internal/yaml/parse_test.go
Normal file
200
internal/yaml/parse_test.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package yaml
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.yaml.in/yaml/v4"
|
||||
"go.yaml.in/yaml/v4/plugin/limit"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
func TestParsePreservesDocumentValuesAndLocations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
input := "# comment\nwide: 9223372036854775808\nwhen: 2026-09-06\nquoted: \"yes\"\nflow: [true, null, ~, false]\nempty:\n"
|
||||
root, issues := Parse(strings.NewReader(input), "source-1", generousLimits())
|
||||
if len(issues) != 0 {
|
||||
t.Fatalf("Parse() issues = %#v", issues)
|
||||
}
|
||||
if root.Kind != document.Mapping || len(root.Mapping) != 5 {
|
||||
t.Fatalf("root = %#v", root)
|
||||
}
|
||||
if root.Mapping[0].Value.Kind != document.Number || root.Mapping[0].Value.Text != "9223372036854775808" {
|
||||
t.Fatalf("wide number = %#v", root.Mapping[0].Value)
|
||||
}
|
||||
if root.Mapping[0].KeyLocation.Line != 2 || root.Mapping[0].ValueLocation.Column != 7 {
|
||||
t.Fatalf("locations = %#v", root.Mapping[0])
|
||||
}
|
||||
if root.Mapping[1].Value.Kind != document.String || root.Mapping[1].Value.Text != "2026-09-06" {
|
||||
t.Fatalf("timestamp-like text = %#v", root.Mapping[1].Value)
|
||||
}
|
||||
if root.Mapping[3].Value.Sequence[1].Kind != document.Null || root.Mapping[3].Value.Sequence[2].Kind != document.Null {
|
||||
t.Fatalf("null spellings = %#v", root.Mapping[3].Value.Sequence)
|
||||
}
|
||||
if root.Mapping[4].Value.Kind != document.Null {
|
||||
t.Fatalf("empty value = %#v", root.Mapping[4].Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePolicyTable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
code string
|
||||
}{
|
||||
{name: "empty input", input: "", code: codeEmpty},
|
||||
{name: "empty document", input: "---\n", code: codeEmpty},
|
||||
{name: "sequence root", input: "- value\n", code: codeRootType},
|
||||
{name: "scalar root", input: "value\n", code: codeRootType},
|
||||
{name: "second document", input: "a: 1\n---\nb: 2\n", code: codeMultipleDocuments},
|
||||
{name: "empty second document", input: "a: 1\n---\n", code: codeMultipleDocuments},
|
||||
{name: "duplicate key", input: "a: 1\na: 2\n", code: codeDuplicateKey},
|
||||
{name: "nested duplicate", input: "a: {b: 1, b: 2}\n", code: codeDuplicateKey},
|
||||
{name: "explicit standard tag", input: "a: !!str value\n", code: codeTag},
|
||||
{name: "explicit custom tag", input: "a: !custom value\n", code: codeTag},
|
||||
{name: "anchor", input: "a: &name value\n", code: codeAnchor},
|
||||
{name: "alias source anchor", input: "a: &name value\nb: *name\n", code: codeAnchor},
|
||||
{name: "merge key", input: "<<: value\n", code: codeMergeKey},
|
||||
{name: "quoted merge key", input: "\"<<\": value\n", code: codeMergeKey},
|
||||
{name: "boolean key", input: "true: value\n", code: codeScalar},
|
||||
{name: "number key", input: "1: value\n", code: codeScalar},
|
||||
{name: "null key", input: "null: value\n", code: codeScalar},
|
||||
{name: "legacy yes", input: "a: yes\n", code: codeScalar},
|
||||
{name: "legacy mixed", input: "a: OfF\n", code: codeScalar},
|
||||
{name: "reserved case", input: "a: TRUE\n", code: codeScalar},
|
||||
{name: "hex", input: "a: 0x10\n", code: codeScalar},
|
||||
{name: "octal", input: "a: 0o10\n", code: codeScalar},
|
||||
{name: "separator", input: "a: 1_000\n", code: codeScalar},
|
||||
{name: "leading plus", input: "a: +1\n", code: codeScalar},
|
||||
{name: "leading zero", input: "a: 01\n", code: codeScalar},
|
||||
{name: "negative leading zero", input: "a: -01\n", code: codeScalar},
|
||||
{name: "leading dot", input: "a: .5\n", code: codeScalar},
|
||||
{name: "trailing dot", input: "a: 1.\n", code: codeScalar},
|
||||
{name: "incomplete exponent", input: "a: 1e\n", code: codeScalar},
|
||||
{name: "incomplete signed exponent", input: "a: 1e+\n", code: codeScalar},
|
||||
{name: "infinity", input: "a: .inf\n", code: codeScalar},
|
||||
{name: "nan", input: "a: .NaN\n", code: codeScalar},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, issues := Parse(strings.NewReader(test.input), "source-1", generousLimits())
|
||||
if len(issues) != 1 || issues[0].Code != test.code {
|
||||
t.Fatalf("issues = %#v, want code %q", issues, test.code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslationRejectsAliasNodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
node := &yaml.Node{Kind: yaml.AliasNode, Line: 2, Column: 3}
|
||||
state := translationState{sourceID: "source-1", limits: generousLimits()}
|
||||
if _, issue := state.translate(node, 0); issue == nil || issue.Code != codeAlias {
|
||||
t.Fatalf("alias issue = %#v", issue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAcceptsSupportedSyntax(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
inputs := []string{
|
||||
"plain: text\nquoted_bool: 'ON'\nquoted_number: \"01\"\nblock: |\n text\n",
|
||||
"{zero: 0, negative: -1, fraction: 1.25, exponent: 1e+3, boolean: false}\n",
|
||||
"\"true\": value\n\"1\": number key\n\"null\": null key\n",
|
||||
}
|
||||
for _, input := range inputs {
|
||||
if _, issues := Parse(strings.NewReader(input), "source-1", generousLimits()); len(issues) != 0 {
|
||||
t.Errorf("Parse(%q) issues = %#v", input, issues)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuplicateKeyReportsBothLocations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, issues := Parse(strings.NewReader("same: first\nsame: second\n"), "source-1", generousLimits())
|
||||
if len(issues) != 1 || issues[0].Code != codeDuplicateKey || issues[0].Line != 2 || len(issues[0].Related) != 1 || issues[0].Related[0].Line != 1 {
|
||||
t.Fatalf("duplicate issues = %#v", issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEnforcesTreeAndParserLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, input := range []string{"a:\n b: value\n", "{a: {b: value}}\n"} {
|
||||
if _, issues := Parse(strings.NewReader(input), "source-1", Limits{Depth: 2, Nodes: 20, ParserDepth: 20}); len(issues) != 0 {
|
||||
t.Fatalf("depth boundary rejected for %q: %#v", input, issues)
|
||||
}
|
||||
if _, issues := Parse(strings.NewReader(input), "source-1", Limits{Depth: 1, Nodes: 20, ParserDepth: 20}); len(issues) != 1 || issues[0].Code != codeDepthLimit {
|
||||
t.Fatalf("depth overflow for %q = %#v", input, issues)
|
||||
}
|
||||
}
|
||||
if _, issues := Parse(strings.NewReader("a: 1\n"), "source-1", Limits{Depth: 2, Nodes: 3, ParserDepth: 20}); len(issues) != 0 {
|
||||
t.Fatalf("node boundary rejected: %#v", issues)
|
||||
}
|
||||
if _, issues := Parse(strings.NewReader("a: 1\n"), "source-1", Limits{Depth: 2, Nodes: 2, ParserDepth: 20}); len(issues) != 1 || issues[0].Code != codeNodeLimit {
|
||||
t.Fatalf("node overflow = %#v", issues)
|
||||
}
|
||||
if _, issues := Parse(strings.NewReader("a:\n b:\n c: value\n"), "source-1", Limits{Depth: 20, Nodes: 20, ParserDepth: 1}); len(issues) != 1 || issues[0].Code != codeDepthLimit {
|
||||
t.Fatalf("parser depth overflow = %#v", issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParserDepthCalibrationForBlockAndFlow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
inputs := []string{"a:\n b: value\n", "{a: {b: value}}\n"}
|
||||
for _, input := range inputs {
|
||||
maximum := observedParserDepth(t, input)
|
||||
if maximum > 4 {
|
||||
t.Fatalf("parser depth %d exceeds root-zero tree depth plus safety offset", maximum)
|
||||
}
|
||||
if _, issues := Parse(strings.NewReader(input), "source-1", Limits{Depth: 2, Nodes: 20, ParserDepth: maximum}); len(issues) != 0 {
|
||||
t.Fatalf("parser boundary %d rejected %q: %#v", maximum, input, issues)
|
||||
}
|
||||
if _, issues := Parse(strings.NewReader(input), "source-1", Limits{Depth: 20, Nodes: 20, ParserDepth: maximum - 1}); len(issues) != 1 || issues[0].Code != codeDepthLimit {
|
||||
t.Fatalf("parser overflow at %d for %q: %#v", maximum-1, input, issues)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func observedParserDepth(t *testing.T, input string) int {
|
||||
t.Helper()
|
||||
maximum := 0
|
||||
depthPlugin := limit.New(limit.DepthFunc(func(depth int, _ *limit.DepthContext) error {
|
||||
maximum = max(maximum, depth)
|
||||
return nil
|
||||
}))
|
||||
loader, err := yaml.NewLoader(strings.NewReader(input), yaml.WithPlugin(depthPlugin))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var node yaml.Node
|
||||
if err := loader.Load(&node); err != nil && err != io.EOF {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return maximum
|
||||
}
|
||||
|
||||
func TestMalformedParserDiagnosticIsSafe(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const canary = "CANARY_SECRET_VALUE"
|
||||
_, issues := Parse(strings.NewReader("value: ["+canary+"\n"), "source-1", generousLimits())
|
||||
if len(issues) != 1 || issues[0].Code != codeInvalid || issues[0].Line == 0 {
|
||||
t.Fatalf("issues = %#v", issues)
|
||||
}
|
||||
if strings.Contains(issues[0].Message, canary) {
|
||||
t.Fatal("parser diagnostic exposed input")
|
||||
}
|
||||
}
|
||||
|
||||
func generousLimits() Limits {
|
||||
return Limits{Depth: 32, Nodes: 1_000, ParserDepth: 64}
|
||||
}
|
||||
Reference in New Issue
Block a user