Files
ConfigKit/internal/engine/discovery.go

462 lines
14 KiB
Go

package engine
import (
"errors"
"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
}
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" {
return validWindowsFilenameComponent(value)
}
return true
}
func validWindowsFilenameComponent(value string) bool {
if strings.ContainsAny(value, `<>:"|?*`) || strings.HasSuffix(value, ".") || strings.HasSuffix(value, " ") {
return false
}
for _, character := range value {
if character < 32 {
return false
}
}
base := strings.ToUpper(strings.TrimSuffix(value, filepath.Ext(value)))
switch base {
case "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "COM¹", "COM²", "COM³", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", "LPT¹", "LPT²", "LPT³":
return false
default:
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 ""
}
}