18 Commits

Author SHA1 Message Date
8f3396d6f0 Retire completed diff plans for release preparation 2026-10-09 14:23:20 +00:00
1c2610c2c0 Prepare release notes for v0.7.0 2026-10-09 14:21:37 +00:00
92025d65cb Correct diff masking for historical descendants 2026-10-09 13:22:40 +00:00
a4b5a6840a Record effective diff review findings 2026-10-09 12:57:40 +00:00
2ebdfb539b Document and audit effective configuration diffs 2026-10-09 12:42:17 +00:00
a37e6895e1 Add effective diff consumer examples 2026-10-09 12:37:18 +00:00
8ae542b3a3 Expose effective diffs through the root facade 2026-10-09 12:34:11 +00:00
174efb65f6 Assemble the safe engine diff operation 2026-10-09 12:30:20 +00:00
756149beaa Track hidden changes behind redaction 2026-10-09 12:24:01 +00:00
e5dddef204 Mask sensitive structural replacements 2026-10-09 12:22:36 +00:00
ade5d6a273 Prepare paired safe snapshots for diffs 2026-10-09 12:20:15 +00:00
c20ea4b916 Index combined diff sensitivity 2026-10-09 12:16:13 +00:00
2e17e365a1 Prepare canonical configuration diff regions 2026-10-09 12:14:47 +00:00
fcf914a2ab Record effective diff baseline evidence 2026-10-09 12:12:12 +00:00
3697a51124 Accept the effective configuration diff decision 2026-10-09 12:10:56 +00:00
9194c5b52a Plan effective configuration diff implementation 2026-10-09 05:09:25 +00:00
e82b1c9054 Finalize the effective configuration diff roadmap 2026-10-09 05:02:49 +00:00
6f59cdd237 Plan structured effective configuration diffs 2026-10-09 04:55:29 +00:00
22 changed files with 2323 additions and 6 deletions

View File

