354 lines
12 KiB
Go
354 lines
12 KiB
Go
package engine
|
|
|
|
import (
|
|
"errors"
|
|
"io/fs"
|
|
"path"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
const globReadBatch = 128
|
|
|
|
type globCandidate struct {
|
|
absolute string
|
|
relative string
|
|
}
|
|
|
|
func (s *importTraversal) expandImportGlob(basePath string, declaration importDeclaration) ([]string, []Diagnostic) {
|
|
location := declaration.location
|
|
segments, valid := validGlobPattern(declaration.glob)
|
|
if !valid {
|
|
return nil, []Diagnostic{importDiagnostic(CodeImportInvalid, "import glob pattern is invalid or uses recursive ** syntax", &location)}
|
|
}
|
|
|
|
candidates := []globCandidate{{absolute: basePath}}
|
|
for index, segment := range segments {
|
|
last := index == len(segments)-1
|
|
literal, patterned := globLiteralSegment(segment)
|
|
var next []globCandidate
|
|
for _, candidate := range candidates {
|
|
var names []string
|
|
if patterned {
|
|
matched, diagnostics := s.readGlobMatches(candidate.absolute, segment, location)
|
|
if len(diagnostics) != 0 {
|
|
return nil, diagnostics
|
|
}
|
|
names = matched
|
|
} else {
|
|
names = []string{literal}
|
|
}
|
|
for _, name := range names {
|
|
absolute := s.access.join(candidate.absolute, name)
|
|
relative := path.Join(candidate.relative, name)
|
|
if !s.access.contains(absolute) {
|
|
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob traversal is outside the configuration root", &location)}
|
|
}
|
|
canonical, err := s.access.canonical(absolute)
|
|
if err != nil {
|
|
if errors.Is(err, fs.ErrNotExist) && !patterned {
|
|
continue
|
|
}
|
|
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob match could not be canonicalized", location)}
|
|
}
|
|
if !s.access.contains(canonical) {
|
|
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob traversal resolves outside the configuration root", &location)}
|
|
}
|
|
if last {
|
|
next = append(next, globCandidate{absolute: canonical, relative: relative})
|
|
continue
|
|
}
|
|
info, err := s.access.stat(canonical)
|
|
if err != nil {
|
|
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob traversal target could not be inspected", location)}
|
|
}
|
|
if info.IsDir() {
|
|
next = append(next, globCandidate{absolute: canonical, relative: relative})
|
|
}
|
|
}
|
|
}
|
|
candidates = next
|
|
if len(candidates) == 0 {
|
|
return nil, nil
|
|
}
|
|
}
|
|
|
|
matches := make([]string, len(candidates))
|
|
for index, candidate := range candidates {
|
|
matches[index] = candidate.relative
|
|
}
|
|
slices.Sort(matches)
|
|
return matches, nil
|
|
}
|
|
|
|
// 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 := make(map[string]selectedImportCandidate)
|
|
for _, parent := range candidates {
|
|
if !patterned {
|
|
childPath := access.join(parent.accessPath, literal)
|
|
candidate, missing, diagnostics := explicitGlobCandidate(access, childPath, 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())
|
|
if !access.contains(accessPath) {
|
|
return importSelection{}, []Diagnostic{*importSelectionDiagnostic(site, CodeImportOutsideRoot, "glob traversal is outside the configuration root")}
|
|
}
|
|
candidate := selectedImportCandidate{path: normalizedImportCandidatePath(access, accessPath), accessPath: accessPath, site: site}
|
|
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
|
|
}
|
|
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}
|
|
if len(selected) != 0 {
|
|
selection.outcome = importSelectionHasCandidates
|
|
}
|
|
return selection, nil
|
|
}
|
|
|
|
func explicitGlobCandidate(access *filesystemAccess, candidatePath string, site importFieldSite, last bool) (selectedImportCandidate, bool, []Diagnostic) {
|
|
if !access.contains(candidatePath) {
|
|
return selectedImportCandidate{}, false, []Diagnostic{*importSelectionDiagnostic(site, CodeImportOutsideRoot, "glob traversal is outside the configuration root")}
|
|
}
|
|
target, err := access.importEntryTarget(candidatePath, 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(candidatePath)
|
|
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: candidatePath, site: site,
|
|
}, false, nil
|
|
}
|
|
|
|
func readGlobEntries(access *filesystemAccess, directory string, location Location, limits importLimits, accounting *importAccounting) ([]fs.DirEntry, bool, []Diagnostic) {
|
|
if _, err := access.importEntryTarget(directory, nil, true); 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)}
|
|
}
|
|
entries := make([]fs.DirEntry, 0)
|
|
err = visitBoundedImportDirectory(access, directory, 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 errors.Is(err, fs.ErrNotExist) {
|
|
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 (s *importTraversal) readGlobMatches(directory, pattern string, location Location) ([]string, []Diagnostic) {
|
|
entries, missing, diagnostics := readGlobEntries(s.access, directory, location, s.limits, s.accounting)
|
|
if len(diagnostics) != 0 {
|
|
return nil, diagnostics
|
|
}
|
|
if missing {
|
|
return nil, nil
|
|
}
|
|
var matches []string
|
|
for _, entry := range entries {
|
|
matched, matchErr := path.Match(pattern, entry.Name())
|
|
if matchErr != nil {
|
|
return nil, []Diagnostic{importDiagnostic(CodeImportInvalid, "import glob pattern is invalid", &location)}
|
|
}
|
|
if matched {
|
|
matches = append(matches, entry.Name())
|
|
}
|
|
}
|
|
return matches, nil
|
|
}
|
|
|
|
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)
|
|
}
|