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 .yml followed by .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 } // Drive-relative and drive-root-relative Windows paths need native // resolution; joining a rooted path to the whole working directory changes it. if filepath.VolumeName(path) != "" || runtime.GOOS == "windows" && len(path) > 0 && os.IsPathSeparator(path[0]) { 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 } }