Add safe configuration report helpers
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Implement ConfigKit adoption and configuration comparison
|
# 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
|
## 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
|
effects. They do not examine original configs, expand atomic regions, infer
|
||||||
masking from strings, or determine whether a difference exists.
|
masking from strings, or determine whether a difference exists.
|
||||||
|
|
||||||
**Completion evidence:** focused helper tests for root and `/`, nested placement
|
**Completion evidence — complete:** private YAML-node helpers now decode safe
|
||||||
sharing an ancestor, numeric/dot/percent keys, and escaped slash/tilde keys.
|
snapshots without converting them through loosely typed values, decode canonical
|
||||||
Include `~01` decoding to literal `~1`, without decoding twice. Test value tags
|
JSON Pointer tokens once, and insert mapping keys as YAML strings while reusing
|
||||||
for null, booleans, large integers, strings resembling numbers/booleans, and empty
|
ordered ancestors. Focused checks cover root and `/`, nested placement sharing an
|
||||||
containers. Test malformed snapshot YAML returning an error. Stage 7 owns report
|
ancestor, numeric/dot/percent keys, escaped slash/tilde keys, and `~01` becoming
|
||||||
classification and disclosure cases; do not duplicate its complete report suite.
|
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
|
## 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