Prepare paired safe snapshots for diffs
This commit is contained in:
@@ -194,7 +194,7 @@ whole-map scans or new persistent inspection state.
|
|||||||
|
|
||||||
## Stage 4 — Build paired safe snapshots for compatible shapes
|
## 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
|
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
|
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
|
everyday check. Done: compatible paired masking preserves presence and public
|
||||||
content, while existing standalone rendering remains unchanged.
|
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
|
## Stage 5 — Complete structural-replacement masking
|
||||||
|
|
||||||
Status: planned. Scope: the fallback rule and its shape-sensitive regressions.
|
Status: planned. Scope: the fallback rule and its shape-sensitive regressions.
|
||||||
|
|||||||
170
internal/engine/diff_snapshot.go
Normal file
170
internal/engine/diff_snapshot.go
Normal file
@@ -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]"}
|
||||||
|
}
|
||||||
199
internal/engine/diff_snapshot_test.go
Normal file
199
internal/engine/diff_snapshot_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user