420 lines
15 KiB
Go
420 lines
15 KiB
Go
package engine
|
|
|
|
import (
|
|
"errors"
|
|
"io/fs"
|
|
"path"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
type importSelectionOutcome uint8
|
|
|
|
const (
|
|
importSelectionNone importSelectionOutcome = iota
|
|
importSelectionHasCandidates
|
|
)
|
|
|
|
type selectedImportCandidate struct {
|
|
path string
|
|
accessPath string
|
|
label string
|
|
site importFieldSite
|
|
}
|
|
|
|
// importSelection contains lexical candidates only; admission assigns source identity later.
|
|
type importSelection struct {
|
|
candidates []selectedImportCandidate
|
|
outcome importSelectionOutcome
|
|
}
|
|
|
|
func selectNamedImportCandidates(access *filesystemAccess, basePath string, spec importDeclarationSpec, policy resolvedImportPolicy) (importSelection, []Diagnostic) {
|
|
normalized := make([]selectedImportCandidate, 0, len(spec.paths))
|
|
seen := make(map[string]struct{}, len(spec.paths))
|
|
for index, relative := range spec.paths {
|
|
accessPath := access.join(basePath, relative)
|
|
candidatePath := accessPath
|
|
if access.native == nil {
|
|
candidatePath = filepath.ToSlash(filepath.Clean(accessPath))
|
|
}
|
|
if _, exists := seen[candidatePath]; exists {
|
|
continue
|
|
}
|
|
seen[candidatePath] = struct{}{}
|
|
var site importFieldSite
|
|
if index < len(spec.selectorSites) {
|
|
site = spec.selectorSites[index]
|
|
} else {
|
|
site = spec.fieldSites["path"]
|
|
}
|
|
normalized = append(normalized, selectedImportCandidate{path: candidatePath, accessPath: accessPath, label: relativeImportLabel(access, basePath, accessPath), site: site})
|
|
}
|
|
sort.Slice(normalized, func(left, right int) bool {
|
|
return normalized[left].path < normalized[right].path
|
|
})
|
|
candidates := make([]selectedImportCandidate, 0, len(normalized))
|
|
for _, candidate := range normalized {
|
|
missing, _, diagnostic := validateNamedImportTarget(access, candidate, policy.required, false)
|
|
if diagnostic != nil {
|
|
return importSelection{}, []Diagnostic{*diagnostic}
|
|
}
|
|
if missing {
|
|
continue
|
|
}
|
|
if excludedBasename(path.Base(candidate.path), policy.exclude) {
|
|
continue
|
|
}
|
|
candidates = append(candidates, candidate)
|
|
}
|
|
selection := importSelection{candidates: candidates}
|
|
if len(candidates) != 0 {
|
|
selection.outcome = importSelectionHasCandidates
|
|
}
|
|
return selection, nil
|
|
}
|
|
|
|
func selectDirectoryImportCandidates(access *filesystemAccess, basePath string, spec importDeclarationSpec, policy resolvedImportPolicy, limits importLimits, accounting *importAccounting) (importSelection, []Diagnostic) {
|
|
roots := make([]selectedImportCandidate, 0, len(spec.paths))
|
|
seenRoots := make(map[string]struct{}, len(spec.paths))
|
|
for index, relative := range spec.paths {
|
|
accessPath := access.join(basePath, relative)
|
|
candidatePath := normalizedImportCandidatePath(access, accessPath)
|
|
if _, exists := seenRoots[candidatePath]; exists {
|
|
continue
|
|
}
|
|
seenRoots[candidatePath] = struct{}{}
|
|
site := spec.fieldSites["dir"]
|
|
if index < len(spec.selectorSites) {
|
|
site = spec.selectorSites[index]
|
|
}
|
|
roots = append(roots, selectedImportCandidate{path: candidatePath, accessPath: accessPath, site: site})
|
|
}
|
|
sort.Slice(roots, func(left, right int) bool { return roots[left].path < roots[right].path })
|
|
|
|
if accounting == nil {
|
|
accounting = &importAccounting{}
|
|
}
|
|
discovered := make([]selectedImportCandidate, 0)
|
|
seenCandidates := make(map[string]struct{})
|
|
// Each root is traversed once. Filename patterns are tested together for
|
|
// every discovered file, and seenCandidates forms their set union across
|
|
// overlapping roots. Exclusions are applied once below as set difference.
|
|
for _, root := range roots {
|
|
missing, isDirectory, diagnostic := validateNamedImportTarget(access, root, false, true)
|
|
if diagnostic != nil {
|
|
return importSelection{}, []Diagnostic{*diagnostic}
|
|
}
|
|
if missing {
|
|
continue
|
|
}
|
|
if !isDirectory {
|
|
return importSelection{}, []Diagnostic{*importSelectionDiagnostic(root.site, CodeImportInvalid, "directory search root is not a directory")}
|
|
}
|
|
rootTarget, targetErr := access.importEntryTarget(root.accessPath, nil, true)
|
|
if targetErr != nil {
|
|
return importSelection{}, []Diagnostic{*importDirectoryTargetDiagnostic(root.site, targetErr)}
|
|
}
|
|
if access.native == nil {
|
|
root.accessPath = rootTarget.identity
|
|
}
|
|
entries, missing, diagnostic := importDirectoryEntries(access, root.accessPath, root.site, limits, accounting)
|
|
if diagnostic != nil {
|
|
return importSelection{}, []Diagnostic{*diagnostic}
|
|
}
|
|
if missing {
|
|
continue
|
|
}
|
|
stack := []importDirectoryFrame{{directory: root, depth: 0, entries: entries, identity: rootTarget.identity}}
|
|
// Active-path cycle detection removes each directory when its frame is
|
|
// popped, so a later non-active alias remains a separate traversal.
|
|
activeDirectories := map[string]struct{}{rootTarget.identity: {}}
|
|
for len(stack) != 0 {
|
|
frameIndex := len(stack) - 1
|
|
frame := &stack[frameIndex]
|
|
if frame.next >= len(frame.entries) {
|
|
delete(activeDirectories, frame.identity)
|
|
stack = stack[:frameIndex]
|
|
continue
|
|
}
|
|
entry := frame.entries[frame.next]
|
|
frame.next++
|
|
result, diagnostic := prepareImportDirectoryEntry(access, basePath, *frame, entry, policy, limits, accounting, activeDirectories)
|
|
if diagnostic != nil {
|
|
return importSelection{}, []Diagnostic{*diagnostic}
|
|
}
|
|
switch result.kind {
|
|
case importDirectoryEntrySkip:
|
|
continue
|
|
case importDirectoryEntryFile:
|
|
if _, exists := seenCandidates[result.candidate.path]; exists {
|
|
continue
|
|
}
|
|
seenCandidates[result.candidate.path] = struct{}{}
|
|
discovered = append(discovered, result.candidate)
|
|
case importDirectoryEntryDirectory:
|
|
activeDirectories[result.frame.identity] = struct{}{}
|
|
stack = append(stack, result.frame)
|
|
}
|
|
}
|
|
}
|
|
sort.Slice(discovered, func(left, right int) bool { return discovered[left].path < discovered[right].path })
|
|
selected := make([]selectedImportCandidate, 0, len(discovered))
|
|
for _, candidate := range discovered {
|
|
missing, isDirectory, diagnostic := validateNamedImportTarget(access, candidate, false, true)
|
|
if diagnostic != nil {
|
|
return importSelection{}, []Diagnostic{*diagnostic}
|
|
}
|
|
if missing || isDirectory || excludedBasename(path.Base(candidate.path), policy.exclude) {
|
|
continue
|
|
}
|
|
selected = append(selected, candidate)
|
|
}
|
|
if policy.required && len(selected) == 0 {
|
|
return importSelection{}, []Diagnostic{*importSelectionDiagnostic(spec.fieldSites["dir"], CodeImportMissing, "required directory import selected no files")}
|
|
}
|
|
selection := importSelection{candidates: selected}
|
|
if len(selected) != 0 {
|
|
selection.outcome = importSelectionHasCandidates
|
|
}
|
|
return selection, nil
|
|
}
|
|
|
|
func prepareImportDirectoryEntry(access *filesystemAccess, basePath string, frame importDirectoryFrame, entry fs.DirEntry, policy resolvedImportPolicy, limits importLimits, accounting *importAccounting, activeDirectories map[string]struct{}) (importDirectoryEntryResult, *Diagnostic) {
|
|
accessPath := access.join(frame.directory.accessPath, entry.Name())
|
|
lexicalPath := access.join(frame.directory.path, entry.Name())
|
|
candidate := selectedImportCandidate{
|
|
path: normalizedImportCandidatePath(access, lexicalPath), accessPath: accessPath,
|
|
label: relativeImportLabel(access, basePath, lexicalPath), site: frame.directory.site,
|
|
}
|
|
if !policy.hidden && strings.HasPrefix(entry.Name(), ".") {
|
|
return importDirectoryEntryResult{kind: importDirectoryEntrySkip}, nil
|
|
}
|
|
target, err := access.importEntryTarget(candidate.accessPath, entry, policy.follow)
|
|
if err != nil {
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return importDirectoryEntryResult{kind: importDirectoryEntrySkip}, nil
|
|
}
|
|
return importDirectoryEntryResult{}, importDirectoryTargetDiagnostic(frame.directory.site, err)
|
|
}
|
|
if target.symlink && !policy.follow {
|
|
return importDirectoryEntryResult{kind: importDirectoryEntrySkip}, nil
|
|
}
|
|
if target.dir {
|
|
if !directoryDepthAllows(policy, frame.depth) {
|
|
return importDirectoryEntryResult{kind: importDirectoryEntrySkip}, nil
|
|
}
|
|
if _, exists := activeDirectories[target.identity]; exists {
|
|
return importDirectoryEntryResult{}, importSelectionDiagnostic(frame.directory.site, CodeImportCycle, "directory traversal cycle detected")
|
|
}
|
|
missing, isDirectory, diagnostic := validateNamedImportTarget(access, candidate, false, true)
|
|
if diagnostic != nil {
|
|
return importDirectoryEntryResult{}, diagnostic
|
|
}
|
|
if missing || !isDirectory {
|
|
return importDirectoryEntryResult{kind: importDirectoryEntrySkip}, nil
|
|
}
|
|
entries, vanished, diagnostic := importDirectoryEntries(access, candidate.accessPath, candidate.site, limits, accounting)
|
|
if diagnostic != nil {
|
|
return importDirectoryEntryResult{}, diagnostic
|
|
}
|
|
if vanished {
|
|
return importDirectoryEntryResult{kind: importDirectoryEntrySkip}, nil
|
|
}
|
|
if access.native == nil {
|
|
candidate.accessPath = target.identity
|
|
}
|
|
return importDirectoryEntryResult{kind: importDirectoryEntryDirectory, frame: importDirectoryFrame{
|
|
directory: candidate, depth: frame.depth + 1, entries: entries, identity: target.identity,
|
|
}}, nil
|
|
}
|
|
if target.identity != "" && access.native == nil {
|
|
candidate.accessPath = target.identity
|
|
} else {
|
|
canonical, err := access.canonical(accessPath)
|
|
if err != nil {
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return importDirectoryEntryResult{kind: importDirectoryEntrySkip}, nil
|
|
}
|
|
diagnostic := globFilesystemDiagnostic(err, "directory search file could not be canonicalized", frame.directory.site.location)
|
|
return importDirectoryEntryResult{}, &diagnostic
|
|
}
|
|
candidate.accessPath = canonical
|
|
}
|
|
if !matchesImportFilename(entry.Name(), policy.pattern) {
|
|
return importDirectoryEntryResult{kind: importDirectoryEntrySkip}, nil
|
|
}
|
|
return importDirectoryEntryResult{kind: importDirectoryEntryFile, candidate: candidate}, nil
|
|
}
|
|
|
|
type importDirectoryFrame struct {
|
|
directory selectedImportCandidate
|
|
depth int
|
|
entries []fs.DirEntry
|
|
next int
|
|
identity string
|
|
}
|
|
|
|
type importDirectoryEntryKind uint8
|
|
|
|
const (
|
|
importDirectoryEntrySkip importDirectoryEntryKind = iota
|
|
importDirectoryEntryFile
|
|
importDirectoryEntryDirectory
|
|
)
|
|
|
|
type importDirectoryEntryResult struct {
|
|
kind importDirectoryEntryKind
|
|
candidate selectedImportCandidate
|
|
frame importDirectoryFrame
|
|
}
|
|
|
|
func importDirectoryTargetDiagnostic(site importFieldSite, err error) *Diagnostic {
|
|
if isImportLinkCycle(err) {
|
|
return importSelectionDiagnostic(site, CodeImportCycle, "directory traversal cycle detected")
|
|
}
|
|
if errors.Is(err, errImportPathOutsideRoot) {
|
|
return importSelectionDiagnostic(site, CodeImportOutsideRoot, "import target resolves outside the configuration root")
|
|
}
|
|
return importSelectionDiagnostic(site, CodeImportInvalid, "directory entry could not be inspected")
|
|
}
|
|
|
|
func importDirectoryEntries(access *filesystemAccess, directory string, site importFieldSite, limits importLimits, accounting *importAccounting) ([]fs.DirEntry, bool, *Diagnostic) {
|
|
target, targetErr := access.importEntryTarget(directory, nil, true)
|
|
if targetErr != nil {
|
|
if errors.Is(targetErr, fs.ErrNotExist) {
|
|
return nil, true, nil
|
|
}
|
|
return nil, false, importDirectoryTargetDiagnostic(site, targetErr)
|
|
}
|
|
readPath := target.identity
|
|
if readPath == "" {
|
|
readPath = directory
|
|
}
|
|
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, importSelectionDiagnostic(site, CodeImportLimitExceeded, "directory search exceeds the directory-entry visit limit")
|
|
}
|
|
if missingDirectoryOpen(err) {
|
|
return nil, true, nil
|
|
}
|
|
if err != nil {
|
|
return nil, false, importSelectionDiagnostic(site, CodeImportInvalid, "directory search root could not be read")
|
|
}
|
|
sort.Slice(entries, func(left, right int) bool { return entries[left].Name() < entries[right].Name() })
|
|
return entries, false, nil
|
|
}
|
|
|
|
func directoryDepthAllows(policy resolvedImportPolicy, currentDepth int) bool {
|
|
if !policy.recursive {
|
|
return false
|
|
}
|
|
childDepth := currentDepth + 1
|
|
return !policy.depthSet || policy.depth == 0 || childDepth < policy.depth
|
|
}
|
|
|
|
func normalizedImportCandidatePath(access *filesystemAccess, accessPath string) string {
|
|
if access.native != nil {
|
|
return accessPath
|
|
}
|
|
return filepath.ToSlash(filepath.Clean(accessPath))
|
|
}
|
|
|
|
func relativeImportLabel(access *filesystemAccess, basePath, candidatePath string) string {
|
|
if access.native != nil {
|
|
if candidatePath == basePath {
|
|
return "."
|
|
}
|
|
prefix := strings.TrimSuffix(basePath, "/") + "/"
|
|
if basePath == "." {
|
|
return candidatePath
|
|
}
|
|
if strings.HasPrefix(candidatePath, prefix) {
|
|
return strings.TrimPrefix(candidatePath, prefix)
|
|
}
|
|
return candidatePath
|
|
}
|
|
label, err := filepath.Rel(basePath, candidatePath)
|
|
if err == nil {
|
|
return filepath.ToSlash(label)
|
|
}
|
|
return filepath.ToSlash(candidatePath)
|
|
}
|
|
|
|
func matchesImportFilename(name string, patterns []string) bool {
|
|
if patterns == nil {
|
|
return true
|
|
}
|
|
for _, pattern := range patterns {
|
|
matched, err := path.Match(pattern, name)
|
|
if err == nil && matched {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func validateNamedImportTarget(access *filesystemAccess, candidate selectedImportCandidate, required, allowDirectory bool) (bool, bool, *Diagnostic) {
|
|
if !access.contains(candidate.accessPath) {
|
|
return false, false, importSelectionDiagnostic(candidate.site, CodeImportOutsideRoot, "import target is outside the configuration root")
|
|
}
|
|
canonical, err := access.canonical(candidate.accessPath)
|
|
if err != nil {
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
if required {
|
|
return false, false, importSelectionDiagnostic(candidate.site, CodeImportMissing, "required import target does not exist")
|
|
}
|
|
return true, false, nil
|
|
}
|
|
if errors.Is(err, fs.ErrPermission) {
|
|
return false, false, importSelectionDiagnostic(candidate.site, CodeImportInvalid, "import target cannot be accessed")
|
|
}
|
|
return false, false, importSelectionDiagnostic(candidate.site, CodeImportInvalid, "import target could not be canonicalized")
|
|
}
|
|
if !access.contains(canonical) {
|
|
return false, false, importSelectionDiagnostic(candidate.site, CodeImportOutsideRoot, "import target resolves outside the configuration root")
|
|
}
|
|
info, err := access.stat(canonical)
|
|
if err != nil {
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
if required {
|
|
return false, false, importSelectionDiagnostic(candidate.site, CodeImportMissing, "required import target does not exist")
|
|
}
|
|
return true, false, nil
|
|
}
|
|
if errors.Is(err, fs.ErrPermission) {
|
|
return false, false, importSelectionDiagnostic(candidate.site, CodeImportInvalid, "import target cannot be accessed")
|
|
}
|
|
return false, false, importSelectionDiagnostic(candidate.site, CodeImportInvalid, "import target could not be inspected")
|
|
}
|
|
if info.IsDir() && allowDirectory {
|
|
return false, true, nil
|
|
}
|
|
if !info.Mode().IsRegular() {
|
|
return false, false, importSelectionDiagnostic(candidate.site, CodeSourceNotRegular, "source is not a regular file")
|
|
}
|
|
return false, false, nil
|
|
}
|
|
|
|
func excludedBasename(name string, patterns []string) bool {
|
|
for _, pattern := range patterns {
|
|
matched, err := path.Match(pattern, name)
|
|
if err == nil && matched {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func importSelectionDiagnostic(site importFieldSite, code Code, message string) *Diagnostic {
|
|
location := site.location
|
|
return &Diagnostic{
|
|
Code: code, Severity: SeverityError, Path: site.path.clone(),
|
|
Message: message, Primary: &location,
|
|
}
|
|
}
|