From 30787b280654cd19f503e4749448a5d431466a63 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 11 Oct 2026 21:48:49 +0000 Subject: [PATCH] Add safe configuration report helpers --- docs/roadmap/implementation.md | 17 +-- integrations/ckconfig/diff_report.go | 120 ++++++++++++++++++ integrations/ckconfig/diff_report_test.go | 143 ++++++++++++++++++++++ 3 files changed, 273 insertions(+), 7 deletions(-) create mode 100644 integrations/ckconfig/diff_report.go create mode 100644 integrations/ckconfig/diff_report_test.go diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index e8526d3..990fc0a 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -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 diff --git a/integrations/ckconfig/diff_report.go b/integrations/ckconfig/diff_report.go new file mode 100644 index 0000000..fc67670 --- /dev/null +++ b/integrations/ckconfig/diff_report.go @@ -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() +} diff --git a/integrations/ckconfig/diff_report_test.go b/integrations/ckconfig/diff_report_test.go new file mode 100644 index 0000000..cf7f1bd --- /dev/null +++ b/integrations/ckconfig/diff_report_test.go @@ -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") + } +}