Validate source versions in recursive imports

This commit is contained in:
2026-10-15 15:33:40 +00:00
parent 23ae700194
commit 813d6a38c9
6 changed files with 340 additions and 34 deletions

View File

@@ -19,6 +19,17 @@ type sourceVersionNullConfig struct {
Version *string `yaml:"version"`
}
type sourceVersionImportGroup struct {
Name string `yaml:"name"`
}
type sourceVersionImportConfig struct {
Service struct {
Version string `yaml:"version"`
Groups map[string]sourceVersionImportGroup `yaml:"groups"`
} `yaml:"service"`
}
func sourceVersionTestDocument(t *testing.T, input string) *document.Node {
t.Helper()
parsed, diagnostics := parseSource(Bytes("source-version.yml", []byte(input)), "source-version", defaultParseLimits())
@@ -592,6 +603,213 @@ func TestSourceVersionLoadRejectsOptionsBeforeReadingMain(t *testing.T) {
requireSourceVersionDiagnostic(t, loadErr.Diagnostics(), CodeOptionsInvalid, "")
}
func TestSourceVersionImportsCheckNestedAndGlobSourcesBeforeComposition(t *testing.T) {
t.Parallel()
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `version: main
configkit:
imports:
- glob: fragments/*.yml
destination: [groups]
`)
firstPath := writeImportFile(t, directory, "fragments/a.yml", `version: first
configkit:
imports:
- path: nested/child.yml
destination: [nested]
a: {name: A}
`)
nestedPath := writeImportFile(t, directory, "fragments/nested/child.yml", "version: nested\nname: N\n")
secondPath := writeImportFile(t, directory, "fragments/b.yml", "version: second\nb: {name: B}\n")
checked := make(map[string]int)
loaded, err := LoadLayered(Input{
Source: File(mainPath), Imports: true, Destination: Keys("service"),
}, testSecretBinding(), LayeredOptions[sourceVersionImportConfig]{
SourceVersionMode: sourceVersionModeRequired,
Definition: Definition[sourceVersionImportConfig]{Version: &VersionRule{
Path: Keys("version"),
Check: func(version Version, validation *Validation) error {
checked[version.Text]++
validation.Warning(Path{}, "accepted")
return nil
},
}},
})
if err != nil || loaded == nil {
var loadErr *LoadError
if errors.As(err, &loadErr) {
t.Fatalf("source-local import load = %#v, diagnostics %#v", loaded, loadErr.Diagnostics())
}
t.Fatalf("source-local import load = %#v, %v", loaded, err)
}
if loaded.Config.Service.Version != "main" || loaded.Config.Service.Groups["a"].Name != "A" ||
loaded.Config.Service.Groups["b"].Name != "B" || loaded.Config.Service.Groups["nested"].Name != "N" {
t.Fatalf("mounted source-local imports = %#v", loaded.Config.Service)
}
if len(checked) != 4 || checked["main"] != 1 || checked["first"] != 1 || checked["nested"] != 1 || checked["second"] != 1 {
t.Fatalf("checked declarations = %#v, want each independent source once", checked)
}
warnings := testLoaded(loaded).Diagnostics()
if len(warnings) != 4 {
t.Fatalf("accepted source warnings = %#v, want one per checked source", warnings)
}
for _, warning := range warnings {
if warning.Code != CodeVersionWarning || warning.Severity != SeverityWarning {
t.Fatalf("accepted source warning = %#v", warning)
}
}
wantDestinations := map[string]string{
canonicalTestPath(t, mainPath): "/service",
canonicalTestPath(t, firstPath): "/service/groups",
canonicalTestPath(t, secondPath): "/service/groups",
canonicalTestPath(t, nestedPath): "/service/groups/nested",
}
for _, source := range testLoaded(loaded).Sources() {
want, found := wantDestinations[source.Path]
if !found || source.Destination.String() != want {
t.Errorf("source %q destination = %q, want %q", source.Path, source.Destination.String(), want)
}
delete(wantDestinations, source.Path)
}
if len(wantDestinations) != 0 {
t.Errorf("sources not retained: %#v", wantDestinations)
}
}
func TestSourceVersionImportsRejectHiddenAndMissingDeclarations(t *testing.T) {
t.Parallel()
t.Run("rejected child is not hidden by the main declaration", func(t *testing.T) {
t.Parallel()
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "version: main\nconfigkit: {imports: [{path: child.yml}]}\n")
writeImportFile(t, directory, "child.yml", "version: rejected\n")
checks := 0
_, err := Load(File(mainPath), testSecretBinding(), Options[sourceVersionNullConfig]{
Imports: true, SourceVersionMode: sourceVersionModeIfPresent,
Definition: Definition[sourceVersionNullConfig]{Version: &VersionRule{
Path: Keys("version"), Check: func(version Version, _ *Validation) error {
checks++
if version.Text == "rejected" {
return errors.New("rejected")
}
return nil
},
}},
})
var loadErr *LoadError
if !errors.As(err, &loadErr) {
t.Fatalf("rejected child error = %T %v, want *LoadError", err, err)
}
diagnostic := requireSourceVersionDiagnostic(t, loadErr.Diagnostics(), CodeVersionHookFailed, "/version")
if diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-2" || checks != 2 {
t.Fatalf("rejected child diagnostic/callbacks = %#v, %d", diagnostic, checks)
}
})
t.Run("required child omission reports its nearest source location", func(t *testing.T) {
t.Parallel()
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "version: main\nconfigkit: {imports: [{path: child.yml}]}\n")
writeImportFile(t, directory, "child.yml", "name: child\n")
checks := 0
_, err := Load(File(mainPath), testSecretBinding(), Options[sourceVersionNullConfig]{
Imports: true, SourceVersionMode: sourceVersionModeRequired,
Definition: Definition[sourceVersionNullConfig]{Version: &VersionRule{
Path: Keys("version"), Check: func(Version, *Validation) error { checks++; return nil },
}},
})
var loadErr *LoadError
if !errors.As(err, &loadErr) {
t.Fatalf("required child omission = %T %v, want *LoadError", err, err)
}
diagnostic := requireSourceVersionDiagnostic(t, loadErr.Diagnostics(), CodeVersionError, "/version")
if diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-2" || diagnostic.Primary.Line != 1 || checks != 1 {
t.Fatalf("required omission diagnostic/callbacks = %#v, %d", diagnostic, checks)
}
})
t.Run("optional missing child remains absent", func(t *testing.T) {
t.Parallel()
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `version: main
configkit:
imports:
- path: missing.yml
optional: true
`)
checks := 0
loaded, err := Load(File(mainPath), testSecretBinding(), Options[sourceVersionNullConfig]{
Imports: true, SourceVersionMode: sourceVersionModeRequired,
Definition: Definition[sourceVersionNullConfig]{Version: &VersionRule{
Path: Keys("version"), Check: func(Version, *Validation) error { checks++; return nil },
}},
})
if err != nil || loaded == nil || checks != 1 || len(testLoaded(loaded).Sources()) != 1 {
t.Fatalf("optional missing source = %#v, checks %d, %v", loaded, checks, err)
}
})
}
func TestSourceVersionImportAccountingIncludesConsumedHeaderNodes(t *testing.T) {
t.Parallel()
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: child.yml}]}\n")
childPath := writeImportFile(t, directory, "child.yml", "version: accepted\nname: child\n")
main := parseImportMain(t, mainPath)
child, diagnostics := parseSource(File(childPath), "source-child", defaultParseLimits())
if len(diagnostics) != 0 {
t.Fatalf("child parse diagnostics = %#v", diagnostics)
}
rule := &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }}
state := &sourceVersionState{localRule: rule, effectiveRule: rule, mode: sourceVersionModeRequired}
wantNodes := countInputNodes(main.document) + countInputNodes(child.document)
wantBytes := main.bytes + child.bytes
limits := defaultImportLimits()
limits.bytes = wantBytes
limits.nodes = wantNodes
accounting := &importAccounting{}
composed, sources, foundDiagnostics := loadImportedDocumentsTrackedWithVersion(
File(mainPath), nil, main, Path{}, rule, limits, accounting, nil, nil, state,
)
if len(foundDiagnostics) != 0 || composed == nil || len(sources) != 2 || accounting.nodes != wantNodes || accounting.bytes != wantBytes {
t.Fatalf("exact raw byte/node accounting = %#v, %d/%d, want %d/%d, %#v", composed, accounting.bytes, accounting.nodes, wantBytes, wantNodes, foundDiagnostics)
}
if len(state.evidence) != 1 || state.evidence[0].sourceID != "source-2" || !state.evidence[0].checked || state.evidence[0].version.Text != "accepted" {
t.Fatalf("acquired child evidence = %#v", state.evidence)
}
if _, present := documentAtPath(composed.root, Keys("version")); present {
t.Fatalf("consumed child header remains in application document: %#v", composed.root)
}
below := defaultImportLimits()
below.nodes = wantNodes - 1
accounting = &importAccounting{}
composed, _, foundDiagnostics = loadImportedDocumentsTrackedWithVersion(
File(mainPath), nil, main, Path{}, rule, below, accounting, nil, nil,
&sourceVersionState{localRule: rule, effectiveRule: rule, mode: sourceVersionModeRequired},
)
if composed != nil || len(foundDiagnostics) != 1 || foundDiagnostics[0].Code != CodeImportLimitExceeded {
t.Fatalf("raw header nodes were not charged: %#v, %#v", composed, foundDiagnostics)
}
below = defaultImportLimits()
below.bytes = wantBytes - 1
accounting = &importAccounting{}
composed, _, foundDiagnostics = loadImportedDocumentsTrackedWithVersion(
File(mainPath), nil, main, Path{}, rule, below, accounting, nil, nil,
&sourceVersionState{localRule: rule, effectiveRule: rule, mode: sourceVersionModeRequired},
)
if composed != nil || len(foundDiagnostics) != 1 || foundDiagnostics[0].Code != CodeImportLimitExceeded {
t.Fatalf("raw header bytes were not charged: %#v, %#v", composed, foundDiagnostics)
}
}
func mustParseSourceVersionPath(t *testing.T, input string) Path {
t.Helper()
path, err := ParsePath(input)