From 43c994a32a1ff4ee6036b0b71b59fe65e97f7a8e Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Thu, 15 Oct 2026 16:12:21 +0000 Subject: [PATCH] Publish detached sanitized source evidence --- conversion.go | 24 ++- docs/roadmap/implementation.md | 20 ++- facade_source_version_mode_test.go | 245 +++++++++++++++++++++++++++++ internal/engine/diagnostic.go | 11 +- internal/engine/load.go | 1 + internal/engine/load_input.go | 2 +- internal/engine/load_result.go | 9 +- internal/engine/records.go | 21 +++ internal/engine/source_version.go | 40 +++++ records.go | 24 +++ records_test.go | 26 +++ 11 files changed, 414 insertions(+), 9 deletions(-) diff --git a/conversion.go b/conversion.go index 718146b..2077d12 100644 --- a/conversion.go +++ b/conversion.go @@ -43,10 +43,30 @@ func toEngineLocations(values []Location) []engine.Location { return result } func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo { - return SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Destination: fromEnginePath(value.Destination), Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: fromEngineLocationPointer(value.ImportedFrom)} + return SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Destination: fromEnginePath(value.Destination), Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: fromEngineLocationPointer(value.ImportedFrom), VersionCheck: fromEngineSourceVersionCheck(value.VersionCheck)} } func toEngineSourceInfo(value SourceInfo) engine.SourceInfo { - return engine.SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Destination: toEnginePath(value.Destination), Layer: engine.Layer(value.Layer), Kind: engine.SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: toEngineLocationPointer(value.ImportedFrom)} + return engine.SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Destination: toEnginePath(value.Destination), Layer: engine.Layer(value.Layer), Kind: engine.SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: toEngineLocationPointer(value.ImportedFrom), VersionCheck: toEngineSourceVersionCheck(value.VersionCheck)} +} +func fromEngineSourceVersionCheck(value *engine.SourceVersionCheck) *SourceVersionCheck { + if value == nil { + return nil + } + return &SourceVersionCheck{ + Path: fromEnginePath(value.Path), + Version: Version{Present: value.Version.Present, Null: value.Version.Null, Text: value.Version.Text}, + Location: fromEngineLocationPointer(value.Location), Redacted: value.Redacted, + } +} +func toEngineSourceVersionCheck(value *SourceVersionCheck) *engine.SourceVersionCheck { + if value == nil { + return nil + } + return &engine.SourceVersionCheck{ + Path: toEnginePath(value.Path), + Version: engine.Version{Present: value.Version.Present, Null: value.Version.Null, Text: value.Version.Text}, + Location: toEngineLocationPointer(value.Location), Redacted: value.Redacted, + } } func fromEngineOrigin(value engine.Origin) Origin { result := Origin{Layer: Layer(value.Layer), Locations: fromEngineLocations(value.Locations), Operation: ChangeKind(value.Operation)} diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index d6ad7ce..cb03bcf 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -536,7 +536,7 @@ no metadata, explanation, or Diff paths. These checks passed with Go No environmental limitation affected validation; checks ran on Linux/amd64. -## Stage 11 — Publish detached sanitized source evidence +## Stage 11 — Publish detached sanitized source evidence — Done 1. Add private/public SourceVersionCheck and SourceInfo.VersionCheck records. Extend both conversions and all clone paths, including callback source queries, @@ -555,6 +555,24 @@ Validate: record/conversion/source/version/ownership and public boundary tests, root/engine race checks, and everyday check. Done: existing public source inspection surfaces safely expose detached evidence. +Completion evidence: exposed root-owned and engine-private `SourceVersionCheck` +records through `SourceInfo`, added explicit conversions and deep copies for +paths and locations, and published conservative evidence to resolver queries +and failed loads while retaining final classified evidence in successful +inspection. Covered malformed and early import-acquisition failures, resolver +and validator errors, callback-time canaries, optional absence/null, default +and synthetic CLI records, source removal after load, repeated queries, and +independent mutation of callback, error, conversion, and inspection copies. +These checks passed with Go `go1.26.8 linux/amd64`: + +- `GOTOOLCHAIN=local GOPROXY=off go test ./internal/engine -run 'SourceVersion|SourceInfo|LoadError|Resolution|Validation' -count=1` +- `GOTOOLCHAIN=local GOPROXY=off go test . -run 'SourceVersion|SourceInfo|LoadError|PublicFacadeBoundary|DependencyImportBoundary' -count=1` +- `GOTOOLCHAIN=local GOPROXY=off go test -race ./internal/engine .` +- `GOTOOLCHAIN=local ./scripts/check.sh` +- `git diff --check` + +No environmental limitation affected validation; checks ran on Linux/amd64. + ## Stage 12 — Audit lifecycle agreement and compatibility 1. Map tests to every roadmap participation row/edge decision; add only missing diff --git a/facade_source_version_mode_test.go b/facade_source_version_mode_test.go index 9a7bce0..2c203e1 100644 --- a/facade_source_version_mode_test.go +++ b/facade_source_version_mode_test.go @@ -173,3 +173,248 @@ func TestFacadeSourceVersionDefinitionCanBeReusedWithDifferentModes(t *testing.T t.Fatalf("required absence reached checker: calls = %d", checks) } } + +func TestFacadeSourceVersionEvidenceIsSanitizedAndDetached(t *testing.T) { + t.Parallel() + + const canary = "PRIVATE-SOURCE-VERSION-CANARY" + directory := t.TempDir() + path := filepath.Join(directory, "main.yml") + if err := os.WriteFile(path, []byte("version: "+canary+"\nvalue: visible\n"), 0o600); err != nil { + t.Fatal(err) + } + var callbackSource configkit.SourceInfo + var callbackContext configkit.ResourceContext + definition := configkit.Definition[map[string]any]{ + Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil }}, + Secret: func(candidate configkit.Path) bool { return candidate.String() == "/service/version" }, + Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error { + var found bool + callbackSource, found = resolution.Source("source-1") + if !found { + t.Fatal("resolution callback could not query the acquired source") + } + if callbackSource.VersionCheck == nil || !callbackSource.VersionCheck.Redacted || callbackSource.VersionCheck.Version.Text != "" { + t.Fatalf("callback source did not conservatively redact evidence: %#v", callbackSource.VersionCheck) + } + callbackContext, found = resolution.SourceContext("source-1") + if !found || callbackContext.FS == nil || callbackContext.BaseDir != "." { + t.Fatalf("callback source context = %#v, found=%v", callbackContext, found) + } + return nil + }, + } + loaded, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(path), Destination: configkit.Keys("service")}, configkit.LayeredOptions[map[string]any]{ + Definition: definition, SourceVersionMode: configkit.SourceVersionModeIfPresent, + }) + if err != nil || loaded == nil { + t.Fatalf("LoadLayered = %#v, %v", loaded, err) + } + if callbackSource.VersionCheck == nil || !callbackSource.VersionCheck.Redacted || callbackSource.VersionCheck.Version.Text != "" { + t.Fatalf("callback evidence lost its conservative snapshot: %#v", callbackSource.VersionCheck) + } + callbackSource.VersionCheck.Path = configkit.Keys("mutated-callback") + callbackSource.VersionCheck.Version.Text = canary + callbackSource.VersionCheck.Location.Line = 98 + finalSources := loaded.Sources() + if len(finalSources) != 1 || finalSources[0].Path != path || finalSources[0].VersionCheck == nil { + t.Fatalf("final sources = %#v", finalSources) + } + check := finalSources[0].VersionCheck + if check.Path.String() != "/version" || check.Version != (configkit.Version{Present: true}) || !check.Redacted || + check.Location == nil || check.Location.SourceID != finalSources[0].ID || check.Location.Line != 1 { + t.Fatalf("final classified evidence = %#v", check) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if callbackContext.FS == nil || callbackContext.BaseDir != "." { + t.Fatalf("resource metadata changed after removing the source: %#v", callbackContext) + } + check.Path = configkit.Keys("mutated") + check.Location.Line = 99 + check.Redacted = false + check.Version.Text = "mutated" + repeated := loaded.Sources() + if len(repeated) != 1 || repeated[0].VersionCheck.Path.String() != "/version" || + repeated[0].VersionCheck.Location.Line != 1 || !repeated[0].VersionCheck.Redacted || + repeated[0].VersionCheck.Version.Text != "" { + t.Fatalf("source evidence was aliased or re-read after source removal: %#v", repeated) + } + + publicText := "PUBLIC-SOURCE-VERSION" + var publicCallback configkit.SourceInfo + public, err := configkit.Load(configkit.Bytes("public", []byte("version: "+publicText+"\n")), configkit.Options[map[string]any]{ + SourceVersionMode: configkit.SourceVersionModeIfPresent, + Definition: configkit.Definition[map[string]any]{ + Version: definition.Version, + Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error { + publicCallback, _ = resolution.Source("source-1") + return nil + }, + }, + }) + if err != nil || public == nil || len(public.Sources()) != 1 || public.Sources()[0].VersionCheck == nil { + t.Fatalf("unclassified source evidence = %#v, %v", public, err) + } + if got := public.Sources()[0].VersionCheck; got.Redacted || got.Version.Text != publicText || !got.Version.Present { + t.Fatalf("unclassified evidence = %#v", got) + } + if publicCallback.VersionCheck == nil || !publicCallback.VersionCheck.Redacted || publicCallback.VersionCheck.Version.Text != "" { + t.Fatalf("unclassified callback evidence was not initially conservative: %#v", publicCallback.VersionCheck) + } +} + +func TestFacadeSourceVersionEvidenceOnFailedLoadsAndSyntheticSources(t *testing.T) { + t.Parallel() + + definition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{ + Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil }, + }} + t.Run("early import acquisition failure", func(t *testing.T) { + const canary = "EARLY-ACQUISITION-SOURCE-VERSION-CANARY" + checked := 0 + earlyDefinition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{ + Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { checked++; return nil }, + }} + path := filepath.Join(t.TempDir(), "main.yml") + contents := "version: " + canary + "\nconfigkit:\n imports:\n - path: missing.yml\n" + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + _, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(path), Imports: true}, configkit.LayeredOptions[map[string]any]{ + Definition: earlyDefinition, SourceVersionMode: configkit.SourceVersionModeIfPresent, + }) + var failure *configkit.LoadError + if !errors.As(err, &failure) || len(failure.Sources()) != 1 { + t.Fatalf("early acquisition failure sources = %#v, error %v", failure, err) + } + if checked != 1 { + t.Fatalf("main declaration checker calls = %d; error %v", checked, err) + } + check := failure.Sources()[0].VersionCheck + if check == nil || !check.Redacted || !check.Version.Present || check.Version.Text != "" { + t.Fatalf("early acquisition evidence was not conservatively redacted: %#v; sources %#v", check, failure.Sources()) + } + if strings.Contains(err.Error(), canary) { + t.Fatalf("early acquisition failure disclosed source-version canary: %v", err) + } + }) + _, err := configkit.Load(configkit.Bytes("malformed", []byte("version: true\n")), configkit.Options[map[string]any]{ + Definition: definition, SourceVersionMode: configkit.SourceVersionModeIfPresent, + }) + var loadError *configkit.LoadError + if !errors.As(err, &loadError) { + t.Fatalf("malformed version error = %v", err) + } + sources := loadError.Sources() + if len(sources) != 1 || sources[0].VersionCheck == nil || !sources[0].VersionCheck.Redacted || + !sources[0].VersionCheck.Version.Present || sources[0].VersionCheck.Version.Text != "" || sources[0].VersionCheck.Path.String() != "/version" { + t.Fatalf("failed-load source evidence = %#v", sources) + } + sources[0].VersionCheck.Path = configkit.Keys("mutated-error") + sources[0].VersionCheck.Version.Text = "mutated" + sources[0].VersionCheck.Location.Line = 99 + if again := loadError.Sources()[0].VersionCheck; again.Path.String() != "/version" || again.Version.Text != "" || again.Location.Line == 99 { + t.Fatalf("LoadError source evidence was aliased: %#v", again) + } + for _, test := range []struct { + name string + def configkit.Definition[map[string]any] + canary string + }{ + { + name: "resolver failure", + canary: "RESOLVER-SOURCE-VERSION-CANARY", + def: configkit.Definition[map[string]any]{Version: definition.Version, Resolve: func(*map[string]any, *configkit.Resolution) error { + return errors.New("resolver failed") + }}, + }, + { + name: "validator failure", + canary: "VALIDATOR-SOURCE-VERSION-CANARY", + def: configkit.Definition[map[string]any]{Version: definition.Version, Validate: func(_ *map[string]any, validation *configkit.Validation) { + validation.Error(configkit.Keys("value"), "invalid value") + }}, + }, + } { + t.Run(test.name, func(t *testing.T) { + _, err := configkit.Load(configkit.Bytes("late-failure", []byte("version: "+test.canary+"\nvalue: configured\n")), configkit.Options[map[string]any]{ + Definition: test.def, SourceVersionMode: configkit.SourceVersionModeIfPresent, + }) + var failure *configkit.LoadError + if !errors.As(err, &failure) || len(failure.Sources()) != 1 { + t.Fatalf("late failure sources = %#v, error %v", failure, err) + } + if strings.Contains(err.Error(), test.canary) { + t.Fatalf("late failure disclosed source-version canary: %v", err) + } + check := failure.Sources()[0].VersionCheck + if check == nil || !check.Redacted || !check.Version.Present || check.Version.Text != "" { + t.Fatalf("late-failure evidence was not conservatively redacted: %#v", check) + } + }) + } + + defaultResult, err := configkit.Load(configkit.Bytes("default", []byte("version: 7\n")), configkit.Options[map[string]any]{}) + if err != nil || defaultResult == nil || len(defaultResult.Sources()) != 1 || defaultResult.Sources()[0].VersionCheck != nil { + t.Fatalf("default source version check = %#v, %v", defaultResult, err) + } + + withCLI, err := configkit.Load(configkit.Bytes("main", []byte("version: 8\nvalue: old\n")), configkit.Options[map[string]any]{ + Definition: definition, SourceVersionMode: configkit.SourceVersionModeIfPresent, + Overrides: []configkit.Override{{Path: configkit.Keys("value"), Value: "new", Flag: "--value"}}, + }) + if err != nil || withCLI == nil { + t.Fatalf("enabled load with CLI source = %#v, %v", withCLI, err) + } + sawCLI := false + for _, source := range withCLI.Sources() { + if source.Kind == configkit.SourceKindCLI && source.VersionCheck != nil { + t.Fatalf("synthetic CLI source acquired declaration evidence: %#v", source) + } + sawCLI = sawCLI || source.Kind == configkit.SourceKindCLI + } + if !sawCLI { + t.Fatalf("CLI source record missing: %#v", withCLI.Sources()) + } +} + +func TestFacadeSourceVersionEvidencePreservesAbsenceAndNull(t *testing.T) { + t.Parallel() + + var checked []configkit.Version + loaded, err := configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte("version: 17\n"))}, configkit.LayeredOptions[map[string]any]{ + SourceVersionMode: configkit.SourceVersionModeIfPresent, + Definition: configkit.Definition[map[string]any]{Version: &configkit.VersionRule{ + Path: configkit.Keys("version"), + Check: func(version configkit.Version, _ *configkit.Validation) error { + checked = append(checked, version) + return nil + }, + }}, + Underlays: []configkit.Underlay{{Inputs: []configkit.Input{ + {Source: configkit.Bytes("absent", []byte("value: visible\n")), Destination: configkit.Keys("absent")}, + {Source: configkit.Bytes("null", []byte("version: null\nvalue: visible\n")), Destination: configkit.Keys("nullable")}, + }}}, + }) + if err != nil || loaded == nil { + t.Fatalf("LoadLayered = %#v, %v", loaded, err) + } + if len(checked) != 2 || !checked[0].Present || checked[0].Text != "17" || + !checked[1].Present || !checked[1].Null { + t.Fatalf("checker calls = %#v; absent contribution must skip while null is checked", checked) + } + sources := loaded.Sources() + if len(sources) != 3 || sources[1].VersionCheck == nil || sources[2].VersionCheck == nil { + t.Fatalf("source version evidence = %#v", sources) + } + absent := sources[1].VersionCheck + if absent.Version.Present || absent.Path.String() != "/version" || absent.Location == nil || absent.Redacted { + t.Fatalf("optional omission evidence = %#v", absent) + } + null := sources[2].VersionCheck + if !null.Version.Present || !null.Version.Null || null.Version.Text != "" || null.Path.String() != "/version" { + t.Fatalf("null declaration evidence = %#v", null) + } +} diff --git a/internal/engine/diagnostic.go b/internal/engine/diagnostic.go index a77d846..4e78a02 100644 --- a/internal/engine/diagnostic.go +++ b/internal/engine/diagnostic.go @@ -63,8 +63,9 @@ func WriteDiagnostics(output io.Writer, diagnostics []Diagnostic) error { } type diagnosticAccumulator struct { - diagnostics []Diagnostic - sources []SourceInfo + diagnostics []Diagnostic + sources []SourceInfo + versionState *sourceVersionState } func (a *diagnosticAccumulator) add(diagnostics ...Diagnostic) { @@ -74,9 +75,13 @@ func (a *diagnosticAccumulator) add(diagnostics ...Diagnostic) { } func (a *diagnosticAccumulator) loadError() *LoadError { + sources := a.sources + if a.versionState != nil { + sources = sourceInfosWithVersionEvidence(sources, a.versionState.evidence, nil, true) + } return &LoadError{ diagnostics: canonicalDiagnostics(a.diagnostics), - sources: cloneSourceInfos(a.sources), + sources: cloneSourceInfos(sources), } } diff --git a/internal/engine/load.go b/internal/engine/load.go index 6758734..7886127 100644 --- a/internal/engine/load.go +++ b/internal/engine/load.go @@ -173,6 +173,7 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo versionState := &sourceVersionState{ localRule: request.definition.Version, effectiveRule: request.protectionVersion(), mode: request.sourceVersionMode, } + accumulator.versionState = versionState input, diagnostics := assembleBaseInputTrackedWithVersion(request.main.source, mainOptions, request.main.destination, accounting, nil, nil, versionState) accumulator.sources = input.sources accumulator.add(versionState.diagnostics...) diff --git a/internal/engine/load_input.go b/internal/engine/load_input.go index 94d1c56..e46ee51 100644 --- a/internal/engine/load_input.go +++ b/internal/engine/load_input.go @@ -37,7 +37,7 @@ func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], d if parsed.source != nil { parsed.source.Destination = destination.clone() } - input := assembledInput{parsed: parsed, document: parsed.document, versionState: versionState} + input := assembledInput{parsed: parsed, document: parsed.document, sources: sourceInfos(parsed.source), versionState: versionState} if len(diagnostics) != 0 { input.sources = sourceInfos(parsed.source) // Mapping value issues already identify application content. Other early diff --git a/internal/engine/load_result.go b/internal/engine/load_result.go index 55cebfd..6af60a1 100644 --- a/internal/engine/load_result.go +++ b/internal/engine/load_result.go @@ -58,7 +58,11 @@ func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T], final := decoded.final var resolutionSnapshots []resolutionSnapshot if definition.Resolve != nil { - entry := newResolutionInput(metadata, decoded.final, input.sources, input.contexts) + sources := input.sources + if input.versionState != nil { + sources = sourceInfosWithVersionEvidence(sources, input.versionState.evidence, nil, true) + } + entry := newResolutionInput(metadata, decoded.final, sources, input.contexts) resolved := runResolution(&configuration, definition.Resolve, schema, decoded, input.document, entry) diagnostics = append(diagnostics, resolved.diagnostics...) if hasErrors(resolved.diagnostics) { @@ -103,6 +107,7 @@ func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T] final, applications, resolutionSnapshots := resolved.final, resolved.applications, resolved.snapshots finalizeMetadataPaths(&metadata, final, applications, decoded.baseline, decoded.final) secrets := classifySecretPathsWithEvidence(schema, definition.Secret, metadata, final, applications, resolved.versionEvidence, decoded.baseline, decoded.final) + sources := sourceInfosWithVersionEvidence(accumulator.sources, resolved.versionEvidence, secrets, false) retainedTrees := make([]*document.Node, 0, len(applications)+2) retainedTrees = append(retainedTrees, decoded.final, final) for _, application := range applications { @@ -110,7 +115,7 @@ func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T] } inspection := &Inspection{ metadata: metadata, - sources: cloneSourceInfos(accumulator.sources), + sources: sources, contexts: cloneResourceContexts(resolved.contexts), diagnostics: canonicalDiagnostics(accumulator.diagnostics), baseline: decoded.baseline, diff --git a/internal/engine/records.go b/internal/engine/records.go index 85092d9..e4ccca0 100644 --- a/internal/engine/records.go +++ b/internal/engine/records.go @@ -191,9 +191,19 @@ type SourceInfo struct { Kind SourceKind UnderlayIndex int ImportedFrom *Location + VersionCheck *SourceVersionCheck namespace *namespaceToken } +// SourceVersionCheck is a detached, public-safe record of a source-local +// version declaration observed during an enabled load. +type SourceVersionCheck struct { + Path Path + Version Version + Location *Location + Redacted bool +} + // Diagnostic is a safe structured problem report. Message and source labels must // never contain configuration values or other secrets. type Diagnostic struct { @@ -262,9 +272,20 @@ func cloneLocations(locations []Location) []Location { func cloneSourceInfo(source SourceInfo) SourceInfo { source.ImportedFrom = cloneLocation(source.ImportedFrom) source.Destination = source.Destination.clone() + source.VersionCheck = cloneSourceVersionCheck(source.VersionCheck) return source } +func cloneSourceVersionCheck(value *SourceVersionCheck) *SourceVersionCheck { + if value == nil { + return nil + } + cloned := *value + cloned.Path = value.Path.clone() + cloned.Location = cloneLocation(value.Location) + return &cloned +} + func cloneSourceInfos(sources []SourceInfo) []SourceInfo { if sources == nil { return nil diff --git a/internal/engine/source_version.go b/internal/engine/source_version.go index 792aefd..ad6e71c 100644 --- a/internal/engine/source_version.go +++ b/internal/engine/source_version.go @@ -51,6 +51,46 @@ type sourceVersionState struct { diagnostics []Diagnostic } +func sourceInfosWithVersionEvidence(sources []SourceInfo, evidence []*sourceVersionEvidence, secrets map[string]bool, conservative bool) []SourceInfo { + result := cloneSourceInfos(sources) + if len(evidence) == 0 { + return result + } + sensitivity := newDiffSensitivity(secrets, nil) + for _, item := range evidence { + if item == nil || item.sourceID == "" { + continue + } + for index := range result { + if result[index].ID == item.sourceID { + result[index].VersionCheck = sourceVersionCheckForEvidence(item, sensitivity, conservative) + break + } + } + } + return result +} + +func sourceVersionCheckForEvidence(evidence *sourceVersionEvidence, sensitivity *diffSensitivity, conservative bool) *SourceVersionCheck { + path := evidence.resolvedPath + if !path.valid() { + path = evidence.path + } + version := evidence.version + redacted := evidence.redacted + if conservative { + redacted = redacted || version.Text != "" + } else if sensitivity.isSensitiveAtOrAbove(appendDestination(evidence.destination, path).String()) { + redacted = true + } + if redacted { + version.Text = "" + } + return &SourceVersionCheck{ + Path: path.clone(), Version: version, Location: cloneLocation(evidence.location), Redacted: redacted, + } +} + func (state *sourceVersionState) process(root *document.Node, role sourceVersionRole, destination Path, sourceID string) (sourceVersionProcessingResult, []Diagnostic) { if state == nil || state.mode == sourceVersionModeDefault { return sourceVersionProcessingResult{}, nil diff --git a/records.go b/records.go index ebfc9a0..c840e26 100644 --- a/records.go +++ b/records.go @@ -197,6 +197,19 @@ type SourceInfo struct { Kind SourceKind UnderlayIndex int ImportedFrom *Location + // VersionCheck is the sanitized source-local version declaration observed + // during an enabled source-version load. It is nil otherwise. + VersionCheck *SourceVersionCheck +} + +// SourceVersionCheck describes a source-local version declaration observed +// during an enabled load. Text is empty when classification or conservative +// early-access redaction withholds it; Present and Null retain their meaning. +type SourceVersionCheck struct { + Path Path + Version Version + Location *Location + Redacted bool } // Diagnostic is a safe structured problem report. Message and source labels must @@ -267,9 +280,20 @@ func cloneLocations(locations []Location) []Location { func cloneSourceInfo(source SourceInfo) SourceInfo { source.Destination = source.Destination.clone() source.ImportedFrom = cloneLocation(source.ImportedFrom) + source.VersionCheck = cloneSourceVersionCheck(source.VersionCheck) return source } +func cloneSourceVersionCheck(value *SourceVersionCheck) *SourceVersionCheck { + if value == nil { + return nil + } + cloned := *value + cloned.Path = value.Path.clone() + cloned.Location = cloneLocation(value.Location) + return &cloned +} + func cloneSourceInfos(sources []SourceInfo) []SourceInfo { if sources == nil { return nil diff --git a/records_test.go b/records_test.go index 3f4c7f8..8f0ee42 100644 --- a/records_test.go +++ b/records_test.go @@ -38,3 +38,29 @@ func TestSourceInfoDestinationConversionsAndCopies(t *testing.T) { t.Fatalf("source record destination changed through clone: %q", converted.Destination.String()) } } + +func TestSourceVersionCheckConversionsAndCopies(t *testing.T) { + path := Keys("records").Index(2).Key("version") + source := SourceInfo{ + ID: "source-1", + VersionCheck: &SourceVersionCheck{ + Path: path, Version: Version{Present: true, Text: "123"}, + Location: &Location{SourceID: "source-1", Line: 4, Column: 8}, + }, + } + internal := toEngineSourceInfo(source) + if internal.VersionCheck == nil || internal.VersionCheck.Path.String() != path.String() || + internal.VersionCheck.Version.Text != "123" || internal.VersionCheck.Location == nil || internal.VersionCheck.Location.Line != 4 { + t.Fatalf("engine source version check = %#v", internal.VersionCheck) + } + converted := fromEngineSourceInfo(internal) + copy := cloneSourceInfo(converted) + copy.VersionCheck.Path = Keys("changed") + copy.VersionCheck.Version.Text = "changed" + copy.VersionCheck.Location.Line = 99 + copy.VersionCheck.Redacted = true + if converted.VersionCheck.Path.String() != path.String() || converted.VersionCheck.Version.Text != "123" || + converted.VersionCheck.Location.Line != 4 || converted.VersionCheck.Redacted { + t.Fatalf("source version check changed through a detached copy: %#v", converted.VersionCheck) + } +}