@@ -20,7 +20,11 @@ secrets-directory bootstrap is available as a separate startup operation. Explic
CLI override records apply above overlays. Value explanation is available through
detached structured records and deterministic redacted output. Built-in
`extends` relationships let a mapping inherit from a named sibling during the
same load, retaining explicit presence and source/derivation history.
same load, retaining explicit presence and source/derivation history. The root
`Diff` API compares frozen effective values from two successful same-type loads;
the [configuration contract](docs/config.md#effective-configuration-diffs) and
[consumer guide](docs/consumers/README.md#effective-configuration-diffs) describe
its safe changed-region records and maintained workflows.
```go
type Config struct {

53
diff.go Normal file
View File

@@ -0,0 +1,53 @@
package configkit
import "gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
// Difference describes one canonical changed region between two successful
// loads. Left is the earlier value and Right is the later value. Each snapshot
// is detached; Present distinguishes absence from an explicit null, and
// Redacted reports whether combined sensitivity affected that side. Mappings
// are compared recursively, while sequences are reported as atomic values.
// Records are ordered by canonical JSON pointer. HiddenChanged indicates that
// a value concealed by combined sensitivity changed, without revealing which
// concealed member changed.
type Difference struct {
Path Path
Left ValueSnapshot
Right ValueSnapshot
HiddenChanged bool
}
// Diff compares the frozen effective values of two successful loads of the
// same configuration type. It does not reread sources, environment variables,
// or rerun defaults or resolvers. Equal values produce no records even when
// their sources, defaults, or resolution histories differ. Credential contents
// are not compared: typed secret references compare by reference name only.
//
// Both results must be non-nil successful loads with inspection state; nil or
// zero results return an error and no records. Load errors are returned by
// Load, so callers must handle those before calling Diff. Snapshots are safe
// for presentation but are not a complete reloadable configuration.
func Diff[T any](left, right *Result[T]) ([]Difference, error) {
var leftInspection, rightInspection *engine.Inspection
if left != nil {
leftInspection = left.inspection
}
if right != nil {
rightInspection = right.inspection
}
values, err := engine.Diff(leftInspection, rightInspection)
if err != nil {
return nil, err
}
differences := make([]Difference, len(values))
for index, value := range values {
differences[index] = Difference{
Path: fromEnginePath(value.Path),
Left: fromEngineValueSnapshot(value.Left),
Right: fromEngineValueSnapshot(value.Right),
HiddenChanged: value.HiddenChanged,
}
}
return differences, nil
}

View File

@@ -0,0 +1,99 @@
# ADR 0010: Compare Frozen Effective Configuration Snapshots
## Status
Accepted and implemented. The
[configuration contract](../config.md#effective-configuration-diffs) owns current
behavior. Completed planning and remediation records are preserved in Git
history at `1c2610c` under `docs/roadmap/`.
## Date
2026-10-09
## Context
Applications need to inspect how two completed configurations differ, including
the effective impact of an overlay after defaults and application resolution.
ConfigKit already retains canonical typed inspection snapshots, semantic paths,
presence information, and sensitivity classification for successful loads.
Extending this inspection model lets applications share configuration comparison
and safe disclosure while keeping their presentation and domain interpretation.
The operation should follow ConfigKit's existing equality, ownership, and secret
boundaries so consumers can reason about loading and inspection consistently.
## Decision
Provide structured effective-value comparison between successful results of the
same configuration type. Compare their retained canonical inspection snapshots
through the root-owned public facade. Each comparison uses the completed load
state and returns detached records with a semantic path and safe left and right
value snapshots.
Use canonical value equality and the existing structural comparison rules.
Mappings compare recursively, sequences report atomic changes, and scalar or
kind replacements report the affected region. Presence distinguishes additions
and removals from explicit null. Equal effective values produce an empty diff;
source attribution, explicit-input status, and history remain separately
inspectable through each result. Canonical pointer ordering makes reporting
deterministic.
Detect changes before masking values. Apply sensitivity from both loads to both
returned sides, including historical and dependency-derived classification.
Preserve public content where the shapes permit corresponding sensitive regions
to be identified. When a structural replacement prevents safe correspondence,
mask the complete reported replacement region on both present sides. Absence
retains its own representation.
Keep masking and hidden-change information distinct. Each record indicates
whether an actual change is withheld by redaction, allowing applications to
display a changed-sensitive-value indicator even when safe snapshots render
identically. Public-only changes alongside unchanged masked content retain that
distinction.
Typed secret references participate through their configured environment names.
Resolved credential bytes remain behind the existing explicit secret accessor.
The comparison uses the same frozen inspection boundary as effective rendering
and explanation.
ConfigKit owns comparison and paired safe disclosure. Applications own labels,
layout, and rendering of the returned changed regions. Reusing the existing
snapshot representation and engine boundary keeps the extension consistent with
the library's established inspection model.
## Alternatives considered
- **Compare input text or separately rendered YAML.** Supports familiar textual
displays, but input text precedes composition and resolution, and independently
redacted output can hide changes or apply different disclosure rules. Canonical
comparison with paired masking represents the completed configuration.
- **Leave comparison to applications using returned Go values.** Offers direct
access to application types, but duplicates structural and redaction machinery
and observes caller mutation. Retained inspection snapshots preserve the
completed load's values and established ownership boundary.
- **Align sequence elements and return context-rich rendering data.** Enables
finer-grained displays, but requires additional correspondence and disclosure
rules. Atomic sequence records and existing value snapshots satisfy the
selected changed-region workflows with a smaller public model.
- **Compare resolved credentials or retain credential-derived tokens.** Could
detect rotation behind an unchanged reference, but expands retained sensitive
state and introduces a separate trusted comparison mechanism. Reference-name
comparison preserves the existing inspection boundary.
## Consequences
Applications can compare independently loaded configurations using ConfigKit's
shared equality, presence, and disclosure rules. Existing composition and load
semantics continue to determine the values being compared. Caller mutation of a
returned configuration leaves its retained comparison snapshot unchanged.
Paired redaction can conceal public content within a structurally incompatible
replacement. The changed region and hidden-change indicator still communicate
its effective impact. Credential rotation behind an unchanged environment name
produces equal reference values for comparison.
The implementation requires operation-local disclosure preparation and focused
coverage for presence, shape changes, historical sensitivity, and detached
results. Applications retain control of presentation; changed-region records
supply the safe data needed for that responsibility.

View File

@@ -423,6 +423,46 @@ an explicit `LoadSecretsDir` bootstrap, then use `WriteDiagnostics`,
ConfigKit does not infer bootstrap outcomes from a secret reference and does not
bundle these independent reports into a command framework.
## Effective configuration diffs
`configkit.Diff(left, right)` compares two completed successful loads of the same
configuration type. `left` is the earlier state and `right` is the later state.
Applications load both sides with the sources and options they choose; to inspect
an overlay's impact, load the same base once without the overlay and once with it
selected. The [consumer guide](consumers/README.md#effective-configuration-diffs)
links to maintained examples. The public API returns safe changed-region records;
applications own their presentation.
Comparison uses the frozen canonical effective inspection values, not mutable
`Result.Config`, source text, provenance, or history. It does not reopen sources,
reread environment variables, or rerun defaults or callbacks. Equal effective
values produce no records even when sources, explicit-input status, defaults, or
resolution histories differ; for example, explicit zero and defaulted zero are
equal. Mappings compare recursively, sequences compare atomically, and scalar,
kind, or null/container replacements report their affected region. Records use
the existing comparator's canonical pointer ordering. `Present=false` with empty
YAML means absence; explicit null is present and renders as `null`.
Before either side is returned, sensitivity from both loads is applied to both
snapshots, including historical and dependency-derived classification. A
classification on either side protects the corresponding values on both sides.
`Redacted` reports masking, while `HiddenChanged` reports an actual concealed
value or presence change; it is not implied merely by redaction. A structural
replacement that prevents safe correspondence masks the complete reported
region, and an absent side remains absent. Record membership establishes that a
change occurred even when safe snapshots render identically. Literal
marker-looking input is ordinary input; consumers use the record fields rather
than interpreting marker text.
Typed secret references contribute their configured environment names to the
frozen comparison, never resolved credential bytes. Credential rotation behind
an unchanged reference is therefore not detected. The records contain changed
regions only, not paired full-document context; independently calling
`WriteEffective` on each result does not create cross-protected context. Nil or
uninitialized results return an error with no records. A failed `Load` retains
its normal nil-result/`LoadError` behavior, which callers handle before invoking
`Diff`.
## Typed secret references
Credential-bearing fields use the opaque `Secret` type and store an environment

View File

@@ -18,6 +18,7 @@ sanitized shapes using only ConfigKit and the Go standard library:
| Bundled file | Simple native `fs.FS` source through ordinary `Load` | Selection and lifetime of the application-owned embedded filesystem |
| Layered catalog | Embedded defaults with an import, a mapping underlay, an independent host main file, explicit false/zero input, and source-relative resource access | Domain validation and opening/interpreting the template resource |
| Named presets | One-load sibling inheritance, explicit false, nested destination resolution, and source/derivation inspection | Choosing and executing a preset |
| Effective configuration diff | Same-type frozen effective comparisons for two selected files or base-versus-overlay impact | Source/options selection and consumer-owned safe presentation |
The YAML under [`testdata`](../../examples/consumers/testdata) is loaded by the
executable examples and ordinary tests. It contains no private application data,
@@ -51,6 +52,25 @@ both without redefining their syntax. ConfigKit overlays are configuration overl
they do not select or replace an application's LLM, model, pipeline, report, or
execution profile concepts.
## Effective configuration diffs
[`CompareFiles`](../../examples/consumers/diff.go) loads two caller-selected files
with one application definition, handles each load error, calls `configkit.Diff`,
and presents the safe records through consumer-owned `FormatDifferences`.
[`CompareRolloutOverlay`](../../examples/consumers/diff.go) loads the same base
once without an overlay and once with the selected overlay, so omitted lower
priority values remain present. Its resolver records a derived endpoint URL after
overlay application, and its nested typed secret reference is classified before
the resulting records are presented.
The maintained [behavior tests and executable examples](../../examples/consumers/diff_test.go)
use temporary files and disposable secret values. Presenters should use
`ValueSnapshot.Present` for absence, record membership for change, and
`HiddenChanged` for a withheld change indicator. Do not parse the redaction marker
or add separately rendered full contexts that lack combined sensitivity. The
[configuration contract](../config.md#effective-configuration-diffs) owns shared
equality, granularity, and disclosure semantics.
## Named sibling inheritance
[`LoadPresets`](../../examples/consumers/inheritance.go) loads the maintained
@@ -168,6 +188,7 @@ focused behavioral owner:
| `LoadSecretsDir`, bootstrap reporting, typed lookup, CLI explanation | `Example_weatherStartup` |
| Native filesystem, grouped underlays, programmatic mapping, retained resource context | `Example_nativeFilesystem`, `Example_layeredCatalog`, and their focused consumer tests |
| Named sibling inheritance, presence, and derivation | `Example_inheritedPresets` and `TestPresetCatalogOneLoadAndDerivation` |
| Effective configuration diffs and consumer-owned presentation | `ExampleDiff`, `ExampleCompareFiles`, and `ExampleCompareRolloutOverlay` |
| Results, metadata, diagnostics, effective output, and writer failures | Focused root-package load, provenance, rendering, and explanation tests |
The package-level import-boundary test covers the consumer examples too: supported

View File

@@ -54,6 +54,7 @@ Accepted decisions:
- [Secret references and explicit bootstrap](adr/0004-secret-references-and-explicit-bootstrap.md)
- [Explicit overrides and safe explanation](adr/0005-explicit-overrides-and-safe-explanation.md)
- [Keep the public facade root-owned](adr/0006-public-facade.md)
- [Compare frozen effective configuration snapshots](adr/0010-effective-configuration-diff.md)
For cross-cutting changes, follow every applicable row. Completed implementation
and remediation plans are preserved in Git history at `cb5b435`; use current
@@ -105,6 +106,13 @@ git show 8e91a2b:docs/roadmap/startup-benchmark.md
Remaining optimization candidates belong to
[deferred scope](roadmap/deferred.md#deferred-performance-experiments).
The completed effective-configuration diff roadmap, implementation plan, and
resolved review findings are preserved in Git history at `1c2610c` under
`docs/roadmap/`. The
[configuration contract](config.md#effective-configuration-diffs) owns current
comparison and disclosure behavior, and
[ADR 0010](adr/0010-effective-configuration-diff.md) records the decision.
Install the Go version required by `go.mod` or a compatible newer toolchain.
Acquire declared dependencies explicitly before entering an offline
development or validation environment:
@@ -161,6 +169,7 @@ code changes. Use the component overview to find the corresponding implementatio
| Paths, metadata, provenance | [Paths](../internal/engine/path_test.go), [metadata](../internal/engine/metadata_test.go), [provenance](../internal/engine/provenance_test.go) | `go test ./internal/engine -run 'Path\|Metadata\|Provenance\|Queries'` |
| Passive resource contexts | [Engine contexts](../internal/engine/resource_context_test.go), [public contexts](../facade_resource_context_test.go) | `go test ./internal/engine -run 'Resource'` and `go test . -run 'SourceContext'` |
| Secrets, bootstrap, redaction, explanation | [References](../internal/engine/secret_reference_test.go), [bootstrap](../internal/engine/secrets_test.go), [rendering](../internal/engine/render_test.go), [explanation](../internal/engine/explain_test.go) | `go test ./internal/engine -run 'Secret\|Bootstrap\|Effective\|Explain\|Explanation\|History\|Sensitivity'` |
| Effective configuration diff | [Engine comparison and masking tests](../internal/engine/diff_test.go), [engine operation tests](../internal/engine/diff_operation_test.go), [root facade tests](../facade_diff_test.go), and [consumer examples](../examples/consumers/diff_test.go) | `go test ./internal/engine -run 'Diff|Explain|History|Secret|Independent'`, `go test . -run 'Diff|FacadeInspection|PublicFacadeBoundary|DependencyImportBoundary'`, `go test ./examples/consumers`, and `go test . -run '^ExampleDiff$'` |
| Consumer examples and migration fixtures | [Consumer tests](../examples/consumers/consumers_test.go), [native and layered examples](../examples/consumers/layered_test.go), [API examples](../example_test.go) | `go test ./examples/consumers` and `go test . -run '^Example'` |
| Assembly and layered scaling evidence | [Assembly benchmarks](../internal/engine/assembly_bench_test.go), [layered benchmarks](../internal/engine/underlay_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkUnderlay' -benchmem`; repeat with `^BenchmarkCompose` and `^BenchmarkOverlay` |
| Inheritance scaling evidence | [Expansion benchmarks](../internal/engine/inheritance_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkInheritance' -benchmem` |

View File

@@ -12,7 +12,8 @@ Implemented component owners:
sibling inheritance before typed decoding. It also owns schema
inspection, lifecycle coordination, callback auditing,
provenance/history, passive source-context retention, secret resolution and
bootstrap, and safe inspection rendering. It never imports the root package.
bootstrap, safe inspection rendering, and effective-value comparison over
frozen inspection snapshots. It never imports the root package.
- Root `Source`, `Filesystem`, `Input`, `Underlay`, `Path`, `Secret`, records,
lifecycle callbacks, results, and bootstrap reports are root-defined wrappers
or records. Callback wrappers pass only live engine context during a callback;
@@ -21,6 +22,9 @@ Implemented component owners:
- Root conversion files explicitly cross the path, record, callback, secret, and
error boundaries. There are no public aliases, anonymous engine embeddings, or
public engine bridge methods.
- Root `Diff` and `Difference` provide the public comparison boundary and
convert detached engine records to root-owned paths and snapshots; consumer
examples own presentation of the safe changed regions.
- `internal/document` owns the parser-independent ordered tree and cloning.
`internal/yaml` is the sole upstream YAML adapter and parser/dumper boundary.
- Engine mechanism tests own source, composition, inheritance, decoding,
@@ -37,9 +41,11 @@ these constraints and rejects exported aliases, fields, or signatures involving
internal types.
The [configuration contract](../config.md) owns implemented YAML and lifecycle
semantics. The [consumer adoption guide](../consumers/README.md) owns application
guidance. The [architecture policy](../policy/architecture.md) owns contributor
boundaries, and [ADR 0006](../adr/0006-public-facade.md) records the facade
semantics, including [effective configuration diffs](../config.md#effective-configuration-diffs).
The [consumer adoption guide](../consumers/README.md) owns application guidance.
The [architecture policy](../policy/architecture.md) owns contributor
boundaries; [ADR 0006](../adr/0006-public-facade.md) records the facade decision
and [ADR 0010](../adr/0010-effective-configuration-diff.md) records the diff
decision. See the [development guide](../development.md) for validation commands
and [release procedure](../release.md) for source-candidate gates. [ADR
0009](../adr/0009-sibling-inheritance.md) records why inheritance is a built-in

View File

@@ -201,6 +201,13 @@ See [ADR 0005](../adr/0005-explicit-overrides-and-safe-explanation.md).
Detailed syntax and operation tables belong to the
[current configuration contract](../config.md).
The frozen inspection boundary also supports comparing completed same-type
results without reopening sources or rerunning the load lifecycle. The
[configuration contract](../config.md#effective-configuration-diffs) owns
comparison and disclosure behavior, the [component overview](../internal/overview.md)
owns implementation responsibility, and [ADR 0010](../adr/0010-effective-configuration-diff.md)
records the accepted decision.
## Ownership And Confidentiality
Returned configuration and metadata must not share mutable state across loads.

58
docs/releases/v0.7.0.md Normal file
View File

@@ -0,0 +1,58 @@
# ConfigKit v0.7.0
This minor release adds structured comparison of completed configurations.
Applications can inspect changes between two configuration files or the
effective impact of a selected overlay, with ConfigKit handling comparison and
safe value disclosure while applications own presentation.
## Changes
- Add `Diff[T](left, right *Result[T]) ([]Difference, error)` through the public
facade. It compares frozen effective inspection values from successful loads,
including defaults and recorded application resolution, rather than source
text or later mutations of `Result.Config`.
- Return detached changed-region records containing a semantic path, safe left
and right `ValueSnapshot` values, and `HiddenChanged`. Mappings compare
recursively, sequences report atomic changes, and records follow canonical
pointer order. Presence distinguishes additions and removals from explicit
null; equal effective values produce no records regardless of provenance or
loading history.
- Protect both snapshots with sensitivity from both loads, including historical
and dependency-derived classification. Compatible regions retain public
siblings; incompatible structural replacements use complete-region masking
where required. `HiddenChanged` identifies actual changes withheld by masking,
rather than merely indicating that a snapshot contains redacted content.
- Preserve visible public scalar changes when only historical sensitive
descendants remain, including additions and removals. Historical explanation
redaction and masking of incompatible present shapes remain intact.
- Add maintained file-comparison and base-versus-overlay examples with
consumer-owned formatting, plus an executable public API example. See the
[consumer guide](../consumers/README.md#effective-configuration-diffs) and
[configuration contract](../config.md#effective-configuration-diffs).
## Compatibility and upgrade
No application-code or configuration migration is required from v0.6.0.
Adopting the new API is optional. Loading, composition, inheritance, provenance,
existing inspection, and secret-handling contracts remain unchanged.
Load both configurations successfully before calling `Diff`; nil or
uninitialized results return an error with no records. Comparison does not
reopen sources, rerun callbacks, or reread environment variables. Typed secret
references compare by configured reference name, not resolved credential bytes,
so credential rotation behind an unchanged name is not reported. Returned
snapshots describe changed regions, not cross-protected full-document context.
Select this version and rebuild:
```sh
go get gitea.maximumdirect.net/eric/ConfigKit@v0.7.0
```
The consumer minimum remains Go 1.26.0. The YAML dependency remains
`go.yaml.in/yaml/v4 v4.0.0-rc.6`, and the maintainer tool dependency remains
ReleaseKit v0.3.0. Linux amd64 and arm64 remain release-validated targets;
macOS remains best-effort. Publication follows the existing
[release procedure](../release.md).
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.7.0/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.7.0/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.7.0/docs/oss/attribution.md)

View File

@@ -23,12 +23,18 @@ The following remain deliberately excluded:
- automatic schema migrations;
- hot reload, runtime resource replacement, and remote resource validation;
- a bundled CLI framework or application commands; and
- configuration diffs and fingerprints.
- configuration fingerprints.
Applications may build their own validate, effective-value, source, bootstrap, and
explanation commands around the library. Broadening these boundaries requires
concrete consumer evidence, compatibility review, and an explicit design change.
Effective configuration diffs are implemented over frozen successful-load
inspections; their current comparison and disclosure contract is in the
[configuration contract](../config.md#effective-configuration-diffs), with
consumer workflows in the [adoption guide](../consumers/README.md#effective-configuration-diffs).
Fingerprints remain deferred.
Built-in sibling inheritance using `extends` is implemented as described in the
[configuration contract](../config.md#sibling-inheritance). It is distinct from
inheritance between selected overlay files, which remains excluded above.

View File

@@ -30,6 +30,32 @@ func ExampleNoSource() {
// Output: 4 0 <nil>
}
func ExampleDiff() {
type Config struct {
Workers int `yaml:"workers"`
}
before, err := configkit.Load[Config](configkit.Mapping("before", map[string]any{"workers": 2}))
if err != nil {
fmt.Println(err)
return
}
after, err := configkit.Load[Config](configkit.Mapping("after", map[string]any{"workers": 4}))
if err != nil {
fmt.Println(err)
return
}
differences, err := configkit.Diff(before, after)
if err != nil {
fmt.Println(err)
return
}
for _, difference := range differences {
fmt.Printf("%s: %s -> %s\n", difference.Path.String(),
strings.TrimSpace(difference.Left.YAML), strings.TrimSpace(difference.Right.YAML))
}
// Output: /workers: 2 -> 4
}
func ExampleValidation_Error() {
type Config struct {
First string `yaml:"first"`

136
examples/consumers/diff.go Normal file
View File

@@ -0,0 +1,136 @@
package consumers
import (
"fmt"
"strconv"
"strings"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
// FileComparisonConfig is a small application-owned shape for comparing
// caller-selected configuration files.
type FileComparisonConfig struct {
Name string `yaml:"name"`
Entries map[string]any `yaml:"entries"`
}
// CompareFiles loads two caller-selected files with the same application
// definition, then formats only the safe records returned by configkit.Diff.
func CompareFiles(leftPath, rightPath string) (string, error) {
options := configkit.Options[FileComparisonConfig]{
Definition: configkit.Definition[FileComparisonConfig]{
Defaults: func() FileComparisonConfig {
return FileComparisonConfig{Entries: map[string]any{}}
},
},
}
left, err := configkit.Load(configkit.File(leftPath), options)
if err != nil {
return "", fmt.Errorf("load left configuration: %w", err)
}
right, err := configkit.Load(configkit.File(rightPath), options)
if err != nil {
return "", fmt.Errorf("load right configuration: %w", err)
}
differences, err := configkit.Diff(left, right)
if err != nil {
return "", err
}
return FormatDifferences(differences), nil
}
// RolloutConfig models a service endpoint whose URL is derived by the
// application resolver and whose credential reference is classified.
type RolloutConfig struct {
Service RolloutService `yaml:"service"`
}
// RolloutService contains endpoint settings and a classified credential.
type RolloutService struct {
Endpoint RolloutEndpoint `yaml:"endpoint"`
Credential RolloutCredential `yaml:"credential"`
}
// RolloutEndpoint is the effective destination used by the application.
type RolloutEndpoint struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
URL string `yaml:"url"`
}
// RolloutCredential holds only a typed secret reference in configuration.
type RolloutCredential struct {
Token configkit.Secret `yaml:"token" configkit:"secret"`
}
// LoadRollout loads a base file, optionally with one selected configuration
// overlay. Resolution derives the effective URL after the overlay is applied.
func LoadRollout(basePath, overlayName string) (*configkit.Result[RolloutConfig], error) {
return configkit.Load(configkit.File(basePath), configkit.Options[RolloutConfig]{
Overlay: overlayName,
Definition: configkit.Definition[RolloutConfig]{
Resolve: resolveRollout,
},
})
}
// CompareRolloutOverlay compares the effective base configuration with that
// same file plus one selected overlay. It presents only safe diff records.
func CompareRolloutOverlay(basePath, overlayName string) (string, error) {
base, err := LoadRollout(basePath, "")
if err != nil {
return "", fmt.Errorf("load base configuration: %w", err)
}
withOverlay, err := LoadRollout(basePath, overlayName)
if err != nil {
return "", fmt.Errorf("load overlay configuration: %w", err)
}
differences, err := configkit.Diff(base, withOverlay)
if err != nil {
return "", err
}
return FormatDifferences(differences), nil
}
func resolveRollout(configuration *RolloutConfig, resolution *configkit.Resolution) error {
endpoint := &configuration.Service.Endpoint
if endpoint.Host == "" || endpoint.Port <= 0 {
return nil
}
url := fmt.Sprintf("https://%s:%d", endpoint.Host, endpoint.Port)
if endpoint.URL != url {
endpoint.URL = url
resolution.Record(configkit.Keys("service", "endpoint", "url"),
configkit.ChangeNormalization, "derived endpoint URL",
configkit.Keys("service", "endpoint", "host"),
configkit.Keys("service", "endpoint", "port"))
}
return nil
}
// FormatDifferences is consumer-owned presentation. It distinguishes absence
// through Present and reports withheld changes through HiddenChanged without
// interpreting redaction marker text.
func FormatDifferences(differences []configkit.Difference) string {
if len(differences) == 0 {
return "No effective changes.\n"
}
var output strings.Builder
for _, difference := range differences {
fmt.Fprintf(&output, "%s: left=%s right=%s", difference.Path.String(),
formatSnapshot(difference.Left), formatSnapshot(difference.Right))
if difference.HiddenChanged {
output.WriteString(" hidden-change")
}
output.WriteByte('\n')
}
return output.String()
}
func formatSnapshot(snapshot configkit.ValueSnapshot) string {
if !snapshot.Present {
return "<absent>"
}
return strconv.Quote(snapshot.YAML)
}

View File

@@ -0,0 +1,187 @@
package consumers_test
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers"
)
func ExampleCompareFiles() {
directory, err := os.MkdirTemp("", "configkit-diff-files-")
if err != nil {
fmt.Println(err)
return
}
defer os.RemoveAll(directory)
leftPath := filepath.Join(directory, "before.yml")
rightPath := filepath.Join(directory, "after.yml")
if err := os.WriteFile(leftPath, []byte("name: before\nentries: {}\n"), 0o600); err != nil {
fmt.Println(err)
return
}
if err := os.WriteFile(rightPath, []byte("name: after\nentries:\n added: null\n"), 0o600); err != nil {
fmt.Println(err)
return
}
output, err := consumers.CompareFiles(leftPath, rightPath)
if err != nil {
fmt.Println(err)
return
}
fmt.Print(output)
// Output:
// /entries/added: left=<absent> right="null\n"
// /name: left="before\n" right="after\n"
}
func ExampleCompareRolloutOverlay() {
const baseToken = "CONFIGKIT_DIFF_EXAMPLE_BASE_TOKEN"
const overlayToken = "CONFIGKIT_DIFF_EXAMPLE_OVERLAY_TOKEN"
defer preserveEnvironment(baseToken)()
defer preserveEnvironment(overlayToken)()
_ = os.Setenv(baseToken, "disposable-example-base-credential")
_ = os.Setenv(overlayToken, "disposable-example-overlay-credential")
directory, err := os.MkdirTemp("", "configkit-diff-overlay-")
if err != nil {
fmt.Println(err)
return
}
defer os.RemoveAll(directory)
basePath := filepath.Join(directory, "config.yml")
overlayPath := filepath.Join(directory, "overlays", "canary.yml")
if err := os.MkdirAll(filepath.Dir(overlayPath), 0o700); err != nil {
fmt.Println(err)
return
}
base := "service:\n endpoint:\n host: base.example\n port: 8443\n credential:\n token: " + baseToken + "\n"
overlay := "service:\n endpoint:\n host: canary.example\n credential:\n token: " + overlayToken + "\n"
if err := os.WriteFile(basePath, []byte(base), 0o600); err != nil {
fmt.Println(err)
return
}
if err := os.WriteFile(overlayPath, []byte(overlay), 0o600); err != nil {
fmt.Println(err)
return
}
output, err := consumers.CompareRolloutOverlay(basePath, "canary")
if err != nil {
fmt.Println(err)
return
}
fmt.Print(output)
// Output:
// /service/credential/token: left="'[REDACTED]'\n" right="'[REDACTED]'\n" hidden-change
// /service/endpoint/host: left="base.example\n" right="canary.example\n"
// /service/endpoint/url: left="https://base.example:8443\n" right="https://canary.example:8443\n"
}
func TestCompareFilesUsesCallerSelectedSourcesAndSafePresence(t *testing.T) {
directory := t.TempDir()
leftPath := filepath.Join(directory, "before.yml")
rightPath := filepath.Join(directory, "after.yml")
writeConfiguration(t, leftPath, "name: before\nentries: {}\n")
writeConfiguration(t, rightPath, "name: after\nentries:\n added: null\n")
output, err := consumers.CompareFiles(leftPath, rightPath)
if err != nil {
t.Fatal(err)
}
want := "/entries/added: left=<absent> right=\"null\\n\"\n" +
"/name: left=\"before\\n\" right=\"after\\n\"\n"
if output != want {
t.Fatalf("comparison output = %q, want %q", output, want)
}
if again, err := consumers.CompareFiles(leftPath, rightPath); err != nil || again != output {
t.Fatalf("repeat output = %q, %v", again, err)
}
}
func TestCompareFilesReturnsEachLoadError(t *testing.T) {
directory := t.TempDir()
leftPath := filepath.Join(directory, "before.yml")
rightPath := filepath.Join(directory, "after.yml")
writeConfiguration(t, leftPath, "name: before\n")
writeConfiguration(t, rightPath, "unknown: invalid\n")
output, err := consumers.CompareFiles(leftPath, rightPath)
if err == nil || output != "" || !strings.Contains(err.Error(), "load right configuration") {
t.Fatalf("right load failure = %q, %v", output, err)
}
if _, err := consumers.CompareFiles(filepath.Join(directory, "missing.yml"), rightPath); err == nil || !strings.Contains(err.Error(), "load left configuration") {
t.Fatalf("left load failure = %v", err)
}
}
func TestCompareRolloutOverlayShowsResolvedSafeChangesAndRetainsSiblings(t *testing.T) {
t.Setenv("CONFIGKIT_DIFF_BASE_TOKEN", "disposable-base-credential")
t.Setenv("CONFIGKIT_DIFF_OVERLAY_TOKEN", "disposable-overlay-credential")
directory := t.TempDir()
basePath := filepath.Join(directory, "config.yml")
overlayPath := filepath.Join(directory, "overlays", "canary.yml")
if err := os.MkdirAll(filepath.Dir(overlayPath), 0o700); err != nil {
t.Fatal(err)
}
writeConfiguration(t, basePath, "service:\n endpoint:\n host: base.example\n port: 8443\n credential:\n token: CONFIGKIT_DIFF_BASE_TOKEN\n")
writeConfiguration(t, overlayPath, "service:\n endpoint:\n host: canary.example\n credential:\n token: CONFIGKIT_DIFF_OVERLAY_TOKEN\n")
base, err := consumers.LoadRollout(basePath, "")
if err != nil {
t.Fatal(err)
}
withOverlay, err := consumers.LoadRollout(basePath, "canary")
if err != nil {
t.Fatal(err)
}
if base.Config.Service.Endpoint.Port != 8443 || withOverlay.Config.Service.Endpoint.Port != 8443 {
t.Fatalf("lower-priority port was not retained: base=%#v overlay=%#v", base.Config.Service.Endpoint, withOverlay.Config.Service.Endpoint)
}
urlInfo, found := withOverlay.Metadata().Lookup(configkit.Keys("service", "endpoint", "url"))
if !found || urlInfo.Origin.Layer != configkit.LayerResolution || withOverlay.Config.Service.Endpoint.URL != "https://canary.example:8443" {
t.Fatalf("resolved endpoint = %#v, metadata=%#v, %v", withOverlay.Config.Service.Endpoint, urlInfo, found)
}
differences, err := configkit.Diff(base, withOverlay)
if err != nil {
t.Fatal(err)
}
if len(differences) != 3 {
t.Fatalf("differences = %#v", differences)
}
wantPaths := []string{"/service/credential/token", "/service/endpoint/host", "/service/endpoint/url"}
for index, difference := range differences {
if got := difference.Path.String(); got != wantPaths[index] {
t.Errorf("path[%d] = %q, want %q", index, got, wantPaths[index])
}
}
secret := differences[0]
if !secret.Left.Redacted || !secret.Right.Redacted || !secret.HiddenChanged || secret.Left.YAML != "'[REDACTED]'\n" || secret.Right.YAML != "'[REDACTED]'\n" {
t.Fatalf("classified credential difference = %#v", secret)
}
if strings.Contains(consumers.FormatDifferences(differences), "disposable-") {
t.Fatal("formatted differences exposed credential contents")
}
output, err := consumers.CompareRolloutOverlay(basePath, "canary")
if err != nil || output != consumers.FormatDifferences(differences) {
t.Fatalf("formatted base/overlay comparison = %q, %v", output, err)
}
}
func TestFormatDifferencesUsesRecordMembershipForNoChange(t *testing.T) {
if got := consumers.FormatDifferences(nil); got != "No effective changes.\n" {
t.Fatalf("no-change output = %q", got)
}
}
func writeConfiguration(t *testing.T, filename, contents string) {
t.Helper()
if err := os.WriteFile(filename, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
}

327
facade_diff_test.go Normal file
View File

@@ -0,0 +1,327 @@
package configkit_test
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
type facadeDiffConfig struct {
Value string `yaml:"value"`
Count int `yaml:"count"`
Items []string `yaml:"items"`
Dynamic map[string]any `yaml:"dynamic"`
}
type facadeDiffSecretConfig struct {
Secret configkit.Secret `yaml:"secret"`
}
func TestFacadeDiffKeepsPublicSequenceChangesWithHistoricalDescendants(t *testing.T) {
type configuration struct {
Items []any `yaml:"items"`
}
load := func(public string) *configkit.Result[configuration] {
t.Helper()
result, err := configkit.Load(configkit.Bytes("historical-sequence.yml", []byte("items: [{secret: disposable-canary}, "+public+"]\n")),
configkit.Options[configuration]{Definition: configkit.Definition[configuration]{
Secret: func(path configkit.Path) bool { return path.String() == "/items/0/secret" },
Resolve: func(current *configuration, resolution *configkit.Resolution) error {
current.Items[0] = "public scalar"
resolution.Record(configkit.Keys("items"), configkit.ChangeNormalization, "normalize first item")
return nil
},
}})
if err != nil {
t.Fatal(err)
}
return result
}
left, right := load("1"), load("2")
history, ok := left.Explain(configkit.Keys("items").Index(0).Key("secret"))
if !ok || history.Effective.Present || len(history.History) == 0 {
t.Fatalf("removed historical secret = %#v, known=%v", history, ok)
}
maskedHistory := false
for _, change := range history.History {
maskedHistory = maskedHistory || change.Before.Redacted || change.After.Redacted
}
if !maskedHistory {
t.Fatal("historical sensitivity was not retained")
}
differences, err := configkit.Diff(left, right)
if err != nil || len(differences) != 1 || differences[0].Path.String() != "/items" {
t.Fatalf("sequence difference = %#v, %v", differences, err)
}
difference := differences[0]
if !difference.Left.Present || !difference.Right.Present || difference.Left.Redacted || difference.Right.Redacted || difference.HiddenChanged {
t.Fatalf("public sequence change was concealed: %#v", difference)
}
if difference.Left.YAML != "- public scalar\n- 1\n" || difference.Right.YAML != "- public scalar\n- 2\n" {
t.Fatalf("public sequence snapshots = %#v", difference)
}
}
func TestFacadeDiffKeepsPublicScalarAcrossHistoricalAbsence(t *testing.T) {
type configuration struct {
Dynamic map[string]any `yaml:"dynamic"`
}
options := configkit.Options[configuration]{Definition: configkit.Definition[configuration]{
Secret: func(path configkit.Path) bool { return path.String() == "/dynamic/entry/token" },
Resolve: func(current *configuration, resolution *configkit.Resolution) error {
delete(current.Dynamic, "entry")
resolution.Record(configkit.Keys("dynamic", "entry"), configkit.ChangeNormalization, "remove entry")
return nil
},
}}
absent, err := configkit.Load(configkit.Bytes("removed-entry.yml", []byte("dynamic: {entry: {token: disposable-canary}}\n")), options)
if err != nil {
t.Fatal(err)
}
options.Definition.Resolve = nil
present, err := configkit.Load(configkit.Bytes("public-entry.yml", []byte("dynamic: {entry: public}\n")), options)
if err != nil {
t.Fatal(err)
}
for _, test := range []struct {
name string
left, right *configkit.Result[configuration]
leftPresent bool
}{
{name: "addition", left: absent, right: present},
{name: "removal", left: present, right: absent, leftPresent: true},
} {
t.Run(test.name, func(t *testing.T) {
differences, err := configkit.Diff(test.left, test.right)
if err != nil || len(differences) != 1 || differences[0].Path.String() != "/dynamic/entry" {
t.Fatalf("entry difference = %#v, %v", differences, err)
}
difference := differences[0]
if difference.Left.Present != test.leftPresent || difference.Right.Present == test.leftPresent ||
difference.Left.Redacted || difference.Right.Redacted || difference.HiddenChanged {
t.Fatalf("public presence change was concealed: %#v", difference)
}
wantLeft, wantRight := "", "public\n"
if test.leftPresent {
wantLeft, wantRight = wantRight, wantLeft
}
if difference.Left.YAML != wantLeft || difference.Right.YAML != wantRight {
t.Fatalf("entry snapshots = %#v", difference)
}
})
}
}
func TestFacadeDiffReturnsDetachedCanonicalRecords(t *testing.T) {
left, err := configkit.Load[facadeDiffConfig](configkit.Bytes("left.yml", []byte("value: before\ncount: 0\nitems: [one, two]\ndynamic: {}\n")))
if err != nil {
t.Fatalf("load left: %v", err)
}
right, err := configkit.Load[facadeDiffConfig](configkit.Bytes("right.yml", []byte("value: after\ncount: 0\nitems: [one, three]\ndynamic:\n added: null\n")))
if err != nil {
t.Fatal(err)
}
differences, err := configkit.Diff(left, right)
if err != nil {
t.Fatal(err)
}
wantPaths := []string{"/dynamic/added", "/items", "/value"}
if len(differences) != len(wantPaths) {
t.Fatalf("differences = %#v", differences)
}
for index, difference := range differences {
if got := difference.Path.String(); got != wantPaths[index] {
t.Errorf("path[%d] = %q, want %q", index, got, wantPaths[index])
}
}
if differences[0].Left.Present || !differences[0].Right.Present || differences[0].Right.YAML != "null\n" {
t.Errorf("null/absence snapshots = %#v / %#v", differences[0].Left, differences[0].Right)
}
if differences[1].Left.YAML != "- one\n- two\n" || differences[1].Right.YAML != "- one\n- three\n" {
t.Errorf("sequence snapshots = %#v / %#v", differences[1].Left, differences[1].Right)
}
left.Config.Value = "caller mutation"
left.Config.Items[0] = "caller mutation"
right.Config.Dynamic["added"] = "caller mutation"
differences[2].Left.YAML = "record mutation"
differences[2].Path = configkit.Keys("rewritten")
again, err := configkit.Diff(left, right)
if err != nil {
t.Fatal(err)
}
if again[2].Path.String() != "/value" || again[2].Left.YAML != "before\n" || again[1].Left.YAML != "- one\n- two\n" {
t.Fatalf("repeat diff changed = %#v", again)
}
explanation, ok := left.Explain(configkit.Keys("value"))
if !ok || explanation.Effective.YAML != "before\n" {
t.Fatalf("frozen explanation = %#v, %v", explanation, ok)
}
}
func TestFacadeDiffRejectsUnavailableResults(t *testing.T) {
type config struct {
Value string `yaml:"value"`
}
loaded, err := configkit.Load[config](configkit.Bytes("valid.yml", []byte("value: valid\n")))
if err != nil {
t.Fatal(err)
}
var nilResult *configkit.Result[config]
zeroResult := &configkit.Result[config]{Config: config{Value: "must not be inspected"}}
for name, left := range map[string]*configkit.Result[config]{"nil": nilResult, "zero": zeroResult} {
t.Run(name, func(t *testing.T) {
if records, err := configkit.Diff(left, loaded); err == nil || records != nil {
t.Fatalf("Diff = %#v, %v; want nil records and error", records, err)
}
})
}
if records, err := configkit.Diff(loaded, nilResult); err == nil || records != nil {
t.Fatalf("right nil Diff = %#v, %v", records, err)
}
}
func TestFacadeDiffIgnoresSourceAndResolutionHistory(t *testing.T) {
type config struct {
Count int `yaml:"count"`
}
defaults := configkit.Options[config]{Definition: configkit.Definition[config]{
Defaults: func() config { return config{Count: 7} },
}}
defaulted, err := configkit.Load[config](configkit.NoSource(), defaults)
if err != nil {
t.Fatal(err)
}
explicit, err := configkit.Load[config](configkit.Bytes("explicit.yml", []byte("count: 7\n")))
if err != nil {
t.Fatal(err)
}
if differences, err := configkit.Diff(defaulted, explicit); err != nil || len(differences) != 0 {
t.Fatalf("default/source difference = %#v, %v", differences, err)
}
zeroDefault, err := configkit.Load[config](configkit.NoSource(), configkit.Options[config]{Definition: configkit.Definition[config]{Defaults: func() config { return config{} }}})
if err != nil {
t.Fatal(err)
}
explicitZero, err := configkit.Load[config](configkit.Bytes("explicit-zero.yml", []byte("count: 0\n")))
if err != nil {
t.Fatal(err)
}
if differences, err := configkit.Diff(zeroDefault, explicitZero); err != nil || len(differences) != 0 {
t.Fatalf("explicit/defaulted zero difference = %#v, %v", differences, err)
}
resolve := func(value string) configkit.Options[config] {
return configkit.Options[config]{Definition: configkit.Definition[config]{Resolve: func(current *config, resolution *configkit.Resolution) error {
current.Count = len(value) + 8
resolution.Record(configkit.Keys("count"), configkit.ChangeNormalization, "set effective count")
return nil
}}}
}
resolvedA, err := configkit.Load[config](configkit.Bytes("a.yml", []byte("{}\n")), resolve("a"))
if err != nil {
t.Fatal(err)
}
resolvedB, err := configkit.Load[config](configkit.Bytes("b.yml", []byte("{}\n")), resolve("b"))
if err != nil {
t.Fatal(err)
}
if differences, err := configkit.Diff(resolvedA, resolvedB); err != nil || len(differences) != 0 {
t.Fatalf("equal resolved values = %#v, %v", differences, err)
}
}
func TestFacadeDiffUsesFrozenFilesAndRunsCallbacksOnlyDuringLoad(t *testing.T) {
type config struct {
Value string `yaml:"value"`
}
calls := 0
options := configkit.Options[config]{Definition: configkit.Definition[config]{Defaults: func() config {
calls++
return config{}
}}}
dir := t.TempDir()
paths := []string{filepath.Join(dir, "left.yml"), filepath.Join(dir, "right.yml")}
for index, contents := range []string{"value: left\n", "value: right\n"} {
if err := os.WriteFile(paths[index], []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
}
left, err := configkit.Load[config](configkit.File(paths[0]), options)
if err != nil {
t.Fatal(err)
}
right, err := configkit.Load[config](configkit.File(paths[1]), options)
if err != nil {
t.Fatal(err)
}
if calls != 2 {
t.Fatalf("defaults calls after loads = %d", calls)
}
for _, path := range paths {
if err := os.Remove(path); err != nil {
t.Fatal(err)
}
}
differences, err := configkit.Diff(left, right)
if err != nil || len(differences) != 1 || differences[0].Path.String() != "/value" {
t.Fatalf("Diff after removing files = %#v, %v", differences, err)
}
if calls != 2 {
t.Fatalf("defaults reran during Diff: %d calls", calls)
}
}
func TestFacadeDiffCombinesSecretSensitivityAndLoadErrors(t *testing.T) {
t.Setenv("FACADE_DIFF_SECRET", "credential-one")
secretOptions := configkit.Options[facadeDiffSecretConfig]{Definition: configkit.Definition[facadeDiffSecretConfig]{Secret: func(path configkit.Path) bool {
return path.String() == "/secret"
}}}
left, err := configkit.Load[facadeDiffSecretConfig](configkit.Bytes("left.yml", []byte("secret: FACADE_DIFF_SECRET\n")), secretOptions)
if err != nil {
t.Fatal(err)
}
t.Setenv("FACADE_DIFF_SECRET", "credential-two")
right, err := configkit.Load[facadeDiffSecretConfig](configkit.Bytes("right.yml", []byte("secret: FACADE_DIFF_SECRET\n")), secretOptions)
if err != nil {
t.Fatal(err)
}
if differences, err := configkit.Diff(left, right); err != nil || len(differences) != 0 {
t.Fatalf("credential rotation = %#v, %v", differences, err)
}
t.Setenv("OTHER_FACADE_DIFF_SECRET", "other-credential")
otherReference, err := configkit.Load[facadeDiffSecretConfig](configkit.Bytes("other.yml", []byte("secret: OTHER_FACADE_DIFF_SECRET\n")), secretOptions)
if err != nil {
t.Fatal(err)
}
differences, err := configkit.Diff(left, otherReference)
if err != nil || len(differences) != 1 || !differences[0].HiddenChanged || !differences[0].Left.Redacted || !differences[0].Right.Redacted {
t.Fatalf("reference change = %#v, %v", differences, err)
}
if strings.Contains(differences[0].Left.YAML+differences[0].Right.YAML, "credential") {
t.Fatalf("credential leaked: %#v", differences)
}
invalidLeft, leftErr := configkit.Load[facadeDiffSecretConfig](configkit.Bytes("invalid-left.yml", []byte("unknown: true\n")))
if invalidLeft != nil || leftErr == nil {
t.Fatalf("invalid left load = %#v, %v", invalidLeft, leftErr)
}
var loadError *configkit.LoadError
if !errors.As(leftErr, &loadError) {
t.Fatalf("left load error = %T, want *configkit.LoadError", leftErr)
}
invalidRight, rightErr := configkit.Load[facadeDiffSecretConfig](configkit.Bytes("invalid-right.yml", []byte("unknown: true\n")))
if invalidRight != nil || rightErr == nil {
t.Fatalf("invalid right load = %#v, %v", invalidRight, rightErr)
}
if !errors.As(rightErr, &loadError) {
t.Fatalf("right load error = %T, want *configkit.LoadError", rightErr)
}
}

98
internal/engine/diff.go Normal file
View File

@@ -0,0 +1,98 @@
package engine
import (
"bytes"
"errors"
"io"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
)
var errDiffInspectionUnavailable = errors.New("configkit: diff inspection unavailable")
var errDiffSnapshotUnavailable = errors.New("configkit: diff snapshot unavailable")
// Difference describes one canonical changed region with safe snapshots.
type Difference struct {
Path Path
Left ValueSnapshot
Right ValueSnapshot
HiddenChanged bool
}
type diffRegion struct {
path Path
left *document.Node
right *document.Node
}
func prepareDiffRegions(left, right *Inspection) ([]diffRegion, error) {
if left == nil || left.final == nil || right == nil || right.final == nil {
return nil, errDiffInspectionUnavailable
}
deltas := compareConfigurationValues(left.final, right.final)
regions := make([]diffRegion, 0, len(deltas))
for _, delta := range deltas {
leftNode, leftPresent := documentAtPathWithLookup(left.final, delta.path, left.immutableLookup)
if !leftPresent {
leftNode = nil
}
rightNode, rightPresent := documentAtPathWithLookup(right.final, delta.path, right.immutableLookup)
if !rightPresent {
rightNode = nil
}
regions = append(regions, diffRegion{path: delta.path, left: leftNode, right: rightNode})
}
return regions, nil
}
// Diff compares the final frozen values retained by two successful inspections.
// Values are serialized only after applying the combined sensitivity of both loads.
func Diff(left, right *Inspection) ([]Difference, error) {
regions, err := prepareDiffRegions(left, right)
if err != nil {
return nil, err
}
sensitivity := newDiffSensitivity(left.secrets, right.secrets)
differences := make([]Difference, 0, len(regions))
for _, region := range regions {
paired, supported := preparePairedDiffSnapshot(region.left, region.right, region.path, sensitivity)
if !supported {
return nil, errDiffSnapshotUnavailable
}
leftSnapshot, err := serializeDiffSnapshot(paired.left, paired.leftRedacted)
if err != nil {
return nil, errDiffSnapshotUnavailable
}
rightSnapshot, err := serializeDiffSnapshot(paired.right, paired.rightRedacted)
if err != nil {
return nil, errDiffSnapshotUnavailable
}
differences = append(differences, Difference{
Path: region.path,
Left: leftSnapshot,
Right: rightSnapshot,
HiddenChanged: paired.hiddenChanged,
})
}
return differences, nil
}
func serializeDiffSnapshot(node *document.Node, redacted bool) (ValueSnapshot, error) {
if node == nil {
return ValueSnapshot{}, nil
}
var output bytes.Buffer
if err := writeDiffDocument(&output, node); err != nil {
return ValueSnapshot{}, errDiffSnapshotUnavailable
}
return ValueSnapshot{Present: true, Redacted: redacted, YAML: output.String()}, nil
}
func writeDiffDocument(output io.Writer, node *document.Node) error {
if err := yamladapter.Write(output, node); err != nil {
return errDiffSnapshotUnavailable
}
return nil
}

View File

@@ -0,0 +1,130 @@
package engine
import (
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
func TestPreparePairedDiffSnapshotReportsWhollyMaskedChanges(t *testing.T) {
left := &document.Node{Kind: document.String, Text: "private-before"}
right := &document.Node{Kind: document.String, Text: "private-after"}
paired, supported := preparePairedDiffSnapshot(
left, right, Keys("credential"), newDiffSensitivity(nil, map[string]bool{"/credential": true}),
)
if !supported || !paired.hiddenChanged || !paired.leftRedacted || !paired.rightRedacted {
t.Fatalf("masked change = %#v, supported=%v", paired, supported)
}
assertDiffStringNode(t, paired.left, "[REDACTED]")
assertDiffStringNode(t, paired.right, "[REDACTED]")
}
func TestPreparePairedDiffSnapshotDetectsRawChangesBehindIdenticalMarkers(t *testing.T) {
left := parseCompositionDocument(t, "left", "token: old-canary\n")
right := parseCompositionDocument(t, "right", "token: new-canary\n")
paired, supported := preparePairedDiffSnapshot(
mappingValue(t, left, "token"), mappingValue(t, right, "token"), Keys("token"),
newDiffSensitivity(map[string]bool{"/token": true}, nil),
)
if !supported || !paired.hiddenChanged || paired.left.Text != paired.right.Text {
t.Fatalf("identically rendered masked values lost raw change: %#v, supported=%v", paired, supported)
}
}
func TestPreparePairedDiffSnapshotCombinesPublicAndHiddenChanges(t *testing.T) {
left := parseCompositionDocument(t, "left", "public: before\ntoken: old\n")
right := parseCompositionDocument(t, "right", "public: after\ntoken: new\n")
paired, supported := preparePairedDiffSnapshot(
left, right, Path{}, newDiffSensitivity(nil, map[string]bool{"/token": true}),
)
if !supported || !paired.hiddenChanged {
t.Fatalf("mixed public and hidden change = %#v, supported=%v", paired, supported)
}
assertDiffStringNode(t, mappingValue(t, paired.left, "public"), "before")
assertDiffStringNode(t, mappingValue(t, paired.right, "public"), "after")
assertDiffStringNode(t, mappingValue(t, paired.left, "token"), "[REDACTED]")
assertDiffStringNode(t, mappingValue(t, paired.right, "token"), "[REDACTED]")
}
func TestPreparePairedDiffSnapshotDoesNotFlagUnchangedMaskedValues(t *testing.T) {
left := parseCompositionDocument(t, "left", "public: before\ntoken: unchanged\n")
right := parseCompositionDocument(t, "right", "public: after\ntoken: unchanged\n")
regions, err := prepareDiffRegions(diffTestInspection(left), diffTestInspection(right))
if err != nil || len(regions) != 1 || regions[0].path.String() != "/public" {
t.Fatalf("changed regions = %#v, err = %v; want only /public", regions, err)
}
paired, supported := preparePairedDiffSnapshot(
left, right, Path{}, newDiffSensitivity(map[string]bool{"/token": true}, nil),
)
if !supported || !paired.leftRedacted || !paired.rightRedacted || paired.hiddenChanged {
t.Fatalf("unchanged masked values affected hidden-change state: %#v, supported=%v", paired, supported)
}
leftSequence := parseCompositionDocument(t, "left-sequence", "items: [unchanged, before]\n")
rightSequence := parseCompositionDocument(t, "right-sequence", "items: [unchanged, after]\n")
sequence, supported := preparePairedDiffSnapshot(
mappingValue(t, leftSequence, "items"), mappingValue(t, rightSequence, "items"), Keys("items"),
newDiffSensitivity(nil, map[string]bool{"/items/0": true}),
)
if !supported || !sequence.leftRedacted || !sequence.rightRedacted || sequence.hiddenChanged {
t.Fatalf("unchanged masked sequence member affected hidden-change state: %#v, supported=%v", sequence, supported)
}
}
func TestPreparePairedDiffSnapshotReportsMaskedAdditionAndRemoval(t *testing.T) {
value := &document.Node{Kind: document.String, Text: "private"}
tests := []struct {
name string
left, right *document.Node
leftHidden bool
rightHidden bool
}{
{name: "addition", right: value, rightHidden: true},
{name: "removal", left: value, leftHidden: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
paired, supported := preparePairedDiffSnapshot(
test.left, test.right, Keys("token"), newDiffSensitivity(nil, map[string]bool{"/token": true}),
)
if !supported || !paired.hiddenChanged || paired.leftRedacted != test.leftHidden || paired.rightRedacted != test.rightHidden {
t.Fatalf("masked presence change = %#v, supported=%v", paired, supported)
}
if test.left == nil && paired.left != nil || test.right == nil && paired.right != nil {
t.Fatalf("absence became a value: %#v", paired)
}
})
}
}
func TestPreparePairedDiffSnapshotReportsHiddenSequenceLengthChange(t *testing.T) {
left := parseCompositionDocument(t, "left", "items: [public]\n")
right := parseCompositionDocument(t, "right", "items: [public, private]\n")
paired, supported := preparePairedDiffSnapshot(
mappingValue(t, left, "items"), mappingValue(t, right, "items"), Keys("items"),
newDiffSensitivity(nil, map[string]bool{"/items/1": true}),
)
if !supported || !paired.hiddenChanged || paired.leftRedacted || !paired.rightRedacted {
t.Fatalf("masked sequence length change = %#v, supported=%v", paired, supported)
}
if len(paired.left.Sequence) != 1 || len(paired.right.Sequence) != 2 {
t.Fatalf("safe sequence lengths = %d, %d; want 1, 2", len(paired.left.Sequence), len(paired.right.Sequence))
}
assertDiffStringNode(t, paired.right.Sequence[1], "[REDACTED]")
}
func TestPreparePairedDiffSnapshotClassificationAloneDoesNotCreateHiddenChange(t *testing.T) {
left := parseCompositionDocument(t, "left", "token: same\n")
right := parseCompositionDocument(t, "right", "token: same\n")
regions, err := prepareDiffRegions(diffTestInspection(left), diffTestInspection(right))
if err != nil || len(regions) != 0 {
t.Fatalf("classification-independent changed regions = %#v, err = %v", regions, err)
}
paired, supported := preparePairedDiffSnapshot(
mappingValue(t, left, "token"), mappingValue(t, right, "token"), Keys("token"),
newDiffSensitivity(map[string]bool{"/token": true}, nil),
)
if !supported || !paired.leftRedacted || !paired.rightRedacted || paired.hiddenChanged {
t.Fatalf("classification alone reported hidden change: %#v, supported=%v", paired, supported)
}
}

View File

@@ -0,0 +1,282 @@
package engine
import (
"bytes"
"errors"
"strings"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
type diffIntegrationConfig struct {
Defaulted string `yaml:"defaulted"`
Dynamic map[string]any `yaml:"dynamic"`
Credential Secret `yaml:"credential" configkit:"secret"`
Derived string `yaml:"derived"`
}
type diffOptionalSecretConfig struct {
Optional *Secret `yaml:"optional" configkit:"secret"`
}
func TestDiffUsesFinalValuesAndDependencyDerivedSensitivity(t *testing.T) {
var resolveCalls int
options := Options[diffIntegrationConfig]{Definition: Definition[diffIntegrationConfig]{
Defaults: func() diffIntegrationConfig {
return diffIntegrationConfig{Defaulted: "from-default", Dynamic: map[string]any{"defaulted": "retained"}}
},
Resolve: func(configuration *diffIntegrationConfig, resolution *Resolution) error {
resolveCalls++
configuration.Derived = configuration.Dynamic["visible"].(string)
resolution.Record(Keys("derived"), ChangeNormalization, "derive from effective values", Keys("credential"))
return nil
},
}}
input := func(value string) Source {
return Bytes("effective.yml", []byte("dynamic: {visible: "+value+"}\ncredential: DIFF_ENGINE_SECRET\n"))
}
t.Setenv("DIFF_ENGINE_SECRET", "credential-left-canary")
left, err := testLoad(input("before"), options)
if err != nil {
t.Fatal(err)
}
t.Setenv("DIFF_ENGINE_SECRET", "credential-right-canary")
right, err := testLoad(input("after"), options)
if err != nil {
t.Fatal(err)
}
if left.Config.Defaulted != "from-default" || right.Config.Defaulted != "from-default" {
t.Fatalf("defaulted values = %q, %q", left.Config.Defaulted, right.Config.Defaulted)
}
if !left.Inspection.secrets["/credential"] || !left.Inspection.secrets["/derived"] ||
!right.Inspection.secrets["/credential"] || !right.Inspection.secrets["/derived"] {
t.Fatalf("typed or dependency-derived sensitivity was not retained: left=%#v right=%#v", left.Inspection.secrets, right.Inspection.secrets)
}
if node, present := documentAtPathWithLookup(left.Inspection.final, Keys("derived"), left.Inspection.immutableLookup); !present || node.Text != "before" {
t.Fatalf("left frozen derived value = %#v, present=%v", node, present)
}
differences, err := Diff(left.Inspection, right.Inspection)
if err != nil {
t.Fatal(err)
}
if len(differences) != 2 || differences[0].Path.String() != "/derived" || differences[1].Path.String() != "/dynamic/visible" {
t.Fatalf("differences = %#v", differences)
}
if !differences[0].Left.Redacted || !differences[0].Right.Redacted || !differences[0].HiddenChanged ||
differences[0].Left.YAML != "'[REDACTED]'\n" || differences[0].Right.YAML != "'[REDACTED]'\n" {
t.Fatalf("dependency-derived difference = %#v", differences[0])
}
if differences[1].Left.Redacted || differences[1].Right.Redacted || differences[1].HiddenChanged ||
differences[1].Left.YAML != "before\n" || differences[1].Right.YAML != "after\n" {
t.Fatalf("public dynamic difference = %#v", differences[1])
}
for _, value := range differences {
if strings.Contains(value.Left.YAML+value.Right.YAML, "credential-") {
t.Fatal("diff snapshot exposed a resolved credential")
}
}
var effective bytes.Buffer
if err := left.Inspection.WriteEffective(&effective); err != nil {
t.Fatal(err)
}
if strings.Contains(effective.String(), "credential-left-canary") {
t.Fatal("standalone effective rendering exposed a credential after diff")
}
explanation, ok := left.Inspection.Explain(Keys("derived"))
if !ok || !explanation.Effective.Redacted || explanation.Effective.YAML != "'[REDACTED]'\n" {
t.Fatalf("explanation changed after diff: %#v, ok=%v", explanation, ok)
}
if _, ok := left.Inspection.Metadata().Lookup(Keys("dynamic", "visible")); !ok {
t.Fatal("metadata lookup failed after diff")
}
if resolveCalls != 2 {
t.Fatalf("resolution callbacks ran during diff: %d calls", resolveCalls)
}
// Returned records can be modified without affecting a later comparison.
differences[0].Path.segments[0].key = "changed-by-caller"
differences[0].Left.YAML = "changed-by-caller"
t.Setenv("DIFF_ENGINE_SECRET", "credential-after-load-canary")
repeated, err := Diff(left.Inspection, right.Inspection)
if err != nil || len(repeated) != 2 || repeated[0].Path.String() != "/derived" || repeated[0].Left.YAML != "'[REDACTED]'\n" {
t.Fatalf("repeated diff = %#v, err = %v", repeated, err)
}
if resolveCalls != 2 {
t.Fatalf("repeated diff reran resolution: %d calls", resolveCalls)
}
independent, err := testLoad(input("before"), options)
if err != nil {
t.Fatal(err)
}
if resolveCalls != 3 {
t.Fatalf("independent load did not retain normal resolution behavior: %d calls", resolveCalls)
}
if _, err := Diff(left.Inspection, independent.Inspection); err != nil {
t.Fatalf("independent load comparison = %v", err)
}
}
func TestDiffUsesReferenceNamesAndFrozenSecretState(t *testing.T) {
loadReference := func(name string) *testResult[diffOptionalSecretConfig] {
t.Helper()
result, err := testLoad[diffOptionalSecretConfig](Bytes("secret-reference.yml", []byte("optional: "+name+"\n")))
if err != nil {
t.Fatal(err)
}
return result
}
t.Setenv("DIFF_ROTATING_SECRET", "first-credential-canary")
left := loadReference("DIFF_ROTATING_SECRET")
t.Setenv("DIFF_ROTATING_SECRET", "second-credential-canary")
right := loadReference("DIFF_ROTATING_SECRET")
t.Setenv("DIFF_ROTATING_SECRET", "changed-after-load-canary")
differences, err := Diff(left.Inspection, right.Inspection)
if err != nil || len(differences) != 0 {
t.Fatalf("credential rotation behind one reference = %#v, err = %v", differences, err)
}
if node, present := documentAtPathWithLookup(left.Inspection.final, Keys("optional"), left.Inspection.immutableLookup); !present || node.Text != "DIFF_ROTATING_SECRET" {
t.Fatalf("frozen reference = %#v, present=%v", node, present)
}
if node, present := documentAtPathWithLookup(right.Inspection.final, Keys("optional"), right.Inspection.immutableLookup); !present || node.Text != "DIFF_ROTATING_SECRET" {
t.Fatalf("right frozen reference = %#v, present=%v", node, present)
}
t.Setenv("DIFF_OTHER_SECRET", "other-credential-canary")
other := loadReference("DIFF_OTHER_SECRET")
differences, err = Diff(left.Inspection, other.Inspection)
if err != nil || len(differences) != 1 || differences[0].Path.String() != "/optional" {
t.Fatalf("reference-name change = %#v, err = %v", differences, err)
}
if !differences[0].Left.Present || !differences[0].Right.Present || !differences[0].Left.Redacted ||
!differences[0].Right.Redacted || !differences[0].HiddenChanged ||
differences[0].Left.YAML != "'[REDACTED]'\n" || differences[0].Right.YAML != "'[REDACTED]'\n" {
t.Fatalf("classified reference-name diff = %#v", differences[0])
}
absent, err := testLoad[diffOptionalSecretConfig](Bytes("absent-secret.yml", []byte("{}\n")))
if err != nil {
t.Fatal(err)
}
if absent.Config.Optional != nil {
t.Fatalf("missing optional reference decoded as %#v", absent.Config.Optional)
}
if node, present := documentAtPathWithLookup(absent.Inspection.final, Keys("optional"), absent.Inspection.immutableLookup); !present || node.Kind != document.Null {
t.Fatalf("optional nil projection = %#v, present=%v", node, present)
}
differences, err = Diff(absent.Inspection, left.Inspection)
if err != nil || len(differences) != 1 || differences[0].Path.String() != "/optional" {
t.Fatalf("optional absence diff = %#v, err = %v", differences, err)
}
if !differences[0].Left.Present || !differences[0].Left.Redacted ||
!differences[0].Right.Present || !differences[0].Right.Redacted || !differences[0].HiddenChanged {
t.Fatalf("optional reference change snapshots = %#v", differences[0])
}
for _, value := range differences {
if strings.Contains(value.Left.YAML+value.Right.YAML, "credential-canary") {
t.Fatal("reference diff exposed a resolved credential")
}
}
}
func TestDiffProtectsHistoricalSensitiveRemovalAndReplacement(t *testing.T) {
type configuration struct {
Dynamic map[string]any `yaml:"dynamic"`
}
secretPath := Keys("dynamic", "token")
secretSelector := func(path Path) bool { return path.String() == secretPath.String() }
load := func(input string, resolve func(*configuration, *Resolution) error) *testResult[configuration] {
t.Helper()
result, err := testLoad(Bytes("historical-secret.yml", []byte(input)), Options[configuration]{Definition: Definition[configuration]{
Secret: secretSelector,
Resolve: resolve,
}})
if err != nil {
t.Fatal(err)
}
return result
}
t.Run("removed historical value", func(t *testing.T) {
left := load("dynamic: {token: removed-canary}\n", func(configuration *configuration, resolution *Resolution) error {
delete(configuration.Dynamic, "token")
resolution.Record(secretPath, ChangeNormalization, "remove token")
return nil
})
right := load("dynamic: {token: current-canary}\n", nil)
if !left.Inspection.secrets[secretPath.String()] {
t.Fatal("removed historical selector was not retained")
}
if _, present := documentAtPathWithLookup(left.Inspection.final, secretPath, left.Inspection.immutableLookup); present {
t.Fatal("removed token remains in the effective left tree")
}
differences, err := Diff(left.Inspection, right.Inspection)
if err != nil || len(differences) != 1 || differences[0].Path.String() != secretPath.String() {
t.Fatalf("historical removal diff = %#v, err = %v", differences, err)
}
if differences[0].Left.Present || !differences[0].Right.Present || !differences[0].Right.Redacted || !differences[0].HiddenChanged {
t.Fatalf("historical removal snapshots = %#v", differences[0])
}
if strings.Contains(differences[0].Left.YAML+differences[0].Right.YAML, "canary") {
t.Fatal("historical removal diff exposed a sensitive value")
}
})
t.Run("replaced historical value", func(t *testing.T) {
left := load("dynamic: {token: original-canary}\n", func(configuration *configuration, resolution *Resolution) error {
configuration.Dynamic["token"] = "replacement-canary"
resolution.Record(secretPath, ChangeNormalization, "replace token")
return nil
})
right := load("dynamic: {token: other-canary}\n", nil)
if !left.Inspection.secrets[secretPath.String()] {
t.Fatal("replaced historical selector was not retained")
}
differences, err := Diff(left.Inspection, right.Inspection)
if err != nil || len(differences) != 1 || differences[0].Path.String() != secretPath.String() {
t.Fatalf("historical replacement diff = %#v, err = %v", differences, err)
}
if !differences[0].Left.Present || !differences[0].Right.Present || !differences[0].Left.Redacted ||
!differences[0].Right.Redacted || !differences[0].HiddenChanged {
t.Fatalf("historical replacement snapshots = %#v", differences[0])
}
if strings.Contains(differences[0].Left.YAML+differences[0].Right.YAML, "canary") {
t.Fatal("historical replacement diff exposed a sensitive value")
}
})
}
func TestDiffReturnsSafeErrorsWithoutRecords(t *testing.T) {
valid := diffTestInspection(&document.Node{Kind: document.String, Text: "safe"})
for _, test := range []struct {
name string
left, right *Inspection
}{
{name: "nil left", right: valid},
{name: "nil right", left: valid},
{name: "missing final tree", left: &Inspection{}, right: valid},
} {
t.Run(test.name, func(t *testing.T) {
differences, err := Diff(test.left, test.right)
if differences != nil || err == nil || err != errDiffInspectionUnavailable || strings.Contains(err.Error(), "safe") {
t.Fatalf("differences, err = %#v, %v", differences, err)
}
})
}
}
func TestDiffSerializationErrorDoesNotExposeWriterDetails(t *testing.T) {
err := writeDiffDocument(diffErrorWriter{}, &document.Node{Kind: document.String, Text: "safe"})
if err != errDiffSnapshotUnavailable || strings.Contains(err.Error(), "CANARY") {
t.Fatalf("serialization error = %v", err)
}
}
type diffErrorWriter struct{}
func (diffErrorWriter) Write([]byte) (int, error) {
return 0, errors.New("CANARY_WRITER_ERROR")
}

View File

@@ -0,0 +1,48 @@
package engine
type diffSensitivity struct {
prefixes pathPrefixIndex
pointers []string
}
func newDiffSensitivity(left, right map[string]bool) *diffSensitivity {
sensitivity := &diffSensitivity{}
seen := make(map[string]struct{}, len(left)+len(right))
for _, classifications := range []map[string]bool{left, right} {
for pointer, classified := range classifications {
if !classified {
continue
}
if _, found := seen[pointer]; found {
continue
}
seen[pointer] = struct{}{}
id := len(sensitivity.pointers)
sensitivity.pointers = append(sensitivity.pointers, pointer)
sensitivity.prefixes.insert(pointer, id)
}
}
return sensitivity
}
func (sensitivity *diffSensitivity) isSensitiveAtOrAbove(pointer string) bool {
if sensitivity == nil {
return false
}
found := false
sensitivity.prefixes.visitAncestors(pointer, func(int) { found = true })
return found
}
func (sensitivity *diffSensitivity) hasStrictDescendant(pointer string) bool {
if sensitivity == nil {
return false
}
found := false
sensitivity.prefixes.visitDescendants(pointer, func(id int) {
if sensitivity.pointers[id] != pointer {
found = true
}
})
return found
}

View File

@@ -0,0 +1,90 @@
package engine
import (
"maps"
"testing"
)
func TestDiffSensitivityCombinesClassificationsWithoutMutation(t *testing.T) {
left := map[string]bool{
"/a": true,
"/a/b": true,
"/historical/removed": true,
"/cross~1key": true,
"/literal~01slash": true,
"/items/01": true,
"/items/+1": false,
"/false-left": false,
}
right := map[string]bool{
"/a/b": true,
"/right-only": true,
"/false-right": false,
}
leftBefore, rightBefore := maps.Clone(left), maps.Clone(right)
sensitivity := newDiffSensitivity(left, right)
for _, test := range []struct {
pointer string
want bool
}{
{pointer: "/a", want: true},
{pointer: "/a/b", want: true},
{pointer: "/a/b/c", want: true},
{pointer: "/ab", want: false},
{pointer: "/ab/c", want: false},
{pointer: "/historical/removed", want: true},
{pointer: "/historical/removed/child", want: true},
{pointer: "/historical", want: false},
{pointer: "/right-only", want: true},
{pointer: "/false-left", want: false},
{pointer: "/false-right", want: false},
{pointer: "/cross~1key", want: true},
{pointer: "/cross/key", want: false},
{pointer: "/literal~01slash", want: true},
{pointer: "/literal~1slash", want: false},
{pointer: "/items/01", want: true},
{pointer: "/items/1", want: false},
{pointer: "/items/+1", want: false},
} {
if got := sensitivity.isSensitiveAtOrAbove(test.pointer); got != test.want {
t.Errorf("isSensitiveAtOrAbove(%q) = %v, want %v", test.pointer, got, test.want)
}
}
for _, test := range []struct {
pointer string
want bool
}{
{pointer: "", want: true},
{pointer: "/a", want: true},
{pointer: "/a/b", want: false},
{pointer: "/ab", want: false},
{pointer: "/historical", want: true},
{pointer: "/right-only", want: false},
{pointer: "/cross~1key", want: false},
} {
if got := sensitivity.hasStrictDescendant(test.pointer); got != test.want {
t.Errorf("hasStrictDescendant(%q) = %v, want %v", test.pointer, got, test.want)
}
}
if !maps.Equal(left, leftBefore) || !maps.Equal(right, rightBefore) {
t.Fatal("classification maps were modified")
}
}
func TestDiffSensitivityDistinguishesRootAndEmptyKey(t *testing.T) {
root := newDiffSensitivity(map[string]bool{"": true}, nil)
if !root.isSensitiveAtOrAbove("") || !root.isSensitiveAtOrAbove("/empty") ||
root.hasStrictDescendant("") {
t.Fatal("root classification did not remain distinct from strict descendants")
}
emptyKey := newDiffSensitivity(map[string]bool{"/": true}, nil)
if emptyKey.isSensitiveAtOrAbove("") || !emptyKey.isSensitiveAtOrAbove("/") ||
emptyKey.isSensitiveAtOrAbove("/other") || !emptyKey.hasStrictDescendant("") ||
emptyKey.hasStrictDescendant("/") {
t.Fatal("empty-key classification was treated as the root or an unrelated path")
}
}

View File

@@ -0,0 +1,195 @@
package engine
import (
"sort"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
type pairedDiffSnapshot struct {
left *document.Node
right *document.Node
leftRedacted bool
rightRedacted bool
requiresFallback bool
hiddenChanged bool
}
func preparePairedDiffSnapshot(left, right *document.Node, path Path, sensitivity *diffSensitivity) (pairedDiffSnapshot, bool) {
paired, supported := preparePairedDiffNodes(left, right, path, sensitivity)
if !supported || !paired.requiresFallback {
return paired, supported
}
return pairedDiffSnapshot{
left: redactedDiffNode(left),
right: redactedDiffNode(right),
leftRedacted: left != nil,
rightRedacted: right != nil,
hiddenChanged: true,
}, true
}
func preparePairedDiffNodes(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,
hiddenChanged: !configurationValuesEqual(left, right),
}, true
}
if left != nil && right != nil && left.Kind != right.Kind {
if sensitivity.hasStrictDescendant(path.String()) {
return pairedDiffSnapshot{requiresFallback: true}, true
}
return pairedDiffSnapshot{left: left, right: right}, true
}
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:
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 = &copy
}
if right != nil {
copy := *right
copy.Mapping = make([]document.Entry, 0, len(right.Mapping))
rightCopy = &copy
}
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 := preparePairedDiffNodes(leftChild, rightChild, path.Key(key), sensitivity)
if !supported {
return pairedDiffSnapshot{}, false
}
if child.requiresFallback {
return pairedDiffSnapshot{requiresFallback: true}, true
}
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.hiddenChanged = redacted.hiddenChanged || child.hiddenChanged
}
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 = &copy
}
if right != nil {
copy := *right
copy.Sequence = make([]*document.Node, len(right.Sequence))
rightCopy = &copy
}
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 := preparePairedDiffNodes(leftChild, rightChild, path.Index(index), sensitivity)
if !supported {
return pairedDiffSnapshot{}, false
}
if child.requiresFallback {
return pairedDiffSnapshot{requiresFallback: true}, true
}
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
result.hiddenChanged = result.hiddenChanged || child.hiddenChanged
}
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]"}
}

View File

@@ -0,0 +1,351 @@
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 TestPreparePairedDiffSnapshotMasksSensitiveStructuralReplacements(t *testing.T) {
tests := []struct {
name string
left, right string
path Path
selector string
}{
{
name: "mapping to scalar",
left: "changed: {token: old-secret, public: old}\n",
right: "changed: new-scalar\n",
path: Keys("changed"),
selector: "/changed/token",
},
{
name: "mapping to scalar with historical-only descendant",
left: "changed: {public: old}\n",
right: "changed: new-scalar\n",
path: Keys("changed"),
selector: "/changed/removed/secret",
},
{
name: "scalar to mapping",
left: "changed: old-scalar\n",
right: "changed: {token: new-secret, public: new}\n",
path: Keys("changed"),
selector: "/changed/token",
},
{
name: "mapping to sequence with numeric paths",
left: "changed: {'0': {token: old-secret}}\n",
right: "changed: [{token: new-secret}]\n",
path: Keys("changed"),
selector: "/changed/0/token",
},
{
name: "sequence to mapping with numeric paths",
left: "changed: [{token: old-secret}]\n",
right: "changed: {'0': {token: new-secret}}\n",
path: Keys("changed"),
selector: "/changed/0/token",
},
{
name: "null to container",
left: "changed: null\n",
right: "changed: {token: new-secret}\n",
path: Keys("changed"),
selector: "/changed/token",
},
{
name: "container to null",
left: "changed: {token: old-secret}\n",
right: "changed: null\n",
path: Keys("changed"),
selector: "/changed/token",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
leftRoot := parseCompositionDocument(t, "left", test.left)
rightRoot := parseCompositionDocument(t, "right", test.right)
left := mappingValue(t, leftRoot, test.path.segments[0].key)
right := mappingValue(t, rightRoot, test.path.segments[0].key)
paired, supported := preparePairedDiffSnapshot(
left, right, test.path, newDiffSensitivity(nil, map[string]bool{test.selector: true}),
)
assertWholeRegionFallback(t, paired, supported, left, right)
})
}
}
func TestPreparePairedDiffSnapshotMasksOriginalRegionForNestedSequenceMismatch(t *testing.T) {
leftRoot := parseCompositionDocument(t, "left", "items: [{branch: {token: old-secret, public: old}}]\n")
rightRoot := parseCompositionDocument(t, "right", "items: [{branch: new-scalar}]\n")
left := mappingValue(t, leftRoot, "items")
right := mappingValue(t, rightRoot, "items")
paired, supported := preparePairedDiffSnapshot(
left, right, Keys("items"), newDiffSensitivity(nil, map[string]bool{"/items/0/branch/token": true}),
)
assertWholeRegionFallback(t, paired, supported, left, right)
}
func TestPreparePairedDiffSnapshotKeepsPublicScalarsWithHistoricalDescendants(t *testing.T) {
leftRoot := parseCompositionDocument(t, "left", "changed: before\n")
rightRoot := parseCompositionDocument(t, "right", "changed: after\n")
left := mappingValue(t, leftRoot, "changed")
right := mappingValue(t, rightRoot, "changed")
paired, supported := preparePairedDiffSnapshot(
left, right, Keys("changed"), newDiffSensitivity(map[string]bool{"/changed/removed/secret": true}, nil),
)
if !supported || paired.leftRedacted || paired.rightRedacted || paired.hiddenChanged {
t.Fatalf("public scalar replacement = %#v, supported=%v", paired, supported)
}
assertDiffStringNode(t, paired.left, "before")
assertDiffStringNode(t, paired.right, "after")
}
func TestPreparePairedDiffSnapshotKeepsPublicContentAcrossAbsence(t *testing.T) {
right := parseCompositionDocument(t, "right", "branch: scalar\n")
paired, supported := preparePairedDiffSnapshot(
nil, right, Keys("added"), newDiffSensitivity(nil, map[string]bool{"/added/branch/secret": true}),
)
if !supported || paired.left != nil || paired.leftRedacted || paired.rightRedacted || paired.hiddenChanged {
t.Fatalf("public addition presence/redaction = %#v, supported=%v", paired, supported)
}
assertDiffStringNode(t, mappingValue(t, paired.right, "branch"), "scalar")
}
func TestPreparePairedDiffSnapshotPreservesPublicReplacementBesideSensitiveSibling(t *testing.T) {
left := parseCompositionDocument(t, "left", "child: before\n")
right := &document.Node{Kind: document.String, Text: "after"}
paired, supported := preparePairedDiffSnapshot(
mappingValue(t, left, "child"), right, Keys("public"),
newDiffSensitivity(nil, map[string]bool{"/private/token": true}),
)
if !supported || paired.left != mappingValue(t, left, "child") || paired.right != right || paired.leftRedacted || paired.rightRedacted {
t.Fatalf("unrelated classification changed public replacement: %#v, supported=%v", paired, supported)
}
}
func TestPreparePairedDiffSnapshotTargetsRedactionAcrossAbsence(t *testing.T) {
left := parseCompositionDocument(t, "left", "items: []\n")
right := parseCompositionDocument(t, "right", "items: [{token: new-secret, public: visible}]\n")
leftItems := mappingValue(t, left, "items")
rightItems := mappingValue(t, right, "items")
paired, supported := preparePairedDiffSnapshot(
leftItems, rightItems, Keys("items"), newDiffSensitivity(nil, map[string]bool{"/items/0/token": true}),
)
if !supported || paired.left == nil || paired.right == nil || paired.leftRedacted || !paired.rightRedacted {
t.Fatalf("targeted sequence redaction = %#v, supported=%v", paired, supported)
}
if len(paired.left.Sequence) != 0 || len(paired.right.Sequence) != 1 {
t.Fatalf("sequence lengths = %d, %d; want 0, 1", len(paired.left.Sequence), len(paired.right.Sequence))
}
assertDiffStringNode(t, mappingValue(t, paired.right.Sequence[0], "token"), "[REDACTED]")
assertDiffStringNode(t, mappingValue(t, paired.right.Sequence[0], "public"), "visible")
}
func assertWholeRegionFallback(t *testing.T, paired pairedDiffSnapshot, supported bool, left, right *document.Node) {
t.Helper()
if !supported {
t.Fatal("sensitive incompatible shape was not handled")
}
if !paired.hiddenChanged {
t.Fatal("fallback for a changed reported region did not mark hidden change")
}
if (left == nil) != (paired.left == nil) || (right == nil) != (paired.right == nil) {
t.Fatalf("fallback changed root presence: %#v", paired)
}
if left != nil {
assertDiffStringNode(t, paired.left, "[REDACTED]")
if !paired.leftRedacted {
t.Fatal("present left region was not marked redacted")
}
}
if right != nil {
assertDiffStringNode(t, paired.right, "[REDACTED]")
if !paired.rightRedacted {
t.Fatal("present right region was not marked redacted")
}
}
}
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)
}
}

View File

@@ -0,0 +1,144 @@
package engine
import (
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
func TestPrepareDiffRegionsUsesCanonicalConfigurationComparison(t *testing.T) {
tests := []struct {
name string
left string
right string
want []expectedDiffRegion
}{
{
name: "mapping order is ignored for equal values",
left: "first: 1\nsecond: true\n",
right: "second: true\nfirst: 1\n",
},
{
name: "addition removal null and empty values",
left: "removed: null\n",
right: "added: null\nempty_map: {}\nempty_sequence: []\n",
want: []expectedDiffRegion{
{path: "/added", rightKind: document.Null, rightPresent: true},
{path: "/empty_map", rightKind: document.Mapping, rightPresent: true},
{path: "/empty_sequence", rightKind: document.Sequence, rightPresent: true},
{path: "/removed", leftKind: document.Null, leftPresent: true},
},
},
{
name: "scalar kinds and atomic sequences",
left: "scalar: 1\nitems: [one, two]\n",
right: "scalar: '1'\nitems: [one, changed]\n",
want: []expectedDiffRegion{
{path: "/items", leftKind: document.Sequence, rightKind: document.Sequence, leftPresent: true, rightPresent: true},
{path: "/scalar", leftKind: document.Number, rightKind: document.String, leftPresent: true, rightPresent: true},
},
},
{
name: "escaped empty and numeric mapping keys sort by pointer",
left: `"": old` + "\n'0': old\n'01': old\n'a/b': old\n'a~b': old\n",
right: `"": new` + "\n'0': new\n'01': new\n'a/b': new\n'a~b': new\n",
want: []expectedDiffRegion{
{path: "/", leftKind: document.String, rightKind: document.String, leftPresent: true, rightPresent: true},
{path: "/0", leftKind: document.String, rightKind: document.String, leftPresent: true, rightPresent: true},
{path: "/01", leftKind: document.String, rightKind: document.String, leftPresent: true, rightPresent: true},
{path: "/a~0b", leftKind: document.String, rightKind: document.String, leftPresent: true, rightPresent: true},
{path: "/a~1b", leftKind: document.String, rightKind: document.String, leftPresent: true, rightPresent: true},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
leftRoot := parseCompositionDocument(t, "left", test.left)
rightRoot := parseCompositionDocument(t, "right", test.right)
regions, err := prepareDiffRegions(diffTestInspection(leftRoot), diffTestInspection(rightRoot))
if err != nil {
t.Fatal(err)
}
if len(regions) != len(test.want) {
t.Fatalf("regions = %#v, want %#v", regions, test.want)
}
for index, want := range test.want {
region := regions[index]
if region.path.String() != want.path {
t.Fatalf("region[%d].path = %q, want %q", index, region.path.String(), want.path)
}
assertDiffRegionNode(t, "left", region.left, want.leftPresent, want.leftKind)
assertDiffRegionNode(t, "right", region.right, want.rightPresent, want.rightKind)
}
})
}
}
func TestPrepareDiffRegionsHandlesRootReplacementAndBorrowsNodes(t *testing.T) {
leftRoot := &document.Node{Kind: document.String, Text: "before"}
rightRoot := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{
Key: "value", Value: &document.Node{Kind: document.Number, Text: "1"},
}}}
regions, err := prepareDiffRegions(diffTestInspection(leftRoot), diffTestInspection(rightRoot))
if err != nil {
t.Fatal(err)
}
if len(regions) != 1 || regions[0].path.String() != "" || regions[0].left != leftRoot || regions[0].right != rightRoot {
t.Fatalf("root region = %#v", regions)
}
leftRoot = parseCompositionDocument(t, "left-child", "value: before\n")
rightRoot = parseCompositionDocument(t, "right-child", "value: after\n")
regions, err = prepareDiffRegions(diffTestInspection(leftRoot), diffTestInspection(rightRoot))
if err != nil {
t.Fatal(err)
}
if len(regions) != 1 || regions[0].left != mappingValue(t, leftRoot, "value") || regions[0].right != mappingValue(t, rightRoot, "value") {
t.Fatalf("child nodes were not borrowed from the frozen trees: %#v", regions)
}
}
func TestPrepareDiffRegionsRejectsUnavailableInspections(t *testing.T) {
valid := diffTestInspection(&document.Node{Kind: document.String, Text: "safe"})
tests := []struct {
name string
left, right *Inspection
}{
{name: "nil left", left: nil, right: valid},
{name: "nil right", left: valid, right: nil},
{name: "missing left tree", left: &Inspection{}, right: valid},
{name: "missing right tree", left: valid, right: &Inspection{}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
regions, err := prepareDiffRegions(test.left, test.right)
if err == nil || regions != nil {
t.Fatalf("regions, err = %#v, %v", regions, err)
}
})
}
}
type expectedDiffRegion struct {
path string
leftKind, rightKind document.Kind
leftPresent, rightPresent bool
}
func diffTestInspection(root *document.Node) *Inspection {
return &Inspection{final: root, immutableLookup: buildDocumentLookup(root)}
}
func assertDiffRegionNode(t *testing.T, side string, node *document.Node, wantPresent bool, wantKind document.Kind) {
t.Helper()
if !wantPresent {
if node != nil {
t.Fatalf("%s node = %#v, want absence", side, node)
}
return
}
if node == nil || node.Kind != wantKind {
t.Fatalf("%s node = %#v, want kind %v", side, node, wantKind)
}
}