Add safe configuration report helpers
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Implement ConfigKit adoption and configuration comparison
|
||||
|
||||
Status: implementation in progress; Stages 1–5 are complete.
|
||||
Status: implementation in progress; Stages 1–6 are complete.
|
||||
|
||||
## Scope and authority
|
||||
|
||||
@@ -308,12 +308,15 @@ at semantic mapping paths. This stage does not classify records or deliver outpu
|
||||
effects. They do not examine original configs, expand atomic regions, infer
|
||||
masking from strings, or determine whether a difference exists.
|
||||
|
||||
**Completion evidence:** focused helper tests for root and `/`, nested placement
|
||||
sharing an ancestor, numeric/dot/percent keys, and escaped slash/tilde keys.
|
||||
Include `~01` decoding to literal `~1`, without decoding twice. Test value tags
|
||||
for null, booleans, large integers, strings resembling numbers/booleans, and empty
|
||||
containers. Test malformed snapshot YAML returning an error. Stage 7 owns report
|
||||
classification and disclosure cases; do not duplicate its complete report suite.
|
||||
**Completion evidence — complete:** private YAML-node helpers now decode safe
|
||||
snapshots without converting them through loosely typed values, decode canonical
|
||||
JSON Pointer tokens once, and insert mapping keys as YAML strings while reusing
|
||||
ordered ancestors. Focused checks cover root and `/`, nested placement sharing an
|
||||
ancestor, numeric/dot/percent keys, escaped slash/tilde keys, and `~01` becoming
|
||||
literal `~1`. Snapshot tags are checked for null, booleans, a large integer,
|
||||
number/boolean-like strings, and empty mappings/sequences; malformed YAML returns
|
||||
an error. `go test -count=1 ./...` passes in ckconfig. Classification and report
|
||||
disclosure remain for the following stage.
|
||||
|
||||
## Stage 7: Classify native records and encode the YAML document
|
||||
|
||||
|
||||
120
integrations/ckconfig/diff_report.go
Normal file
120
integrations/ckconfig/diff_report.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package ckconfig
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func decodeSafeSnapshot(snapshot string) (*yaml.Node, error) {
|
||||
var document yaml.Node
|
||||
decoder := yaml.NewDecoder(strings.NewReader(snapshot))
|
||||
if err := decoder.Decode(&document); err != nil {
|
||||
return nil, fmt.Errorf("decode safe YAML snapshot: %w", err)
|
||||
}
|
||||
if document.Kind != yaml.DocumentNode || len(document.Content) == 0 {
|
||||
return nil, fmt.Errorf("decode safe YAML snapshot: document has no value")
|
||||
}
|
||||
return document.Content[0], nil
|
||||
}
|
||||
|
||||
func decodeJSONPointer(pointer string) ([]string, error) {
|
||||
if pointer == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if pointer[0] != '/' {
|
||||
return nil, fmt.Errorf("decode JSON pointer %q: path must start with /", pointer)
|
||||
}
|
||||
|
||||
parts := strings.Split(pointer[1:], "/")
|
||||
for index, part := range parts {
|
||||
var decoded strings.Builder
|
||||
decoded.Grow(len(part))
|
||||
for offset := 0; offset < len(part); offset++ {
|
||||
if part[offset] != '~' {
|
||||
decoded.WriteByte(part[offset])
|
||||
continue
|
||||
}
|
||||
if offset+1 >= len(part) {
|
||||
return nil, fmt.Errorf("decode JSON pointer %q: incomplete escape in token %d", pointer, index)
|
||||
}
|
||||
offset++
|
||||
switch part[offset] {
|
||||
case '0':
|
||||
decoded.WriteByte('~')
|
||||
case '1':
|
||||
decoded.WriteByte('/')
|
||||
default:
|
||||
return nil, fmt.Errorf("decode JSON pointer %q: invalid escape in token %d", pointer, index)
|
||||
}
|
||||
}
|
||||
parts[index] = decoded.String()
|
||||
}
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
func placeYAMLValue(category *yaml.Node, path []string, value *yaml.Node) (*yaml.Node, error) {
|
||||
if value == nil {
|
||||
return nil, fmt.Errorf("place YAML value: value node is nil")
|
||||
}
|
||||
if len(path) == 0 {
|
||||
return value, nil
|
||||
}
|
||||
if category == nil || category.Kind != yaml.MappingNode {
|
||||
return nil, fmt.Errorf("place YAML value at %q: category ancestor is not a mapping", pointerFromTokens(path[:1]))
|
||||
}
|
||||
|
||||
current := category
|
||||
for index, token := range path {
|
||||
keyIndex := mappingKeyIndex(current, token)
|
||||
if index == len(path)-1 {
|
||||
if keyIndex >= 0 {
|
||||
current.Content[keyIndex+1] = value
|
||||
} else {
|
||||
current.Content = append(current.Content, stringKey(token), value)
|
||||
}
|
||||
return category, nil
|
||||
}
|
||||
|
||||
if keyIndex < 0 {
|
||||
child := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
||||
current.Content = append(current.Content, stringKey(token), child)
|
||||
current = child
|
||||
continue
|
||||
}
|
||||
|
||||
child := current.Content[keyIndex+1]
|
||||
if child.Kind != yaml.MappingNode {
|
||||
return nil, fmt.Errorf("place YAML value at %q: ancestor %q is not a mapping", pointerFromTokens(path), pointerFromTokens(path[:index+1]))
|
||||
}
|
||||
current = child
|
||||
}
|
||||
return category, nil
|
||||
}
|
||||
|
||||
func mappingKeyIndex(mapping *yaml.Node, key string) int {
|
||||
for index := 0; index+1 < len(mapping.Content); index += 2 {
|
||||
candidate := mapping.Content[index]
|
||||
if candidate.Kind == yaml.ScalarNode && candidate.Tag == "!!str" && candidate.Value == key {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func stringKey(value string) *yaml.Node {
|
||||
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value}
|
||||
}
|
||||
|
||||
func pointerFromTokens(tokens []string) string {
|
||||
if len(tokens) == 0 {
|
||||
return ""
|
||||
}
|
||||
var pointer strings.Builder
|
||||
for _, token := range tokens {
|
||||
pointer.WriteByte('/')
|
||||
pointer.WriteString(strings.ReplaceAll(strings.ReplaceAll(token, "~", "~0"), "/", "~1"))
|
||||
}
|
||||
return pointer.String()
|
||||
}
|
||||
143
integrations/ckconfig/diff_report_test.go
Normal file
143
integrations/ckconfig/diff_report_test.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package ckconfig
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func TestDecodeSafeSnapshotPreservesYAMLTypes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
text string
|
||||
kind yaml.Kind
|
||||
tag string
|
||||
value string
|
||||
}{
|
||||
{name: "null", text: "null\n", kind: yaml.ScalarNode, tag: "!!null", value: "null"},
|
||||
{name: "boolean", text: "true\n", kind: yaml.ScalarNode, tag: "!!bool", value: "true"},
|
||||
{name: "large integer", text: "9007199254740993\n", kind: yaml.ScalarNode, tag: "!!int", value: "9007199254740993"},
|
||||
{name: "numeric string", text: "'123'\n", kind: yaml.ScalarNode, tag: "!!str", value: "123"},
|
||||
{name: "boolean-like string", text: "'false'\n", kind: yaml.ScalarNode, tag: "!!str", value: "false"},
|
||||
{name: "empty mapping", text: "{}\n", kind: yaml.MappingNode, tag: "!!map"},
|
||||
{name: "empty sequence", text: "[]\n", kind: yaml.SequenceNode, tag: "!!seq"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
node, err := decodeSafeSnapshot(test.text)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if node.Kind != test.kind || node.Tag != test.tag || node.Value != test.value {
|
||||
t.Fatalf("node kind/tag/value = %v/%q/%q, want %v/%q/%q", node.Kind, node.Tag, node.Value, test.kind, test.tag, test.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
nested, err := decodeSafeSnapshot("items:\n - name: example\n enabled: false\n")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if nested.Kind != yaml.MappingNode || nested.Content[1].Kind != yaml.SequenceNode || nested.Content[1].Content[0].Content[3].Tag != "!!bool" {
|
||||
t.Fatalf("nested collection/scalar tags were not preserved: %#v", nested)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeJSONPointerPreservesSemanticTokens(t *testing.T) {
|
||||
tests := []struct {
|
||||
pointer string
|
||||
want []string
|
||||
}{
|
||||
{pointer: "", want: nil},
|
||||
{pointer: "/", want: []string{""}},
|
||||
{pointer: "/parent/child", want: []string{"parent", "child"}},
|
||||
{pointer: "/0/../a.b/50%", want: []string{"0", "..", "a.b", "50%"}},
|
||||
{pointer: "/a~1b/c~0d", want: []string{"a/b", "c~d"}},
|
||||
{pointer: "/~01", want: []string{"~1"}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
got, err := decodeJSONPointer(test.pointer)
|
||||
if err != nil {
|
||||
t.Fatalf("decode %q: %v", test.pointer, err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, test.want) {
|
||||
t.Errorf("decode %q = %#v, want %#v", test.pointer, got, test.want)
|
||||
}
|
||||
}
|
||||
for _, pointer := range []string{"missing-leading-slash", "/bad~", "/bad~2escape"} {
|
||||
if _, err := decodeJSONPointer(pointer); err == nil {
|
||||
t.Errorf("decode %q unexpectedly succeeded", pointer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaceYAMLValueBuildsOrderedSemanticMappings(t *testing.T) {
|
||||
category := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
||||
paths := [][]string{
|
||||
{"parent", "0", "dot.name", "50%"},
|
||||
{"parent", "second"},
|
||||
{"a/b", "~1"},
|
||||
{""},
|
||||
}
|
||||
for index, path := range paths {
|
||||
value := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: string(rune('a' + index))}
|
||||
updated, err := placeYAMLValue(category, path, value)
|
||||
if err != nil {
|
||||
t.Fatalf("place %q: %v", pointerFromTokens(path), err)
|
||||
}
|
||||
category = updated
|
||||
}
|
||||
|
||||
if len(category.Content) != 6 || category.Content[0].Value != "parent" || category.Content[2].Value != "a/b" || category.Content[4].Value != "" {
|
||||
t.Fatalf("mapping insertion order or empty key = %#v", category.Content)
|
||||
}
|
||||
parent := category.Content[1]
|
||||
if parent.Kind != yaml.MappingNode || len(parent.Content) != 4 || parent.Content[0].Value != "0" || parent.Content[2].Value != "second" {
|
||||
t.Fatalf("nested shared ancestor/order = %#v", parent)
|
||||
}
|
||||
numeric := parent.Content[1]
|
||||
dotted := numeric.Content[1]
|
||||
if numeric.Kind != yaml.MappingNode || numeric.Content[0].Value != "dot.name" || dotted.Kind != yaml.MappingNode || dotted.Content[0].Value != "50%" {
|
||||
t.Fatalf("semantic tokens were changed: numeric=%#v dotted=%#v", numeric, dotted)
|
||||
}
|
||||
if category.Content[1].Content[0].Tag != "!!str" || category.Content[2].Tag != "!!str" || category.Content[4].Tag != "!!str" {
|
||||
t.Fatal("mapping keys are not explicitly tagged as YAML strings")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaceYAMLValueHandlesRootAndEmptyKey(t *testing.T) {
|
||||
category := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
||||
root := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"}
|
||||
updated, err := placeYAMLValue(category, nil, root)
|
||||
if err != nil || updated != root {
|
||||
t.Fatalf("root placement = %#v, %v", updated, err)
|
||||
}
|
||||
|
||||
category = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
||||
value := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "empty-key"}
|
||||
updated, err = placeYAMLValue(category, []string{""}, value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated != category || len(category.Content) != 2 || category.Content[0].Value != "" || category.Content[0].Tag != "!!str" || category.Content[1] != value {
|
||||
t.Fatalf("slash path placement = %#v", category)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaceYAMLValueRejectsIncompatibleAncestors(t *testing.T) {
|
||||
category := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map", Content: []*yaml.Node{
|
||||
stringKey("parent"), {Kind: yaml.ScalarNode, Tag: "!!str", Value: "leaf"},
|
||||
}}
|
||||
_, err := placeYAMLValue(category, []string{"parent", "child"}, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"})
|
||||
if err == nil || !strings.Contains(err.Error(), "/parent/child") || !strings.Contains(err.Error(), "/parent") {
|
||||
t.Fatalf("incompatible ancestor error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeSafeSnapshotRejectsMalformedYAML(t *testing.T) {
|
||||
if _, err := decodeSafeSnapshot("value: [unterminated\n"); err == nil {
|
||||
t.Fatal("malformed snapshot unexpectedly decoded")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user