Files
ConfigKit/internal/engine/glob.go

284 lines
10 KiB
Go

package engine
import (
"errors"
"io/fs"
"path"
"slices"
"sort"
"strings"
)
const globReadBatch = 128
// selectGlobImportCandidates expands path components while retaining the
// lexical spelling used for filtering and later source admission.
func selectGlobImportCandidates(access *filesystemAccess, basePath string, spec importDeclarationSpec, policy resolvedImportPolicy, limits importLimits, accounting *importAccounting) (importSelection, []Diagnostic) {
site := spec.fieldSites["glob"]
segments := spec.globSegments
if len(segments) == 0 {
var valid bool
segments, valid = validGlobPattern(spec.glob)
if !valid {
return importSelection{}, []Diagnostic{*importSelectionDiagnostic(site, CodeImportInvalid, "import glob pattern is invalid")}
}
}
if accounting == nil {
accounting = &importAccounting{}
}
rootPath := normalizedImportCandidatePath(access, basePath)
candidates := []selectedImportCandidate{{path: rootPath, accessPath: basePath, site: site}}
for index, segment := range segments {
last := index == len(segments)-1
literal, patterned := globLiteralSegment(segment)
// nextByPath is the set union of candidates reached from each matching
// parent at this segment; final exclusions below are set difference.
nextByPath := make(map[string]selectedImportCandidate)
for _, parent := range candidates {
if !patterned {
childPath := access.join(parent.path, literal)
childAccessPath := access.join(parent.accessPath, literal)
candidate, missing, diagnostics := explicitGlobCandidate(access, basePath, childPath, childAccessPath, site, last)
if len(diagnostics) != 0 {
return importSelection{}, diagnostics
}
if !missing {
nextByPath[candidate.path] = candidate
}
continue
}
entries, missing, diagnostics := readGlobEntries(access, parent.accessPath, site.location, limits, accounting)
if len(diagnostics) != 0 {
return importSelection{}, diagnostics
}
if missing {
continue
}
for _, entry := range entries {
matched, err := path.Match(segment, entry.Name())
if err != nil {
return importSelection{}, []Diagnostic{*importSelectionDiagnostic(site, CodeImportInvalid, "import glob pattern is invalid")}
}
if !matched || !policy.hidden && strings.HasPrefix(entry.Name(), ".") {
continue
}
accessPath := access.join(parent.accessPath, entry.Name())
lexicalPath := access.join(parent.path, entry.Name())
if !access.contains(accessPath) || !access.contains(lexicalPath) {
return importSelection{}, []Diagnostic{*importSelectionDiagnostic(site, CodeImportOutsideRoot, "glob traversal is outside the configuration root")}
}
target, err := access.importEntryTarget(accessPath, entry, policy.follow)
if err != nil {
return importSelection{}, []Diagnostic{globCandidateDiagnostic(err, site.location, "glob match could not be inspected")}
}
if target.symlink && !policy.follow {
continue
}
if !last && !target.dir {
continue
}
physicalPath := target.identity
// Native source identity and relative imports use the namespace
// spelling; resolved link identities belong only to discovery.
if access.native != nil {
physicalPath = accessPath
}
if physicalPath == "" {
physicalPath, err = access.canonical(accessPath)
if err != nil {
return importSelection{}, []Diagnostic{globFilesystemDiagnostic(err, "glob match could not be canonicalized", site.location)}
}
}
candidate := selectedImportCandidate{
path: normalizedImportCandidatePath(access, lexicalPath), accessPath: physicalPath,
label: relativeImportLabel(access, basePath, lexicalPath), site: site,
}
nextByPath[candidate.path] = candidate
}
}
candidates = candidatesFromMap(nextByPath)
if len(candidates) == 0 {
break
}
}
selected := make([]selectedImportCandidate, 0, len(candidates))
for _, candidate := range candidates {
missing, _, diagnostic := validateNamedImportTarget(access, candidate, false, false)
if diagnostic != nil {
return importSelection{}, []Diagnostic{*diagnostic}
}
if missing || excludedBasename(path.Base(candidate.path), policy.exclude) {
continue
}
selected = append(selected, candidate)
}
if policy.required && len(selected) == 0 {
return importSelection{}, []Diagnostic{*importSelectionDiagnostic(site, CodeImportMissing, "required import glob selected no files")}
}
// Source allowance is charged by source admission, after this filtered set
// reaches the traversal; that preserves repeated-source diagnostics first.
selection := importSelection{candidates: selected}
return selection, nil
}
func explicitGlobCandidate(access *filesystemAccess, basePath, candidatePath, accessPath string, site importFieldSite, last bool) (selectedImportCandidate, bool, []Diagnostic) {
if !access.contains(candidatePath) || !access.contains(accessPath) {
return selectedImportCandidate{}, false, []Diagnostic{*importSelectionDiagnostic(site, CodeImportOutsideRoot, "glob traversal is outside the configuration root")}
}
target, err := access.importEntryTarget(accessPath, nil, true)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return selectedImportCandidate{}, true, nil
}
return selectedImportCandidate{}, false, []Diagnostic{globCandidateDiagnostic(err, site.location, "glob match could not be inspected")}
}
canonical, err := access.canonical(accessPath)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return selectedImportCandidate{}, true, nil
}
return selectedImportCandidate{}, false, []Diagnostic{globFilesystemDiagnostic(err, "glob match could not be canonicalized", site.location)}
}
if !access.contains(canonical) {
return selectedImportCandidate{}, false, []Diagnostic{*importSelectionDiagnostic(site, CodeImportOutsideRoot, "glob traversal resolves outside the configuration root")}
}
if !last {
if !target.dir {
return selectedImportCandidate{}, true, nil
}
}
return selectedImportCandidate{
path: normalizedImportCandidatePath(access, candidatePath), accessPath: canonical,
label: relativeImportLabel(access, basePath, candidatePath), site: site,
}, false, nil
}
func readGlobEntries(access *filesystemAccess, directory string, location Location, limits importLimits, accounting *importAccounting) ([]fs.DirEntry, bool, []Diagnostic) {
target, err := access.importEntryTarget(directory, nil, true)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil, true, nil
}
return nil, false, []Diagnostic{globCandidateDiagnostic(err, location, "glob directory could not be inspected")}
}
canonical, err := access.canonical(directory)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil, true, nil
}
return nil, false, []Diagnostic{globFilesystemDiagnostic(err, "glob directory could not be canonicalized", location)}
}
if !access.contains(canonical) {
return nil, false, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob directory resolves outside the configuration root", &location)}
}
if _, err := access.relative(canonical); err != nil {
return nil, false, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob directory could not be addressed inside the configuration root", &location)}
}
readPath := target.identity
if readPath == "" {
readPath = canonical
}
entries := make([]fs.DirEntry, 0)
err = visitBoundedImportDirectory(access, readPath, limits.entries, accounting, func(entry fs.DirEntry) error {
entries = append(entries, entry)
return nil
})
if errors.Is(err, errImportDirectoryEntryLimit) {
return nil, false, []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "glob traversal exceeds the directory-entry visit limit", &location)}
}
if missingDirectoryOpen(err) {
return nil, true, nil
}
if err != nil {
return nil, false, []Diagnostic{globFilesystemDiagnostic(err, "glob directory could not be traversed", location)}
}
sort.Slice(entries, func(left, right int) bool { return entries[left].Name() < entries[right].Name() })
return entries, false, nil
}
func globCandidateDiagnostic(err error, location Location, message string) Diagnostic {
if errors.Is(err, errImportDirectoryLinkCycle) {
return importDiagnostic(CodeImportCycle, "directory traversal cycle detected", &location)
}
if errors.Is(err, errImportPathOutsideRoot) {
return importDiagnostic(CodeImportOutsideRoot, "glob traversal resolves outside the configuration root", &location)
}
return globFilesystemDiagnostic(err, message, location)
}
func candidatesFromMap(candidates map[string]selectedImportCandidate) []selectedImportCandidate {
paths := make([]string, 0, len(candidates))
for candidatePath := range candidates {
paths = append(paths, candidatePath)
}
slices.Sort(paths)
result := make([]selectedImportCandidate, 0, len(paths))
for _, candidatePath := range paths {
result = append(result, candidates[candidatePath])
}
return result
}
func validGlobPattern(pattern string) ([]string, bool) {
if !validImportPath(pattern) {
return nil, false
}
segments := strings.Split(pattern, "/")
for _, segment := range segments {
if segment == "" || hasRecursiveStars(segment) {
return nil, false
}
if _, err := path.Match(segment, ""); err != nil {
return nil, false
}
}
return segments, true
}
func hasRecursiveStars(segment string) bool {
inClass := false
for index := 0; index < len(segment); index++ {
switch segment[index] {
case '\\':
index++
case '[':
inClass = true
case ']':
inClass = false
case '*':
if !inClass && index+1 < len(segment) && segment[index+1] == '*' {
return true
}
}
}
return false
}
func globLiteralSegment(segment string) (string, bool) {
var literal strings.Builder
for index := 0; index < len(segment); index++ {
switch segment[index] {
case '*', '?', '[':
return "", true
case '\\':
index++
if index < len(segment) {
literal.WriteByte(segment[index])
}
default:
literal.WriteByte(segment[index])
}
}
return literal.String(), false
}
func globFilesystemDiagnostic(err error, message string, location Location) Diagnostic {
code := CodeImportRead
if errors.Is(err, fs.ErrPermission) {
code = CodeImportPermission
}
return importDiagnostic(code, message, &location)
}