From 30bcd8eeb6774dd7125d62c5604bc5e3cfc8ad05 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 17 Oct 2026 04:48:25 +0000 Subject: [PATCH] Protect the main version inside item scopes --- docs/roadmap/remediation.md | 7 ++++- facade_source_version_mode_test.go | 41 ++++++++++++++++++++++++++++++ internal/engine/imports.go | 17 ++++++++++++- 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index 95eeab0..440f85c 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -158,7 +158,12 @@ is an unintended restriction beyond the requested feature. ## 5. Keep default-mode main version ownership inside item scopes — P2 -- [ ] Addressed and regression-tested. +- [x] Addressed and regression-tested. + +Completion: default-mode effective protection is translated into the main item's +local scope, preserving segment intent and effective diagnostic paths. Both +public entry points reject helper-supplied main declarations and permit unrelated +version fields. Focused source-version tests and the everyday check passed. **Owner:** the `mainIsItem` branch of [imports.go](../../internal/engine/imports.go), lines 228–235. diff --git a/facade_source_version_mode_test.go b/facade_source_version_mode_test.go index efc260b..9546097 100644 --- a/facade_source_version_mode_test.go +++ b/facade_source_version_mode_test.go @@ -101,6 +101,47 @@ func TestFacadeSourceVersionOwnershipAcceptsContextualIndexes(t *testing.T) { } } +func TestFacadeItemMainKeepsDefaultVersionOwnership(t *testing.T) { + t.Parallel() + for _, layered := range []bool{false, true} { + for _, protected := range []bool{true, false} { + body, helper := "id: main\n", "version: imported-version\n" + if !protected { + body += "version: main-version\n" + helper = "other: {version: permitted}\n" + } + filesystem := configkit.NewFilesystem("item-main", fstest.MapFS{ + "main.yml": {Data: []byte(body + "configkit: {imports: [{path: helper.yml}]}\n")}, "helper.yml": {Data: []byte(helper)}, + }) + checks := 0 + definition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{ + Path: configkit.Keys("records").Index(0).Key("version"), + Check: func(version configkit.Version, _ *configkit.Validation) error { + checks++ + if version.Text != "main-version" { + t.Error("helper supplied main version") + } + return nil + }, + }} + var err error + if layered { + _, err = configkit.LoadLayered[map[string]any](configkit.Input{Source: filesystem.File("main.yml"), Imports: true, Destination: configkit.Keys("records"), PlacementMode: configkit.PlacementModeSequenceItem}, configkit.LayeredOptions[map[string]any]{Definition: definition}) + } else { + _, err = configkit.Load(filesystem.File("main.yml"), configkit.Options[map[string]any]{Definition: definition, Imports: true, Destination: configkit.Keys("records"), PlacementMode: configkit.PlacementModeSequenceItem}) + } + if protected { + loadError, ok := err.(*configkit.LoadError) + if !ok || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != configkit.CodeImportVersion || loadError.Diagnostics()[0].Path.String() != "/records/0/version" || checks != 0 { + t.Fatalf("layered=%t ownership error = %v, checks=%d", layered, err, checks) + } + } else if err != nil || checks != 1 { + t.Fatalf("unrelated helper version rejected: %v, checks=%d", err, checks) + } + } + } +} + type sourceVersionModeReader struct { *strings.Reader reads int diff --git a/internal/engine/imports.go b/internal/engine/imports.go index fd7ad17..1ebb04a 100644 --- a/internal/engine/imports.go +++ b/internal/engine/imports.go @@ -230,7 +230,22 @@ func loadImportedWithAccessTrackedAndProcessingPolicy(access *filesystemAccess, return nil, cloneSourceInfos(state.sources), prependDiagnosticPaths(failures, destination.Index(0)) } if versionState != nil { - if diagnostic := importedVersionClaim(documents, completed, versionState.localRule, 0); diagnostic != nil { + protectionRule := versionState.localRule + if versionState.mode == sourceVersionModeDefault && protectionRule != nil { + prefix := destination.Index(0) + relative, inside := relativeHistoryPath(prefix, protectionRule.Path) + if !inside || !matchesPathShape(Path{segments: protectionRule.Path.segments[:len(prefix.segments)]}, prefix) { + protectionRule = nil + } else { + scoped := *protectionRule + scoped.Path = relative + protectionRule = &scoped + } + } + if diagnostic := importedVersionClaim(documents, completed, protectionRule, 0); diagnostic != nil { + if rule != nil { + diagnostic.Path = rule.Path.clone() + } return nil, cloneSourceInfos(state.sources), []Diagnostic{*diagnostic} } }