diff --git a/docs/internal/overview.md b/docs/internal/overview.md index 44bf3d3..42404f3 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -51,9 +51,11 @@ module and package documentation boundary. Its implemented public primitives are `secrets.go`; - fixed-point tag/predicate/dependency sensitivity classification in `secret.go`; - frozen-snapshot redaction and canonical effective output in `render.go`; -- bounded immutable input applications, safe historical replay, detached explanation - records, and deterministic human rendering in `explain.go`, with the - only upstream dumper integration isolated in `internal/yaml/write.go`; +- detached public explanation records and coordination in `explain.go`, immutable + input applications/replay and retained-node budgeting in `history.go`, shared + safe snapshot projection in `snapshot.go`, and deterministic human output in + `explain_render.go`; the only upstream dumper integration remains isolated in + `internal/yaml/write.go`; - a separate maintained `examples/consumers` package with four sanitized consumer shapes, fixtures, domain validators, and runtime-boundary functions; and - a fail-fast source-candidate checker in `scripts/release-check.sh`, with local diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index 27a8e35..6b813ef 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -338,7 +338,16 @@ ordering by reading the coordinator. Run the complete race suite. ## Stage 8 — Separate explanation replay, snapshots, and rendering -Status: pending. +Status: complete. + +Completion: public records and Explain coordination remain in `explain.go`; +immutable applications, replay/coalescing, and shared-node budgeting live in +`history.go`; shared effective/explanation redaction lives in `snapshot.go`; +human output lives in `explain_render.go`. Metadata versus replay ownership is +documented beside history. Existing reconciliation, historical sensitivity, +container/leaf, retained-budget, detachment, and writer-failure tests passed +without expectation changes. Full offline tests, vet, build, and whitespace +checks passed with Go 1.26.7 on Linux/amd64. Evidence: [explain.go](../../explain.go) combines approximately 576 lines of public records, replay algorithms, snapshot redaction, retained-history accounting, and diff --git a/explain.go b/explain.go index 4c9e05e..16bdc35 100644 --- a/explain.go +++ b/explain.go @@ -1,23 +1,6 @@ package configkit -import ( - "bytes" - "errors" - "io" - "slices" - "strconv" - "strings" - - "gitea.maximumdirect.net/eric/ConfigKit/internal/document" - yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml" -) - -const historyNodeLimit = 4_000_000 - -var ( - errExplanationUnavailable = errors.New("configkit: configuration explanation is unavailable") - errExplanationUnknownPath = errors.New("configkit: configuration explanation path is unknown") -) +import "gitea.maximumdirect.net/eric/ConfigKit/internal/document" // ValueSnapshot is a detached, deterministic YAML representation of one value. // Present distinguishes absence or deletion from an explicit null value. @@ -51,38 +34,6 @@ type Explanation struct { SecretReference *SecretReferenceInfo } -type historyApplication struct { - layer Layer - path Path - value *document.Node - origin Origin -} - -func cloneHistoryApplications(values []historyApplication) []historyApplication { - if values == nil { - return nil - } - cloned := make([]historyApplication, len(values)) - for index, value := range values { - cloned[index] = historyApplication{ - layer: value.layer, path: value.path.clone(), value: value.value, - origin: cloneOrigin(value.origin), - } - } - return cloned -} - -func cloneSecretReferences(values map[string]string) map[string]string { - if values == nil { - return nil - } - cloned := make(map[string]string, len(values)) - for path, name := range values { - cloned[path] = name - } - return cloned -} - // Explain returns safe effective and chronological values for a known final or // historical path. Unknown and malformed paths return false. func (r *Result[T]) Explain(path Path) (Explanation, bool) { @@ -169,408 +120,3 @@ func (r *Result[T]) Explain(path Path) (Explanation, bool) { } return explanation, true } - -func applicationOperationPath(application historyApplication, query Path) (Path, bool) { - if !application.path.valid() || application.value == nil { - return Path{}, false - } - if application.path.String() != "" { - if pathsIntersect(application.path, query) { - return application.path.clone(), true - } - return Path{}, false - } - if len(query.segments) == 0 { - return Path{}, true - } - for length := 1; length <= len(query.segments); length++ { - prefix := Path{segments: clonePathSegments(query.segments[:length], 0)} - node, present := documentAtPath(application.value, prefix) - if !present { - return Path{}, false - } - if length == len(query.segments) || node.Kind != document.Mapping { - return prefix, true - } - } - return Path{}, false -} - -func historyApplicationOrigin(application historyApplication, operationPath Path) Origin { - if application.origin.Layer != "" { - return cloneOrigin(application.origin) - } - origin := Origin{Layer: application.layer} - relative, ok := relativeHistoryPath(application.path, operationPath) - if !ok { - return origin - } - if node, present := documentAtPath(application.value, relative); present { - origin.Locations = []Location{publicLocation(node.Location)} - } - return origin -} - -func relativeHistoryPath(root, path Path) (Path, bool) { - if !pathContains(root, path) { - return Path{}, false - } - return Path{segments: clonePathSegments(path.segments[len(root.segments):], 0)}, true -} - -func pathsIntersect(left, right Path) bool { - return pathContains(left, right) || pathContains(right, left) -} - -func applyHistoryApplication(root **document.Node, application historyApplication) { - if application.path.String() == "" { - mergeHistoryDocument(root, application.value) - return - } - mergeHistoryAtPath(root, application.path, application.value) -} - -func mergeHistoryDocument(target **document.Node, incoming *document.Node) { - if incoming == nil { - return - } - if *target == nil || (*target).Kind != document.Mapping || incoming.Kind != document.Mapping { - *target = document.Clone(incoming) - return - } - for _, entry := range incoming.Mapping { - position := documentEntryIndex((*target).Mapping, entry.Key) - if position < 0 { - cloned := entry - cloned.Value = document.Clone(entry.Value) - (*target).Mapping = append((*target).Mapping, cloned) - continue - } - mergeHistoryDocument(&(*target).Mapping[position].Value, entry.Value) - } -} - -func mergeHistoryAtPath(root **document.Node, path Path, incoming *document.Node) { - if root == nil || *root == nil || len(path.segments) == 0 { - mergeHistoryDocument(root, incoming) - return - } - current := *root - for index, segment := range path.segments { - if current.Kind != document.Mapping || segment.kind == pathSegmentIndex { - return - } - position := documentEntryIndex(current.Mapping, segment.key) - if index == len(path.segments)-1 { - if position < 0 { - current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, Value: document.Clone(incoming)}) - } else { - mergeHistoryDocument(¤t.Mapping[position].Value, incoming) - } - return - } - if position < 0 { - child := &document.Node{Kind: document.Mapping} - current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, Value: child}) - current = child - } else { - current = current.Mapping[position].Value - } - } -} - -// Resolution records describe one before/final transformation, not assignment -// order. A parent already carries its descendants' final values; merge overlapping -// intent into that event rather than replay a child assignment a second time. -func coalescedResolutionSnapshots(snapshots []resolutionSnapshot) []resolutionSnapshot { - groups := make([]resolutionSnapshot, 0, len(snapshots)) - for _, snapshot := range snapshots { - merged := false - for i := range groups { - if !pathContains(groups[i].change.Path, snapshot.change.Path) { - continue - } - for _, reason := range snapshot.change.Reasons { - groups[i].change.Reasons = appendUniqueString(groups[i].change.Reasons, reason) - } - for _, dependency := range snapshot.change.Dependencies { - groups[i].change.Dependencies = appendUniquePath(groups[i].change.Dependencies, dependency) - } - if snapshot.change.Origin.Operation == ChangeNormalization { - groups[i].change.Origin.Operation = ChangeNormalization - } - merged = true - break - } - if !merged { - snapshot.change = cloneChange(snapshot.change) - groups = append(groups, snapshot) - } - } - return groups -} - -func replaceHistoryPath(root **document.Node, path Path, replacement *document.Node) { - if root == nil || *root == nil { - return - } - if len(path.segments) == 0 { - *root = document.Clone(replacement) - return - } - current := *root - for index, segment := range path.segments { - last := index == len(path.segments)-1 - switch current.Kind { - case document.Mapping: - if segment.kind == pathSegmentIndex { - return - } - position := documentEntryIndex(current.Mapping, segment.key) - if position < 0 { - if replacement == nil { - return - } - child := &document.Node{Kind: document.Mapping} - if last { - child = document.Clone(replacement) - } - current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, Value: child}) - if last { - return - } - current = child - continue - } - if last { - if replacement == nil { - current.Mapping = append(current.Mapping[:position], current.Mapping[position+1:]...) - } else { - current.Mapping[position].Value = document.Clone(replacement) - } - return - } - current = current.Mapping[position].Value - case document.Sequence: - position, ok := schemaIndex(segment) - if !ok || position >= len(current.Sequence) { - return - } - if last { - if replacement != nil { - current.Sequence[position] = document.Clone(replacement) - } - return - } - current = current.Sequence[position] - default: - return - } - } -} - -func valueSnapshot(root *document.Node, path Path, secrets map[string]bool) ValueSnapshot { - node, present := documentAtPath(root, path) - if !present { - return ValueSnapshot{} - } - safe, redacted := safeSnapshotDocument(node, secrets, path) - var output bytes.Buffer - if err := yamladapter.Write(&output, safe); err != nil { - return ValueSnapshot{Present: true, Redacted: true, YAML: "'[REDACTED]'\n"} - } - return ValueSnapshot{Present: true, Redacted: redacted, YAML: output.String()} -} - -func safeSnapshotDocument(node *document.Node, secrets map[string]bool, path Path) (*document.Node, bool) { - if node == nil { - return nil, false - } - if pathIsSecret(secrets, path) { - return &document.Node{Kind: document.String, Text: "[REDACTED]"}, true - } - result := *node - redacted := false - switch node.Kind { - case document.Mapping: - result.Mapping = make([]document.Entry, len(node.Mapping)) - copy(result.Mapping, node.Mapping) - sortDocumentMapping(result.Mapping) - for index := range result.Mapping { - entry := &result.Mapping[index] - entry.Value, redacted = safeSnapshotChild(entry.Value, secrets, path.Key(entry.Key), redacted) - } - case document.Sequence: - result.Sequence = make([]*document.Node, len(node.Sequence)) - for index, child := range node.Sequence { - result.Sequence[index], redacted = safeSnapshotChild(child, secrets, path.Index(index), redacted) - } - } - return &result, redacted -} - -func safeSnapshotChild(node *document.Node, secrets map[string]bool, path Path, prior bool) (*document.Node, bool) { - safe, redacted := safeSnapshotDocument(node, secrets, path) - return safe, prior || redacted -} - -func sortDocumentMapping(entries []document.Entry) { - slices.SortFunc(entries, func(left, right document.Entry) int { - return strings.Compare(left.Key, right.Key) - }) -} - -func historyNodesWithinLimit(baseline, input, final *document.Node, applications []historyApplication, resolutions []resolutionSnapshot) bool { - return historyNodesWithin(historyNodeLimit, baseline, input, final, applications, resolutions) -} - -func historyNodesWithin(limit int, baseline, input, final *document.Node, applications []historyApplication, resolutions []resolutionSnapshot) bool { - seen := make(map[*document.Node]bool) - count := 0 - var visit func(*document.Node) bool - visit = func(node *document.Node) bool { - if node == nil || seen[node] { - return true - } - seen[node] = true - count++ - if count > limit { - return false - } - for _, entry := range node.Mapping { - if !visit(entry.Value) { - return false - } - } - for _, child := range node.Sequence { - if !visit(child) { - return false - } - } - return true - } - if !visit(baseline) || !visit(input) || !visit(final) { - return false - } - for _, application := range applications { - if !visit(application.value) { - return false - } - } - for _, resolution := range resolutions { - if !visit(resolution.before) || !visit(resolution.after) { - return false - } - } - return true -} - -// WriteExplanation writes a deterministic human-readable rendering of Explain. -// Unknown paths produce no output. Writer failures may leave partial output. -func (r *Result[T]) WriteExplanation(output io.Writer, path Path) error { - if r == nil || output == nil { - return errExplanationUnavailable - } - explanation, ok := r.Explain(path) - if !ok { - return errExplanationUnknownPath - } - var rendered strings.Builder - rendered.WriteString("path: ") - rendered.WriteString(strconv.Quote(explanation.Path.String())) - rendered.WriteByte('\n') - writeExplanationOrigin(&rendered, "effective source", explanation.Origin, r.sources) - writeExplanationSnapshot(&rendered, "effective (canonical typed)", explanation.Effective, 0) - if explanation.SecretReference != nil { - rendered.WriteString("secret reference: ") - rendered.WriteString(strconv.Quote(explanation.SecretReference.Name)) - rendered.WriteString(" (resolved from process environment)\n") - } - rendered.WriteString("history:\n") - for _, change := range explanation.History { - rendered.WriteString(" - representation: ") - if change.Origin.Operation == ChangeDecoding { - rendered.WriteString("input to canonical typed\n") - } else if change.Origin.Layer == LayerBase || change.Origin.Layer == LayerProfile || change.Origin.Layer == LayerCLI { - rendered.WriteString("policy-validated input\n") - } else { - rendered.WriteString("canonical typed\n") - } - writeExplanationOrigin(&rendered, " source", change.Origin, r.sources) - writeExplanationSnapshot(&rendered, " before", change.Before, 4) - writeExplanationSnapshot(&rendered, " after", change.After, 4) - for _, reason := range change.Reasons { - rendered.WriteString(" reason: ") - rendered.WriteString(strconv.Quote(reason)) - rendered.WriteByte('\n') - } - for _, dependency := range change.Dependencies { - rendered.WriteString(" dependency: ") - rendered.WriteString(strconv.Quote(dependency.String())) - rendered.WriteByte('\n') - } - } - data := []byte(rendered.String()) - written, err := output.Write(data) - if err == nil && written != len(data) { - return io.ErrShortWrite - } - return err -} - -func writeExplanationOrigin(output *strings.Builder, label string, origin Origin, sources []SourceInfo) { - output.WriteString(label) - output.WriteString(": layer=") - output.WriteString(strconv.Quote(string(origin.Layer))) - if origin.Operation != "" { - output.WriteString(" operation=") - output.WriteString(strconv.Quote(string(origin.Operation))) - } - output.WriteByte('\n') - for _, location := range origin.Locations { - output.WriteString(" location: source=") - output.WriteString(strconv.Quote(location.SourceID)) - if label := sourceLabel(sources, location.SourceID); label != "" { - output.WriteString(" label=") - output.WriteString(strconv.Quote(label)) - } - output.WriteString(" line=") - output.WriteString(strconv.Itoa(location.Line)) - output.WriteString(" column=") - output.WriteString(strconv.Itoa(location.Column)) - output.WriteByte('\n') - } -} - -func sourceLabel(sources []SourceInfo, sourceID string) string { - for _, source := range sources { - if source.ID == sourceID { - return source.Label - } - } - return "" -} - -func writeExplanationSnapshot(output *strings.Builder, label string, snapshot ValueSnapshot, indent int) { - if !snapshot.Present { - output.WriteString(label) - output.WriteString(": absent\n") - return - } - output.WriteString(label) - if snapshot.Redacted { - output.WriteString(" (redacted)") - } - output.WriteString(":\n") - prefix := strings.Repeat(" ", indent+2) - for _, line := range strings.SplitAfter(snapshot.YAML, "\n") { - if line == "" { - continue - } - output.WriteString(prefix) - output.WriteString(line) - if !strings.HasSuffix(line, "\n") { - output.WriteByte('\n') - } - } -} diff --git a/explain_render.go b/explain_render.go new file mode 100644 index 0000000..be8250d --- /dev/null +++ b/explain_render.go @@ -0,0 +1,123 @@ +package configkit + +import ( + "errors" + "io" + "strconv" + "strings" +) + +var ( + errExplanationUnavailable = errors.New("configkit: configuration explanation is unavailable") + errExplanationUnknownPath = errors.New("configkit: configuration explanation path is unknown") +) + +// WriteExplanation writes a deterministic human-readable rendering of Explain. +// Unknown paths produce no output. Writer failures may leave partial output. +func (r *Result[T]) WriteExplanation(output io.Writer, path Path) error { + if r == nil || output == nil { + return errExplanationUnavailable + } + explanation, ok := r.Explain(path) + if !ok { + return errExplanationUnknownPath + } + var rendered strings.Builder + rendered.WriteString("path: ") + rendered.WriteString(strconv.Quote(explanation.Path.String())) + rendered.WriteByte('\n') + writeExplanationOrigin(&rendered, "effective source", explanation.Origin, r.sources) + writeExplanationSnapshot(&rendered, "effective (canonical typed)", explanation.Effective, 0) + if explanation.SecretReference != nil { + rendered.WriteString("secret reference: ") + rendered.WriteString(strconv.Quote(explanation.SecretReference.Name)) + rendered.WriteString(" (resolved from process environment)\n") + } + rendered.WriteString("history:\n") + for _, change := range explanation.History { + rendered.WriteString(" - representation: ") + if change.Origin.Operation == ChangeDecoding { + rendered.WriteString("input to canonical typed\n") + } else if change.Origin.Layer == LayerBase || change.Origin.Layer == LayerProfile || change.Origin.Layer == LayerCLI { + rendered.WriteString("policy-validated input\n") + } else { + rendered.WriteString("canonical typed\n") + } + writeExplanationOrigin(&rendered, " source", change.Origin, r.sources) + writeExplanationSnapshot(&rendered, " before", change.Before, 4) + writeExplanationSnapshot(&rendered, " after", change.After, 4) + for _, reason := range change.Reasons { + rendered.WriteString(" reason: ") + rendered.WriteString(strconv.Quote(reason)) + rendered.WriteByte('\n') + } + for _, dependency := range change.Dependencies { + rendered.WriteString(" dependency: ") + rendered.WriteString(strconv.Quote(dependency.String())) + rendered.WriteByte('\n') + } + } + data := []byte(rendered.String()) + written, err := output.Write(data) + if err == nil && written != len(data) { + return io.ErrShortWrite + } + return err +} + +func writeExplanationOrigin(output *strings.Builder, label string, origin Origin, sources []SourceInfo) { + output.WriteString(label) + output.WriteString(": layer=") + output.WriteString(strconv.Quote(string(origin.Layer))) + if origin.Operation != "" { + output.WriteString(" operation=") + output.WriteString(strconv.Quote(string(origin.Operation))) + } + output.WriteByte('\n') + for _, location := range origin.Locations { + output.WriteString(" location: source=") + output.WriteString(strconv.Quote(location.SourceID)) + if label := sourceLabel(sources, location.SourceID); label != "" { + output.WriteString(" label=") + output.WriteString(strconv.Quote(label)) + } + output.WriteString(" line=") + output.WriteString(strconv.Itoa(location.Line)) + output.WriteString(" column=") + output.WriteString(strconv.Itoa(location.Column)) + output.WriteByte('\n') + } +} + +func sourceLabel(sources []SourceInfo, sourceID string) string { + for _, source := range sources { + if source.ID == sourceID { + return source.Label + } + } + return "" +} + +func writeExplanationSnapshot(output *strings.Builder, label string, snapshot ValueSnapshot, indent int) { + if !snapshot.Present { + output.WriteString(label) + output.WriteString(": absent\n") + return + } + output.WriteString(label) + if snapshot.Redacted { + output.WriteString(" (redacted)") + } + output.WriteString(":\n") + prefix := strings.Repeat(" ", indent+2) + for _, line := range strings.SplitAfter(snapshot.YAML, "\n") { + if line == "" { + continue + } + output.WriteString(prefix) + output.WriteString(line) + if !strings.HasSuffix(line, "\n") { + output.WriteByte('\n') + } + } +} diff --git a/history.go b/history.go new file mode 100644 index 0000000..6b9111a --- /dev/null +++ b/history.go @@ -0,0 +1,276 @@ +package configkit + +import ( + "gitea.maximumdirect.net/eric/ConfigKit/internal/document" +) + +const historyNodeLimit = 4_000_000 + +// History applications borrow immutable policy-validated input trees. Metadata +// records answer presence and origin queries; replay reconstructs value changes. +// Replay reconciles with canonical decoding before domain resolution so both +// contracts agree on the effective result without retaining a tree per field. +type historyApplication struct { + layer Layer + path Path + value *document.Node + origin Origin +} + +func cloneHistoryApplications(values []historyApplication) []historyApplication { + if values == nil { + return nil + } + cloned := make([]historyApplication, len(values)) + for index, value := range values { + cloned[index] = historyApplication{ + layer: value.layer, path: value.path.clone(), value: value.value, + origin: cloneOrigin(value.origin), + } + } + return cloned +} + +func applicationOperationPath(application historyApplication, query Path) (Path, bool) { + if !application.path.valid() || application.value == nil { + return Path{}, false + } + if application.path.String() != "" { + if pathsIntersect(application.path, query) { + return application.path.clone(), true + } + return Path{}, false + } + if len(query.segments) == 0 { + return Path{}, true + } + for length := 1; length <= len(query.segments); length++ { + prefix := Path{segments: clonePathSegments(query.segments[:length], 0)} + node, present := documentAtPath(application.value, prefix) + if !present { + return Path{}, false + } + if length == len(query.segments) || node.Kind != document.Mapping { + return prefix, true + } + } + return Path{}, false +} + +func historyApplicationOrigin(application historyApplication, operationPath Path) Origin { + if application.origin.Layer != "" { + return cloneOrigin(application.origin) + } + origin := Origin{Layer: application.layer} + relative, ok := relativeHistoryPath(application.path, operationPath) + if !ok { + return origin + } + if node, present := documentAtPath(application.value, relative); present { + origin.Locations = []Location{publicLocation(node.Location)} + } + return origin +} + +func relativeHistoryPath(root, path Path) (Path, bool) { + if !pathContains(root, path) { + return Path{}, false + } + return Path{segments: clonePathSegments(path.segments[len(root.segments):], 0)}, true +} + +func pathsIntersect(left, right Path) bool { + return pathContains(left, right) || pathContains(right, left) +} + +func applyHistoryApplication(root **document.Node, application historyApplication) { + if application.path.String() == "" { + mergeHistoryDocument(root, application.value) + return + } + mergeHistoryAtPath(root, application.path, application.value) +} + +func mergeHistoryDocument(target **document.Node, incoming *document.Node) { + if incoming == nil { + return + } + if *target == nil || (*target).Kind != document.Mapping || incoming.Kind != document.Mapping { + *target = document.Clone(incoming) + return + } + for _, entry := range incoming.Mapping { + position := documentEntryIndex((*target).Mapping, entry.Key) + if position < 0 { + cloned := entry + cloned.Value = document.Clone(entry.Value) + (*target).Mapping = append((*target).Mapping, cloned) + continue + } + mergeHistoryDocument(&(*target).Mapping[position].Value, entry.Value) + } +} + +func mergeHistoryAtPath(root **document.Node, path Path, incoming *document.Node) { + if root == nil || *root == nil || len(path.segments) == 0 { + mergeHistoryDocument(root, incoming) + return + } + current := *root + for index, segment := range path.segments { + if current.Kind != document.Mapping || segment.kind == pathSegmentIndex { + return + } + position := documentEntryIndex(current.Mapping, segment.key) + if index == len(path.segments)-1 { + if position < 0 { + current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, Value: document.Clone(incoming)}) + } else { + mergeHistoryDocument(¤t.Mapping[position].Value, incoming) + } + return + } + if position < 0 { + child := &document.Node{Kind: document.Mapping} + current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, Value: child}) + current = child + } else { + current = current.Mapping[position].Value + } + } +} + +// Resolution records describe one before/final transformation, not assignment +// order. A parent already carries its descendants' final values; merge overlapping +// intent into that event rather than replay a child assignment a second time. +func coalescedResolutionSnapshots(snapshots []resolutionSnapshot) []resolutionSnapshot { + groups := make([]resolutionSnapshot, 0, len(snapshots)) + for _, snapshot := range snapshots { + merged := false + for i := range groups { + if !pathContains(groups[i].change.Path, snapshot.change.Path) { + continue + } + for _, reason := range snapshot.change.Reasons { + groups[i].change.Reasons = appendUniqueString(groups[i].change.Reasons, reason) + } + for _, dependency := range snapshot.change.Dependencies { + groups[i].change.Dependencies = appendUniquePath(groups[i].change.Dependencies, dependency) + } + if snapshot.change.Origin.Operation == ChangeNormalization { + groups[i].change.Origin.Operation = ChangeNormalization + } + merged = true + break + } + if !merged { + snapshot.change = cloneChange(snapshot.change) + groups = append(groups, snapshot) + } + } + return groups +} + +func replaceHistoryPath(root **document.Node, path Path, replacement *document.Node) { + if root == nil || *root == nil { + return + } + if len(path.segments) == 0 { + *root = document.Clone(replacement) + return + } + current := *root + for index, segment := range path.segments { + last := index == len(path.segments)-1 + switch current.Kind { + case document.Mapping: + if segment.kind == pathSegmentIndex { + return + } + position := documentEntryIndex(current.Mapping, segment.key) + if position < 0 { + if replacement == nil { + return + } + child := &document.Node{Kind: document.Mapping} + if last { + child = document.Clone(replacement) + } + current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, Value: child}) + if last { + return + } + current = child + continue + } + if last { + if replacement == nil { + current.Mapping = append(current.Mapping[:position], current.Mapping[position+1:]...) + } else { + current.Mapping[position].Value = document.Clone(replacement) + } + return + } + current = current.Mapping[position].Value + case document.Sequence: + position, ok := schemaIndex(segment) + if !ok || position >= len(current.Sequence) { + return + } + if last { + if replacement != nil { + current.Sequence[position] = document.Clone(replacement) + } + return + } + current = current.Sequence[position] + default: + return + } + } +} + +func historyNodesWithinLimit(baseline, input, final *document.Node, applications []historyApplication, resolutions []resolutionSnapshot) bool { + return historyNodesWithin(historyNodeLimit, baseline, input, final, applications, resolutions) +} + +func historyNodesWithin(limit int, baseline, input, final *document.Node, applications []historyApplication, resolutions []resolutionSnapshot) bool { + seen := make(map[*document.Node]bool) + count := 0 + var visit func(*document.Node) bool + visit = func(node *document.Node) bool { + if node == nil || seen[node] { + return true + } + seen[node] = true + count++ + if count > limit { + return false + } + for _, entry := range node.Mapping { + if !visit(entry.Value) { + return false + } + } + for _, child := range node.Sequence { + if !visit(child) { + return false + } + } + return true + } + if !visit(baseline) || !visit(input) || !visit(final) { + return false + } + for _, application := range applications { + if !visit(application.value) { + return false + } + } + for _, resolution := range resolutions { + if !visit(resolution.before) || !visit(resolution.after) { + return false + } + } + return true +} diff --git a/secret_reference.go b/secret_reference.go index caee05e..42e793e 100644 --- a/secret_reference.go +++ b/secret_reference.go @@ -212,3 +212,14 @@ func collectChangedSecretStates(value reflect.Value, descriptor *typeDescriptor, } } } + +func cloneSecretReferences(values map[string]string) map[string]string { + if values == nil { + return nil + } + cloned := make(map[string]string, len(values)) + for path, name := range values { + cloned[path] = name + } + return cloned +} diff --git a/snapshot.go b/snapshot.go new file mode 100644 index 0000000..6d144d0 --- /dev/null +++ b/snapshot.go @@ -0,0 +1,63 @@ +package configkit + +import ( + "bytes" + "slices" + "strings" + + "gitea.maximumdirect.net/eric/ConfigKit/internal/document" + yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml" +) + +// Snapshot projection is the shared redaction boundary for effective YAML +// and explanation values. It borrows frozen trees and returns a detached tree. +func valueSnapshot(root *document.Node, path Path, secrets map[string]bool) ValueSnapshot { + node, present := documentAtPath(root, path) + if !present { + return ValueSnapshot{} + } + safe, redacted := safeSnapshotDocument(node, secrets, path) + var output bytes.Buffer + if err := yamladapter.Write(&output, safe); err != nil { + return ValueSnapshot{Present: true, Redacted: true, YAML: "'[REDACTED]'\n"} + } + return ValueSnapshot{Present: true, Redacted: redacted, YAML: output.String()} +} + +func safeSnapshotDocument(node *document.Node, secrets map[string]bool, path Path) (*document.Node, bool) { + if node == nil { + return nil, false + } + if pathIsSecret(secrets, path) { + return &document.Node{Kind: document.String, Text: "[REDACTED]"}, true + } + result := *node + redacted := false + switch node.Kind { + case document.Mapping: + result.Mapping = make([]document.Entry, len(node.Mapping)) + copy(result.Mapping, node.Mapping) + sortDocumentMapping(result.Mapping) + for index := range result.Mapping { + entry := &result.Mapping[index] + entry.Value, redacted = safeSnapshotChild(entry.Value, secrets, path.Key(entry.Key), redacted) + } + case document.Sequence: + result.Sequence = make([]*document.Node, len(node.Sequence)) + for index, child := range node.Sequence { + result.Sequence[index], redacted = safeSnapshotChild(child, secrets, path.Index(index), redacted) + } + } + return &result, redacted +} + +func safeSnapshotChild(node *document.Node, secrets map[string]bool, path Path, prior bool) (*document.Node, bool) { + safe, redacted := safeSnapshotDocument(node, secrets, path) + return safe, prior || redacted +} + +func sortDocumentMapping(entries []document.Entry) { + slices.SortFunc(entries, func(left, right document.Entry) int { + return strings.Compare(left.Key, right.Key) + }) +}