539 lines
17 KiB
Go
539 lines
17 KiB
Go
package engine
|
|
|
|
import (
|
|
"errors"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
)
|
|
|
|
// Search is an immutable ordered description of possible host-file inputs.
|
|
// It does not access the filesystem until a later loading integration consumes it.
|
|
type Search struct {
|
|
entries []searchEntry
|
|
issues []string
|
|
}
|
|
|
|
// FilenameGroup is an immutable ordered group used by a directory search.
|
|
type FilenameGroup struct {
|
|
kind filenameGroupKind
|
|
names []string
|
|
issues []string
|
|
}
|
|
|
|
// DirectorySearchOptions controls the filename groups used by SearchDirectory.
|
|
// A nil Filenames slice selects the default groups; a non-nil empty slice selects none.
|
|
type DirectorySearchOptions struct {
|
|
Filenames []FilenameGroup
|
|
}
|
|
|
|
// SearchOptions controls whether a later source consumption requires a match.
|
|
type SearchOptions struct {
|
|
Required bool
|
|
}
|
|
|
|
// SearchConfig creates a source that selects the first matching host file when
|
|
// consumed. Description and option errors are reported during loading.
|
|
func SearchConfig(application string, search Search, supplied ...SearchOptions) Source {
|
|
captured := Search{entries: cloneSearchEntries(search.entries), issues: append([]string(nil), search.issues...)}
|
|
source := Source{kind: sourceSearch, search: &captured, searchApp: application}
|
|
if len(supplied) > 1 {
|
|
source.searchIssues = []string{"SearchConfig accepts at most one options value"}
|
|
} else if len(supplied) == 1 {
|
|
source.searchRequired = supplied[0].Required
|
|
}
|
|
return source
|
|
}
|
|
|
|
type filenameGroupKind uint8
|
|
|
|
const (
|
|
filenameGroupEmpty filenameGroupKind = iota
|
|
filenameGroupNamed
|
|
filenameGroupGeneric
|
|
filenameGroupCustom
|
|
)
|
|
|
|
type searchEntryKind uint8
|
|
|
|
const (
|
|
searchEntryPath searchEntryKind = iota + 1
|
|
searchEntryDirectory
|
|
)
|
|
|
|
type directoryBaseKind uint8
|
|
|
|
const (
|
|
directoryBaseLiteral directoryBaseKind = iota
|
|
directoryBaseUnixLocal
|
|
directoryBaseUnixEtc
|
|
directoryBaseUserConfig
|
|
directoryBaseHome
|
|
directoryBaseWorking
|
|
)
|
|
|
|
type searchEntry struct {
|
|
kind searchEntryKind
|
|
path string
|
|
baseKind directoryBaseKind
|
|
directory string
|
|
appendSlug bool
|
|
slugPrefix string
|
|
filenames []FilenameGroup
|
|
}
|
|
|
|
// NamedFilenames selects <application>.yml followed by <application>.yaml.
|
|
func NamedFilenames() FilenameGroup {
|
|
return FilenameGroup{kind: filenameGroupNamed}
|
|
}
|
|
|
|
// GenericFilenames selects config.yml, config.yaml, main.yml, and main.yaml.
|
|
func GenericFilenames() FilenameGroup {
|
|
return FilenameGroup{kind: filenameGroupGeneric}
|
|
}
|
|
|
|
// CustomFilenames selects literal filename components in the supplied order.
|
|
func CustomFilenames(names ...string) FilenameGroup {
|
|
group := FilenameGroup{kind: filenameGroupCustom, names: append([]string(nil), names...)}
|
|
for _, name := range group.names {
|
|
if !validFilenameComponent(name) {
|
|
group.issues = append(group.issues, "custom filename must be a usable host filename component")
|
|
}
|
|
}
|
|
return group
|
|
}
|
|
|
|
// SearchDirectory searches the supplied directory using the selected filename groups.
|
|
// With omitted options or nil Filenames, it selects Named then Generic filenames.
|
|
func SearchDirectory(directory string, supplied ...DirectorySearchOptions) Search {
|
|
search := Search{}
|
|
if len(supplied) > 1 {
|
|
search.issues = append(search.issues, "directory search accepts at most one options value")
|
|
}
|
|
var groups []FilenameGroup
|
|
if len(supplied) == 0 || supplied[0].Filenames == nil {
|
|
groups = []FilenameGroup{NamedFilenames(), GenericFilenames()}
|
|
} else {
|
|
groups = cloneFilenameGroups(supplied[0].Filenames)
|
|
}
|
|
search.entries = append(search.entries, searchEntry{
|
|
kind: searchEntryDirectory, baseKind: directoryBaseLiteral,
|
|
directory: directory, filenames: groups,
|
|
})
|
|
return search
|
|
}
|
|
|
|
// Paths describes literal host-file candidates in the supplied order.
|
|
func Paths(paths ...string) Search {
|
|
search := Search{entries: make([]searchEntry, 0, len(paths))}
|
|
for _, path := range append([]string(nil), paths...) {
|
|
search.entries = append(search.entries, searchEntry{kind: searchEntryPath, path: path})
|
|
}
|
|
return search
|
|
}
|
|
|
|
// Searches concatenates descriptions in order and snapshots their entries.
|
|
func Searches(searches ...Search) Search {
|
|
result := Search{}
|
|
for _, search := range append([]Search(nil), searches...) {
|
|
result.entries = append(result.entries, cloneSearchEntries(search.entries)...)
|
|
result.issues = append(result.issues, search.issues...)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// UnixSystemDirectories describes the conventional Unix system directories.
|
|
func UnixSystemDirectories() Search {
|
|
namedGeneric := []FilenameGroup{NamedFilenames(), GenericFilenames()}
|
|
return Search{entries: []searchEntry{
|
|
{kind: searchEntryDirectory, baseKind: directoryBaseUnixLocal, appendSlug: true, filenames: cloneFilenameGroups(namedGeneric)},
|
|
{kind: searchEntryDirectory, baseKind: directoryBaseUnixEtc, appendSlug: true, filenames: cloneFilenameGroups(namedGeneric)},
|
|
{kind: searchEntryDirectory, baseKind: directoryBaseUnixEtc, filenames: []FilenameGroup{NamedFilenames()}},
|
|
}}
|
|
}
|
|
|
|
// UserConfigDirectory describes the Go user-configuration directory searches.
|
|
func UserConfigDirectory() Search {
|
|
return Search{entries: []searchEntry{
|
|
{kind: searchEntryDirectory, baseKind: directoryBaseUserConfig, appendSlug: true, filenames: defaultFilenameGroups()},
|
|
{kind: searchEntryDirectory, baseKind: directoryBaseUserConfig, filenames: []FilenameGroup{NamedFilenames()}},
|
|
}}
|
|
}
|
|
|
|
// HomeDirectory describes the Go home-directory searches.
|
|
func HomeDirectory() Search {
|
|
return Search{entries: []searchEntry{
|
|
{kind: searchEntryDirectory, baseKind: directoryBaseHome, appendSlug: true, slugPrefix: ".", filenames: defaultFilenameGroups()},
|
|
{kind: searchEntryDirectory, baseKind: directoryBaseHome, filenames: []FilenameGroup{NamedFilenames()}},
|
|
}}
|
|
}
|
|
|
|
// WorkingDirectory describes application-named files in the current directory.
|
|
func WorkingDirectory() Search {
|
|
return Search{entries: []searchEntry{{
|
|
kind: searchEntryDirectory, baseKind: directoryBaseWorking,
|
|
filenames: []FilenameGroup{NamedFilenames()},
|
|
}}}
|
|
}
|
|
|
|
func defaultFilenameGroups() []FilenameGroup {
|
|
return []FilenameGroup{NamedFilenames(), GenericFilenames()}
|
|
}
|
|
|
|
func cloneFilenameGroups(groups []FilenameGroup) []FilenameGroup {
|
|
if groups == nil {
|
|
return nil
|
|
}
|
|
cloned := make([]FilenameGroup, len(groups))
|
|
for i, group := range groups {
|
|
cloned[i] = FilenameGroup{
|
|
kind: group.kind, names: append([]string(nil), group.names...),
|
|
issues: append([]string(nil), group.issues...),
|
|
}
|
|
}
|
|
return cloned
|
|
}
|
|
|
|
func cloneSearchEntries(entries []searchEntry) []searchEntry {
|
|
if entries == nil {
|
|
return nil
|
|
}
|
|
cloned := make([]searchEntry, len(entries))
|
|
for i, entry := range entries {
|
|
cloned[i] = entry
|
|
cloned[i].filenames = cloneFilenameGroups(entry.filenames)
|
|
}
|
|
return cloned
|
|
}
|
|
|
|
func validateSearch(search Search, application string) []Diagnostic {
|
|
var diagnostics []Diagnostic
|
|
for _, issue := range search.issues {
|
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, issue))
|
|
}
|
|
slugNeeded := false
|
|
for _, entry := range search.entries {
|
|
switch entry.kind {
|
|
case searchEntryPath:
|
|
if entry.path == "" {
|
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "search path must not be empty"))
|
|
}
|
|
case searchEntryDirectory:
|
|
if entry.baseKind == directoryBaseLiteral && entry.directory == "" {
|
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "directory search path must not be empty"))
|
|
}
|
|
if entry.appendSlug {
|
|
slugNeeded = true
|
|
}
|
|
for _, group := range entry.filenames {
|
|
switch group.kind {
|
|
case filenameGroupEmpty, filenameGroupGeneric:
|
|
case filenameGroupNamed:
|
|
slugNeeded = true
|
|
case filenameGroupCustom:
|
|
default:
|
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "filename group is invalid"))
|
|
}
|
|
for _, issue := range group.issues {
|
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, issue))
|
|
}
|
|
}
|
|
default:
|
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "search description is invalid"))
|
|
}
|
|
}
|
|
if slugNeeded && !validFilenameComponent(application) {
|
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "application name must be a usable host filename component"))
|
|
}
|
|
return canonicalDiagnostics(diagnostics)
|
|
}
|
|
|
|
func validFilenameComponent(value string) bool {
|
|
if value == "" || value == "." || value == ".." || strings.IndexByte(value, 0) >= 0 {
|
|
return false
|
|
}
|
|
if filepath.IsAbs(value) || filepath.VolumeName(value) != "" || filepath.Base(value) != value {
|
|
return false
|
|
}
|
|
if runtime.GOOS == "windows" {
|
|
if !filepath.IsLocal(value) || strings.ContainsAny(value, `<>:"|?*`) || strings.HasSuffix(value, ".") || strings.HasSuffix(value, " ") {
|
|
return false
|
|
}
|
|
for _, character := range value {
|
|
if character < 32 {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
var errUnixSystemPresetUnavailable = errors.New("Unix system directories are unavailable on this platform")
|
|
|
|
type discoveryDirectoryHelpers struct {
|
|
userConfigDir func() (string, error)
|
|
userHomeDir func() (string, error)
|
|
workingDir func() (string, error)
|
|
unixSupported func() bool
|
|
}
|
|
|
|
type searchCandidateIterator struct {
|
|
search Search
|
|
application string
|
|
helpers discoveryDirectoryHelpers
|
|
entryIndex int
|
|
groupIndex int
|
|
nameIndex int
|
|
directory string
|
|
dirReady bool
|
|
}
|
|
|
|
func newSearchCandidateIterator(search Search, application string, helpers discoveryDirectoryHelpers) (*searchCandidateIterator, []Diagnostic) {
|
|
if diagnostics := validateSearch(search, application); len(diagnostics) != 0 {
|
|
return nil, diagnostics
|
|
}
|
|
defaults := defaultDiscoveryDirectoryHelpers()
|
|
if helpers.userConfigDir == nil {
|
|
helpers.userConfigDir = defaults.userConfigDir
|
|
}
|
|
if helpers.userHomeDir == nil {
|
|
helpers.userHomeDir = defaults.userHomeDir
|
|
}
|
|
if helpers.workingDir == nil {
|
|
helpers.workingDir = defaults.workingDir
|
|
}
|
|
if helpers.unixSupported == nil {
|
|
helpers.unixSupported = defaults.unixSupported
|
|
}
|
|
return &searchCandidateIterator{search: search, application: application, helpers: helpers}, nil
|
|
}
|
|
|
|
func defaultDiscoveryDirectoryHelpers() discoveryDirectoryHelpers {
|
|
return discoveryDirectoryHelpers{
|
|
userConfigDir: os.UserConfigDir,
|
|
userHomeDir: os.UserHomeDir,
|
|
workingDir: os.Getwd,
|
|
unixSupported: unixSystemPresetSupported,
|
|
}
|
|
}
|
|
|
|
// next expands one candidate at a time, resolving only the directory currently
|
|
// reached. Directory filenames are visited before moving to the next directory.
|
|
func (iterator *searchCandidateIterator) next() (string, bool, error) {
|
|
for {
|
|
if iterator.dirReady {
|
|
entry := iterator.search.entries[iterator.entryIndex]
|
|
for iterator.groupIndex < len(entry.filenames) {
|
|
group := entry.filenames[iterator.groupIndex]
|
|
count := filenameGroupLength(group)
|
|
if iterator.nameIndex < count {
|
|
name := filenameGroupAt(group, iterator.nameIndex, iterator.application)
|
|
iterator.nameIndex++
|
|
return filepath.Join(iterator.directory, name), true, nil
|
|
}
|
|
iterator.groupIndex++
|
|
iterator.nameIndex = 0
|
|
}
|
|
iterator.dirReady = false
|
|
iterator.entryIndex++
|
|
}
|
|
if iterator.entryIndex >= len(iterator.search.entries) {
|
|
return "", false, nil
|
|
}
|
|
entry := iterator.search.entries[iterator.entryIndex]
|
|
if entry.kind == searchEntryPath {
|
|
iterator.entryIndex++
|
|
path, err := iterator.absoluteCandidate(entry.path)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
return path, true, nil
|
|
}
|
|
if !directoryHasCandidates(entry.filenames) {
|
|
iterator.entryIndex++
|
|
continue
|
|
}
|
|
directory, err := iterator.resolveDirectory(entry)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
iterator.directory = directory
|
|
iterator.groupIndex = 0
|
|
iterator.nameIndex = 0
|
|
iterator.dirReady = true
|
|
}
|
|
}
|
|
|
|
func (iterator *searchCandidateIterator) absoluteCandidate(path string) (string, error) {
|
|
if filepath.IsAbs(path) {
|
|
return filepath.Clean(path), nil
|
|
}
|
|
if filepath.VolumeName(path) != "" {
|
|
return filepath.Abs(path)
|
|
}
|
|
working, err := iterator.helpers.workingDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Clean(filepath.Join(working, path)), nil
|
|
}
|
|
|
|
func (iterator *searchCandidateIterator) resolveDirectory(entry searchEntry) (string, error) {
|
|
var base string
|
|
switch entry.baseKind {
|
|
case directoryBaseLiteral:
|
|
base = entry.directory
|
|
case directoryBaseUnixLocal:
|
|
if !iterator.helpers.unixSupported() {
|
|
return "", errUnixSystemPresetUnavailable
|
|
}
|
|
base = "/usr/local/etc"
|
|
case directoryBaseUnixEtc:
|
|
if !iterator.helpers.unixSupported() {
|
|
return "", errUnixSystemPresetUnavailable
|
|
}
|
|
base = "/etc"
|
|
case directoryBaseUserConfig:
|
|
var err error
|
|
base, err = iterator.helpers.userConfigDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
case directoryBaseHome:
|
|
var err error
|
|
base, err = iterator.helpers.userHomeDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
case directoryBaseWorking:
|
|
var err error
|
|
base, err = iterator.helpers.workingDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
default:
|
|
return "", errors.New("directory search base is invalid")
|
|
}
|
|
if entry.appendSlug {
|
|
base = filepath.Join(base, entry.slugPrefix+iterator.application)
|
|
}
|
|
if entry.baseKind == directoryBaseLiteral && !filepath.IsAbs(base) {
|
|
return iterator.absoluteCandidate(base)
|
|
}
|
|
return filepath.Clean(base), nil
|
|
}
|
|
|
|
func directoryHasCandidates(groups []FilenameGroup) bool {
|
|
for _, group := range groups {
|
|
if filenameGroupLength(group) != 0 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func filenameGroupLength(group FilenameGroup) int {
|
|
switch group.kind {
|
|
case filenameGroupNamed:
|
|
return 2
|
|
case filenameGroupGeneric:
|
|
return 4
|
|
case filenameGroupCustom:
|
|
return len(group.names)
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func filenameGroupAt(group FilenameGroup, index int, application string) string {
|
|
switch group.kind {
|
|
case filenameGroupNamed:
|
|
if index == 0 {
|
|
return application + ".yml"
|
|
}
|
|
return application + ".yaml"
|
|
case filenameGroupGeneric:
|
|
return [...]string{"config.yml", "config.yaml", "main.yml", "main.yaml"}[index]
|
|
case filenameGroupCustom:
|
|
return group.names[index]
|
|
default:
|
|
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}
|
|
}
|
|
|
|
// resolveSearchInput associates a terminal candidate failure with the same file
|
|
// evidence as ordinary acquisition. Exhaustion and base failures have no file.
|
|
func resolveSearchInput(source Source, sourceID string, helpers discoveryDirectoryHelpers, probe discoveryHostProbe) (Source, *SourceInfo, []Diagnostic) {
|
|
selected, diagnostics := resolveSearchSource(source, source.search, source.searchApp, source.searchRequired, helpers, probe)
|
|
if len(diagnostics) == 0 || selected.kind != sourceFile {
|
|
return selected, nil, diagnostics
|
|
}
|
|
info := &SourceInfo{ID: sourceID, Label: selected.label, Path: selected.path, Layer: LayerBase, Kind: SourceKindFile}
|
|
for index := range diagnostics {
|
|
diagnostics[index].Primary = &Location{SourceID: sourceID}
|
|
}
|
|
return selected, info, diagnostics
|
|
}
|
|
|
|
// resolveSearchSource selects one ordinary file source. A nil description leaves
|
|
// an existing source untouched; the selected file is read later by readSource.
|
|
// A terminal candidate failure returns its file descriptor for error evidence.
|
|
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 File(candidate), []Diagnostic{classifySourceError(err, "")}
|
|
}
|
|
if _, err := probe.stat(canonical); err != nil {
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
continue
|
|
}
|
|
failed := File(candidate)
|
|
failed.path = canonical
|
|
return failed, []Diagnostic{classifySourceError(err, "")}
|
|
}
|
|
return File(candidate), nil
|
|
}
|
|
}
|