From 2f0900535da9924c2147f3b445e9275710c5b3c4 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 24 Oct 2026 15:46:36 +0000 Subject: [PATCH] Select configuration files through host rules --- docs/roadmap/implementation.md | 4 +- internal/engine/discovery.go | 58 ++++++++++ internal/engine/discovery_test.go | 176 ++++++++++++++++++++++++++++++ 3 files changed, 236 insertions(+), 2 deletions(-) diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 5e99870..f635cff 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Configuration Discovery Implementation Plan -Status: in progress. Stages 1 and 2 are complete. +Status: in progress. Stages 1, 2, and 3 are complete. Implement the target behavior in [the discovery roadmap](discovery.md). That document owns the selected conventions and OD-1 through OD-5; this plan owns the @@ -246,7 +246,7 @@ Work is linear in description size plus candidates visited, excluding OS path operations and selected-file processing. No benchmark gate is needed. Run focused discovery tests and the everyday check; mark and commit. -## Stage 3 — Select A Concrete File Through Host Acquisition Rules +## Stage 3 — Select A Concrete File Through Host Acquisition Rules (Complete) 1. Add a private resolver taking an immutable search description and returning the concrete source or safe diagnostics. Non-search sources pass through. diff --git a/internal/engine/discovery.go b/internal/engine/discovery.go index 1e407a8..4c6dd18 100644 --- a/internal/engine/discovery.go +++ b/internal/engine/discovery.go @@ -2,6 +2,7 @@ package engine import ( "errors" + "io/fs" "os" "path/filepath" "runtime" @@ -459,3 +460,60 @@ func filenameGroupAt(group FilenameGroup, index int, application string) string return "" } } + +type discoveryHostProbe struct { + evalSymlinks func(string) (string, error) + stat func(string) (fs.FileInfo, error) +} + +func defaultDiscoveryHostProbe() discoveryHostProbe { + return discoveryHostProbe{evalSymlinks: filepath.EvalSymlinks, stat: os.Stat} +} + +// resolveSearchSource selects one ordinary file source. A nil description leaves +// an existing source untouched; the selected file is read later by readSource. +func resolveSearchSource(source Source, description *Search, application string, required bool, helpers discoveryDirectoryHelpers, probe discoveryHostProbe) (Source, []Diagnostic) { + if description == nil { + return source, nil + } + iterator, diagnostics := newSearchCandidateIterator(*description, application, helpers) + if len(diagnostics) != 0 { + return Source{}, diagnostics + } + defaults := defaultDiscoveryHostProbe() + if probe.evalSymlinks == nil { + probe.evalSymlinks = defaults.evalSymlinks + } + if probe.stat == nil { + probe.stat = defaults.stat + } + for { + candidate, ok, err := iterator.next() + if err != nil { + if errors.Is(err, errUnixSystemPresetUnavailable) { + return Source{}, []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "Unix system directory search is unavailable on this platform")} + } + return Source{}, []Diagnostic{classifySourceError(err, "")} + } + if !ok { + if required { + return Source{}, []Diagnostic{sourceDiagnostic(CodeSourceMissing, "", "required configuration search found no file")} + } + return NoSource(), nil + } + canonical, err := probe.evalSymlinks(candidate) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + continue + } + return Source{}, []Diagnostic{classifySourceError(err, "")} + } + if _, err := probe.stat(canonical); err != nil { + if errors.Is(err, fs.ErrNotExist) { + continue + } + return Source{}, []Diagnostic{classifySourceError(err, "")} + } + return File(candidate), nil + } +} diff --git a/internal/engine/discovery_test.go b/internal/engine/discovery_test.go index d335795..67e42dd 100644 --- a/internal/engine/discovery_test.go +++ b/internal/engine/discovery_test.go @@ -2,8 +2,11 @@ package engine import ( "errors" + "io/fs" + "os" "path/filepath" "runtime" + "strings" "testing" ) @@ -333,3 +336,176 @@ func assertCandidatePaths(t *testing.T, got, want []string) { } } } + +func TestResolveSearchSelectsFirstExistingCandidate(t *testing.T) { + t.Parallel() + + root := t.TempDir() + first := filepath.Join(root, "first.yml") + second := filepath.Join(root, "second.yml") + writeDiscoveryFile(t, first, "value: first\n") + writeDiscoveryFile(t, second, "value: second\n") + selected, diagnostics := resolveSearchSource(Source{}, ptrSearch(Paths(filepath.Join(root, "missing.yml"), first, second)), "", false, discoveryDirectoryHelpers{}, discoveryHostProbe{}) + if len(diagnostics) != 0 || selected.kind != sourceFile || selected.path != first { + t.Fatalf("selection = %#v, diagnostics %#v", selected, diagnostics) + } + read, diagnostics := readSource(selected, "source-1", defaultSourceLimits()) + if len(diagnostics) != 0 || string(read.data) != "value: first\n" || read.info.Path != canonicalTestPath(t, first) { + t.Fatalf("selected source read = %#v, diagnostics %#v", read, diagnostics) + } +} + +func TestResolveSearchNamedExtensionsAndNonregularSelection(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeDiscoveryFile(t, filepath.Join(root, "app.yml"), "value: yml\n") + writeDiscoveryFile(t, filepath.Join(root, "app.yaml"), "value: yaml\n") + selected, diagnostics := resolveSearchSource(Source{}, ptrSearch(SearchDirectory(root)), "app", false, discoveryDirectoryHelpers{}, discoveryHostProbe{}) + if len(diagnostics) != 0 || selected.path != filepath.Join(root, "app.yml") { + t.Fatalf("named extension selection = %#v, %#v", selected, diagnostics) + } + + directory := filepath.Join(root, "subdirectory") + if err := os.Mkdir(directory, 0o700); err != nil { + t.Fatal(err) + } + good := filepath.Join(root, "good.yml") + writeDiscoveryFile(t, good, "value: later\n") + selected, diagnostics = resolveSearchSource(Source{}, ptrSearch(Paths(directory, good)), "", false, discoveryDirectoryHelpers{}, discoveryHostProbe{}) + if len(diagnostics) != 0 || selected.path != directory { + t.Fatalf("directory candidate selection = %#v, %#v", selected, diagnostics) + } + _, diagnostics = readSource(selected, "source-1", defaultSourceLimits()) + if len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceNotRegular { + t.Fatalf("selected directory read diagnostics = %#v", diagnostics) + } +} + +func TestResolveSearchContainedSymlinkAndNoFallbackAfterReadFailure(t *testing.T) { + t.Parallel() + + root := t.TempDir() + target := filepath.Join(root, "actual.yml") + link := filepath.Join(root, "linked.yml") + writeDiscoveryFile(t, target, "value: linked\n") + if err := os.Symlink(target, link); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + writeDiscoveryFile(t, filepath.Join(root, "later.yml"), "value: later\n") + selected, diagnostics := resolveSearchSource(Source{}, ptrSearch(Paths(link, filepath.Join(root, "later.yml"))), "", false, discoveryDirectoryHelpers{}, discoveryHostProbe{}) + if len(diagnostics) != 0 || selected.path != link { + t.Fatalf("symlink selection = %#v, %#v", selected, diagnostics) + } + read, diagnostics := readSource(selected, "source-1", defaultSourceLimits()) + if len(diagnostics) != 0 || string(read.data) != "value: linked\n" || read.info.Path != canonicalTestPath(t, target) { + t.Fatalf("symlink read = %#v, %#v", read, diagnostics) + } + if err := os.Remove(target); err != nil { + t.Fatal(err) + } + _, diagnostics = readSource(selected, "source-1", defaultSourceLimits()) + if len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceMissing { + t.Fatalf("selected source read failure = %#v", diagnostics) + } +} + +func TestResolveSearchRequiredAndOptionalExhaustion(t *testing.T) { + t.Parallel() + + optional, diagnostics := resolveSearchSource(Source{}, ptrSearch(Search{}), "", false, discoveryDirectoryHelpers{}, discoveryHostProbe{}) + if len(diagnostics) != 0 || optional.kind != sourceNone { + t.Fatalf("optional empty search = %#v, %#v", optional, diagnostics) + } + _, diagnostics = resolveSearchSource(Source{}, ptrSearch(Search{}), "", true, discoveryDirectoryHelpers{}, discoveryHostProbe{}) + if len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceMissing || diagnostics[0].Primary != nil || !strings.Contains(diagnostics[0].Message, "search") { + t.Fatalf("required empty search = %#v", diagnostics) + } + + root := t.TempDir() + missing := filepath.Join(root, "missing.yml") + optional, diagnostics = resolveSearchSource(Source{}, ptrSearch(Paths(missing)), "", false, discoveryDirectoryHelpers{}, discoveryHostProbe{}) + if len(diagnostics) != 0 || optional.kind != sourceNone { + t.Fatalf("optional missing search = %#v, %#v", optional, diagnostics) + } + _, diagnostics = resolveSearchSource(Source{}, ptrSearch(Paths(missing)), "", true, discoveryDirectoryHelpers{}, discoveryHostProbe{}) + if len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceMissing { + t.Fatalf("required missing search = %#v", diagnostics) + } +} + +func TestResolveSearchLeavesNonSearchSourcesUntouched(t *testing.T) { + t.Parallel() + + source := Bytes("memory", []byte("value: unchanged\n")) + resolved, diagnostics := resolveSearchSource(source, nil, "", false, discoveryDirectoryHelpers{}, discoveryHostProbe{}) + if len(diagnostics) != 0 || resolved.kind != sourceBytes || string(resolved.data) != "value: unchanged\n" { + t.Fatalf("non-search source = %#v, diagnostics = %#v", resolved, diagnostics) + } +} + +func TestResolveSearchStopsOnReachedProbeError(t *testing.T) { + t.Parallel() + + root := t.TempDir() + first := filepath.Join(root, "first.yml") + second := filepath.Join(root, "second.yml") + writeDiscoveryFile(t, first, "value: first\n") + writeDiscoveryFile(t, second, "value: second\n") + statCalls := 0 + probe := discoveryHostProbe{stat: func(path string) (fs.FileInfo, error) { + statCalls++ + return nil, fs.ErrPermission + }} + _, diagnostics := resolveSearchSource(Source{}, ptrSearch(Paths(first, second)), "", false, discoveryDirectoryHelpers{}, probe) + if len(diagnostics) != 1 || diagnostics[0].Code != CodeSourcePermission || diagnostics[0].Primary != nil || statCalls != 1 { + t.Fatalf("probe failure = %#v, stat calls = %d", diagnostics, statCalls) + } +} + +func TestResolveSearchLeavesLaterDirectoryUnresolvedAfterSelection(t *testing.T) { + t.Parallel() + + root := t.TempDir() + first := filepath.Join(root, "selected.yml") + writeDiscoveryFile(t, first, "value: selected\n") + baseCalls := 0 + helpers := discoveryDirectoryHelpers{userConfigDir: func() (string, error) { + baseCalls++ + return "", errors.New("later base should not be reached") + }} + search := ptrSearch(Searches(Paths(first), UserConfigDirectory())) + selected, diagnostics := resolveSearchSource(Source{}, search, "app", false, helpers, discoveryHostProbe{}) + if len(diagnostics) != 0 || selected.path != first || baseCalls != 0 { + t.Fatalf("selected = %#v, diagnostics = %#v, later base calls = %d", selected, diagnostics, baseCalls) + } +} + +func TestResolveSearchTreatsShellLikeNamesLiterally(t *testing.T) { + t.Parallel() + + root := t.TempDir() + paths := []string{"~/literal.yml", "$CONFIG/literal.yml", "*.yaml"} + for _, name := range paths { + writeDiscoveryFile(t, filepath.Join(root, name), "value: literal\n") + } + helper := discoveryDirectoryHelpers{workingDir: func() (string, error) { return root, nil }} + for _, name := range paths { + selected, diagnostics := resolveSearchSource(Source{}, ptrSearch(Paths(name)), "", false, helper, discoveryHostProbe{}) + if len(diagnostics) != 0 || selected.path != filepath.Join(root, name) { + t.Fatalf("literal %q selection = %#v, %#v", name, selected, diagnostics) + } + } +} + +func ptrSearch(search Search) *Search { return &search } + +func writeDiscoveryFile(t *testing.T, path, contents string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } +}