From ade5d6a273ccb2f2ebfb3bb5d9fbc249b052f5b6 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 9 Oct 2026 12:20:15 +0000 Subject: [PATCH] Prepare paired safe snapshots for diffs --- docs/roadmap/implementation.md | 9 +- internal/engine/diff_snapshot.go | 170 ++++++++++++++++++++++ internal/engine/diff_snapshot_test.go | 199 ++++++++++++++++++++++++++ 3 files changed, 377 insertions(+), 1 deletion(-) create mode 100644 internal/engine/diff_snapshot.go create mode 100644 internal/engine/diff_snapshot_test.go diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index ada2f60..636122c 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -194,7 +194,7 @@ whole-map scans or new persistent inspection state. ## Stage 4 — Build paired safe snapshots for compatible shapes -Status: planned. Scope: compatible mapping/sequence/scalar traversal and absence. +Status: complete. Scope: compatible mapping/sequence/scalar traversal and absence. 1. Add private paired traversal in `internal/engine/diff_snapshot.go`, passing the reported region path and Stage 3's lookup. Build safe owned containers @@ -215,6 +215,13 @@ Validate: `go test ./internal/engine -run 'Diff|Effective|Snapshot'` and the everyday check. Done: compatible paired masking preserves presence and public content, while existing standalone rendering remains unchanged. +Evidence: the focused engine tests and `./scripts/check.sh` passed. The check +completed formatting, whitespace, `go test ./...`, `go vet ./...`, and +`go build ./...` on `go1.26.8 linux/amd64`. Tests confirm one-sided +classification protects both sides, absent subtrees retain absence and public +siblings, compatible sequences preserve each side's length, retained trees are +unchanged, and unsupported intermediate shapes return no nodes. + ## Stage 5 — Complete structural-replacement masking Status: planned. Scope: the fallback rule and its shape-sensitive regressions. diff --git a/internal/engine/diff_snapshot.go b/internal/engine/diff_snapshot.go new file mode 100644 index 0000000..df87e81 --- /dev/null +++ b/internal/engine/diff_snapshot.go @@ -0,0 +1,170 @@ +package engine + +import ( + "sort" + + "gitea.maximumdirect.net/eric/ConfigKit/internal/document" +) + +type pairedDiffSnapshot struct { + left *document.Node + right *document.Node + leftRedacted bool + rightRedacted bool +} + +func preparePairedDiffSnapshot(left, right *document.Node, path Path, sensitivity *diffSensitivity) (pairedDiffSnapshot, bool) { + if left == nil && right == nil { + return pairedDiffSnapshot{}, true + } + if sensitivity.isSensitiveAtOrAbove(path.String()) { + return pairedDiffSnapshot{ + left: redactedDiffNode(left), + right: redactedDiffNode(right), + leftRedacted: left != nil, + rightRedacted: right != nil, + }, true + } + if left != nil && right != nil && left.Kind != right.Kind { + return pairedDiffSnapshot{}, false + } + + kind := document.Null + if left != nil { + kind = left.Kind + } else if right != nil { + kind = right.Kind + } + switch kind { + case document.Mapping: + return preparePairedDiffMapping(left, right, path, sensitivity) + case document.Sequence: + return preparePairedDiffSequence(left, right, path, sensitivity) + default: + if sensitivity.hasStrictDescendant(path.String()) { + return pairedDiffSnapshot{}, false + } + return pairedDiffSnapshot{left: left, right: right}, true + } +} + +func preparePairedDiffMapping(left, right *document.Node, path Path, sensitivity *diffSensitivity) (pairedDiffSnapshot, bool) { + leftEntries := diffMappingEntries(left) + rightEntries := diffMappingEntries(right) + keys := make([]string, 0, len(leftEntries)+len(rightEntries)) + seen := make(map[string]struct{}, len(leftEntries)+len(rightEntries)) + for key := range leftEntries { + seen[key] = struct{}{} + keys = append(keys, key) + } + for key := range rightEntries { + if _, found := seen[key]; found { + continue + } + keys = append(keys, key) + } + sort.Strings(keys) + + var leftCopy, rightCopy *document.Node + if left != nil { + copy := *left + copy.Mapping = make([]document.Entry, 0, len(left.Mapping)) + leftCopy = © + } + if right != nil { + copy := *right + copy.Mapping = make([]document.Entry, 0, len(right.Mapping)) + rightCopy = © + } + redacted := pairedDiffSnapshot{} + for _, key := range keys { + leftEntry, leftPresent := leftEntries[key] + rightEntry, rightPresent := rightEntries[key] + var leftChild, rightChild *document.Node + if leftPresent { + leftChild = leftEntry.Value + } + if rightPresent { + rightChild = rightEntry.Value + } + child, supported := preparePairedDiffSnapshot(leftChild, rightChild, path.Key(key), sensitivity) + if !supported { + return pairedDiffSnapshot{}, false + } + if leftPresent { + leftEntry.Value = child.left + leftCopy.Mapping = append(leftCopy.Mapping, leftEntry) + } + if rightPresent { + rightEntry.Value = child.right + rightCopy.Mapping = append(rightCopy.Mapping, rightEntry) + } + redacted.leftRedacted = redacted.leftRedacted || child.leftRedacted + redacted.rightRedacted = redacted.rightRedacted || child.rightRedacted + } + redacted.left = leftCopy + redacted.right = rightCopy + return redacted, true +} + +func preparePairedDiffSequence(left, right *document.Node, path Path, sensitivity *diffSensitivity) (pairedDiffSnapshot, bool) { + var leftCopy, rightCopy *document.Node + if left != nil { + copy := *left + copy.Sequence = make([]*document.Node, len(left.Sequence)) + leftCopy = © + } + if right != nil { + copy := *right + copy.Sequence = make([]*document.Node, len(right.Sequence)) + rightCopy = © + } + length := 0 + if left != nil { + length = len(left.Sequence) + } + if right != nil && len(right.Sequence) > length { + length = len(right.Sequence) + } + result := pairedDiffSnapshot{left: leftCopy, right: rightCopy} + for index := 0; index < length; index++ { + var leftChild, rightChild *document.Node + if left != nil && index < len(left.Sequence) { + leftChild = left.Sequence[index] + } + if right != nil && index < len(right.Sequence) { + rightChild = right.Sequence[index] + } + child, supported := preparePairedDiffSnapshot(leftChild, rightChild, path.Index(index), sensitivity) + if !supported { + return pairedDiffSnapshot{}, false + } + if left != nil && index < len(left.Sequence) { + leftCopy.Sequence[index] = child.left + } + if right != nil && index < len(right.Sequence) { + rightCopy.Sequence[index] = child.right + } + result.leftRedacted = result.leftRedacted || child.leftRedacted + result.rightRedacted = result.rightRedacted || child.rightRedacted + } + return result, true +} + +func diffMappingEntries(node *document.Node) map[string]document.Entry { + entries := make(map[string]document.Entry) + if node == nil { + return entries + } + for _, entry := range node.Mapping { + entries[entry.Key] = entry + } + return entries +} + +func redactedDiffNode(node *document.Node) *document.Node { + if node == nil { + return nil + } + return &document.Node{Kind: document.String, Text: "[REDACTED]"} +} diff --git a/internal/engine/diff_snapshot_test.go b/internal/engine/diff_snapshot_test.go new file mode 100644 index 0000000..aaebfd3 --- /dev/null +++ b/internal/engine/diff_snapshot_test.go @@ -0,0 +1,199 @@ +package engine + +import ( + "testing" + + "gitea.maximumdirect.net/eric/ConfigKit/internal/document" +) + +func TestPreparePairedDiffSnapshotRedactsAlignedMappingFromEitherSide(t *testing.T) { + left := parseCompositionDocument(t, "left", "credential: alpha\npublic: before\n") + right := parseCompositionDocument(t, "right", "credential: bravo\npublic: after\n") + sensitivity := newDiffSensitivity(map[string]bool{"/credential": true}, nil) + + paired, supported := preparePairedDiffSnapshot(left, right, Path{}, sensitivity) + if !supported { + t.Fatal("compatible mappings were not supported") + } + if !paired.leftRedacted || !paired.rightRedacted { + t.Fatalf("redacted flags = %v, %v; want true on both sides", paired.leftRedacted, paired.rightRedacted) + } + assertDiffStringNode(t, mappingValue(t, paired.left, "credential"), "[REDACTED]") + assertDiffStringNode(t, mappingValue(t, paired.right, "credential"), "[REDACTED]") + assertDiffStringNode(t, mappingValue(t, paired.left, "public"), "before") + assertDiffStringNode(t, mappingValue(t, paired.right, "public"), "after") + assertDiffStringNode(t, mappingValue(t, left, "credential"), "alpha") + assertDiffStringNode(t, mappingValue(t, right, "credential"), "bravo") + if paired.left == left || paired.right == right || &paired.left.Mapping[0] == &left.Mapping[0] { + t.Fatal("safe mapping containers or entries share mutable storage with retained trees") + } +} + +func TestPreparePairedDiffSnapshotPreservesSequencePositionsAndLengths(t *testing.T) { + left := parseCompositionDocument(t, "left", "items: [left-public, hidden, old-tail]\n") + right := parseCompositionDocument(t, "right", "items: [right-public, hidden, old-tail, extra]\n") + sensitivity := newDiffSensitivity(nil, map[string]bool{"/items/1": true}) + + paired, supported := preparePairedDiffSnapshot( + mappingValue(t, left, "items"), mappingValue(t, right, "items"), Keys("items"), sensitivity, + ) + if !supported { + t.Fatal("compatible sequences were not supported") + } + if !paired.leftRedacted || !paired.rightRedacted { + t.Fatalf("redacted flags = %v, %v; want true on both sides", paired.leftRedacted, paired.rightRedacted) + } + if len(paired.left.Sequence) != 3 || len(paired.right.Sequence) != 4 { + t.Fatalf("sequence lengths = %d, %d; want 3, 4", len(paired.left.Sequence), len(paired.right.Sequence)) + } + assertDiffStringNode(t, paired.left.Sequence[0], "left-public") + assertDiffStringNode(t, paired.right.Sequence[0], "right-public") + assertDiffStringNode(t, paired.left.Sequence[1], "[REDACTED]") + assertDiffStringNode(t, paired.right.Sequence[1], "[REDACTED]") + assertDiffStringNode(t, paired.right.Sequence[3], "extra") + assertDiffStringNode(t, mappingValue(t, left, "items").Sequence[1], "hidden") + if &paired.left.Sequence[0] == &mappingValue(t, left, "items").Sequence[0] { + t.Fatal("safe sequence shares mutable storage with retained tree") + } +} + +func TestPreparePairedDiffSnapshotMasksSensitiveDescendantsOfAbsentSubtrees(t *testing.T) { + tests := []struct { + name string + left, right string + absentSide string + }{ + { + name: "removed subtree", + left: "removed: {token: old-secret, public: retained}\n", + right: "other: value\n", + absentSide: "right", + }, + { + name: "added subtree", + left: "other: value\n", + right: "added: {token: new-secret, public: retained}\n", + absentSide: "left", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + left := parseCompositionDocument(t, "left", test.left) + right := parseCompositionDocument(t, "right", test.right) + paired, supported := preparePairedDiffSnapshot( + mappingValueIfPresent(left, "removed", "added"), + mappingValueIfPresent(right, "removed", "added"), + Keys("subtree"), + newDiffSensitivity(map[string]bool{"/subtree/token": true}, nil), + ) + if !supported { + t.Fatal("compatible subtree and absence were not supported") + } + if test.absentSide == "left" { + if paired.left != nil || paired.leftRedacted || !paired.rightRedacted { + t.Fatalf("paired absence/redaction = %#v", paired) + } + assertDiffStringNode(t, mappingValue(t, paired.right, "token"), "[REDACTED]") + assertDiffStringNode(t, mappingValue(t, paired.right, "public"), "retained") + } else { + if paired.right != nil || paired.rightRedacted || !paired.leftRedacted { + t.Fatalf("paired absence/redaction = %#v", paired) + } + assertDiffStringNode(t, mappingValue(t, paired.left, "token"), "[REDACTED]") + assertDiffStringNode(t, mappingValue(t, paired.left, "public"), "retained") + } + }) + } +} + +func TestPreparePairedDiffSnapshotMasksSelectedRootAndContainer(t *testing.T) { + tests := []struct { + name string + left, right *document.Node + path Path + selector string + }{ + { + name: "root", + left: parseCompositionDocument(t, "left", "token: old\n"), + right: parseCompositionDocument(t, "right", "token: new\n"), + path: Path{}, + selector: "", + }, + { + name: "container", + left: parseCompositionDocument(t, "left", "group: {public: old}\n"), + right: parseCompositionDocument(t, "right", "group: {public: new}\n"), + path: Keys("group"), + selector: "/group", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + paired, supported := preparePairedDiffSnapshot( + test.left, test.right, test.path, + newDiffSensitivity(map[string]bool{test.selector: true}, nil), + ) + if !supported { + t.Fatal("selected nodes were not supported") + } + if !paired.leftRedacted || !paired.rightRedacted { + t.Fatalf("redacted flags = %v, %v; want true on both sides", paired.leftRedacted, paired.rightRedacted) + } + assertDiffStringNode(t, paired.left, "[REDACTED]") + assertDiffStringNode(t, paired.right, "[REDACTED]") + }) + } +} + +func TestPreparePairedDiffSnapshotDoesNotInterpretMarkerLookingInput(t *testing.T) { + left := parseCompositionDocument(t, "left", "label: '[REDACTED]'\n") + right := parseCompositionDocument(t, "right", "label: '[REDACTED]'\n") + paired, supported := preparePairedDiffSnapshot( + mappingValue(t, left, "label"), mappingValue(t, right, "label"), Keys("label"), + newDiffSensitivity(nil, nil), + ) + if !supported { + t.Fatal("ordinary scalar values were not supported") + } + if paired.leftRedacted || paired.rightRedacted { + t.Fatalf("redacted flags = %v, %v; want false", paired.leftRedacted, paired.rightRedacted) + } + if paired.left != mappingValue(t, left, "label") || paired.right != mappingValue(t, right, "label") { + t.Fatal("unselected scalar nodes should remain borrowed immutable nodes") + } + assertDiffStringNode(t, paired.left, "[REDACTED]") + assertDiffStringNode(t, paired.right, "[REDACTED]") +} + +func TestPreparePairedDiffSnapshotWithholdsUnsupportedIntermediateShape(t *testing.T) { + left := parseCompositionDocument(t, "left", "token: undisclosed-canary\npublic: safe\n") + right := &document.Node{Kind: document.String, Text: "replacement"} + paired, supported := preparePairedDiffSnapshot( + left, right, Path{}, newDiffSensitivity(nil, map[string]bool{"/token": true}), + ) + if supported || paired.left != nil || paired.right != nil { + t.Fatalf("unsupported shape returned potentially unsafe nodes: %#v, supported=%v", paired, supported) + } +} + +func mappingValueIfPresent(node *document.Node, keys ...string) *document.Node { + if node == nil { + return nil + } + for _, key := range keys { + for _, entry := range node.Mapping { + if entry.Key == key { + return entry.Value + } + } + } + return nil +} + +func assertDiffStringNode(t *testing.T, node *document.Node, want string) { + t.Helper() + if node == nil || node.Kind != document.String || node.Text != want { + t.Fatalf("node = %#v, want string %q", node, want) + } +}