Complete bounded glob imports
This commit is contained in:
205
glob.go
Normal file
205
glob.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package configkit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"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 := filepath.Join(candidate.absolute, filepath.FromSlash(name))
|
||||
relative := path.Join(candidate.relative, name)
|
||||
if !containedPath(s.rootPath, filepath.Clean(absolute)) {
|
||||
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob traversal is outside the configuration root", &location)}
|
||||
}
|
||||
canonical, err := canonicalExistingPath(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 !containedPath(s.rootPath, canonical) {
|
||||
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob traversal resolves outside the configuration root", &location)}
|
||||
}
|
||||
if last {
|
||||
next = append(next, globCandidate{absolute: canonical, relative: relative})
|
||||
if len(s.sources)+len(next) > s.limits.sources {
|
||||
return nil, []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "glob matches exceed the remaining source-count limit", &location)}
|
||||
}
|
||||
continue
|
||||
}
|
||||
info, err := os.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
|
||||
}
|
||||
|
||||
func (s *importTraversal) readGlobMatches(directory, pattern string, location Location) ([]string, []Diagnostic) {
|
||||
canonical, err := canonicalExistingPath(directory)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob directory could not be canonicalized", location)}
|
||||
}
|
||||
if !containedPath(s.rootPath, canonical) {
|
||||
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob directory resolves outside the configuration root", &location)}
|
||||
}
|
||||
relative, err := filepath.Rel(s.rootPath, canonical)
|
||||
if err != nil || filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
||||
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob directory could not be addressed inside the configuration root", &location)}
|
||||
}
|
||||
directoryFile, err := s.root.Open(relative)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob directory could not be opened", location)}
|
||||
}
|
||||
|
||||
var matches []string
|
||||
for {
|
||||
entries, readErr := directoryFile.ReadDir(globReadBatch)
|
||||
for _, entry := range entries {
|
||||
s.usedEntries++
|
||||
if s.usedEntries > s.limits.entries {
|
||||
_ = directoryFile.Close()
|
||||
return nil, []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "glob traversal exceeds the directory-entry visit limit", &location)}
|
||||
}
|
||||
matched, matchErr := path.Match(pattern, entry.Name())
|
||||
if matchErr != nil {
|
||||
_ = directoryFile.Close()
|
||||
return nil, []Diagnostic{importDiagnostic(CodeImportInvalid, "import glob pattern is invalid", &location)}
|
||||
}
|
||||
if matched {
|
||||
matches = append(matches, entry.Name())
|
||||
}
|
||||
}
|
||||
if errors.Is(readErr, io.EOF) {
|
||||
break
|
||||
}
|
||||
if readErr != nil {
|
||||
_ = directoryFile.Close()
|
||||
return nil, []Diagnostic{globFilesystemDiagnostic(readErr, "glob directory could not be read", location)}
|
||||
}
|
||||
}
|
||||
if err := directoryFile.Close(); err != nil {
|
||||
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob directory could not be closed", location)}
|
||||
}
|
||||
slices.Sort(matches)
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user