Follow links safely during directory discovery
This commit is contained in:
353
internal/engine/directory_links_test.go
Normal file
353
internal/engine/directory_links_test.go
Normal file
@@ -0,0 +1,353 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSelectDirectoriesApplyHiddenAndFollowControls(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
for name, content := range map[string]string{
|
||||
"direct.yaml": "value: direct\n",
|
||||
".private.yaml": "value: hidden\n",
|
||||
".private/nested.yaml": "value: hidden directory\n",
|
||||
"target/linked.yaml": "value: linked\n",
|
||||
"target/source.txt": "value: source\n",
|
||||
} {
|
||||
filename := filepath.Join(root, filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(filename), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filename, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.Symlink("target", filepath.Join(root, "visible-alias")); err != nil {
|
||||
t.Skipf("symlinks are unavailable: %v", err)
|
||||
}
|
||||
if err := os.Symlink("target/linked.yaml", filepath.Join(root, "linked-alias.txt")); err != nil {
|
||||
t.Skipf("symlinks are unavailable: %v", err)
|
||||
}
|
||||
if err := os.Symlink("target/source.txt", filepath.Join(root, "selected.yaml")); err != nil {
|
||||
t.Skipf("symlinks are unavailable: %v", err)
|
||||
}
|
||||
if err := os.Symlink("target", filepath.Join(root, ".hidden-alias")); err != nil {
|
||||
t.Skipf("symlinks are unavailable: %v", err)
|
||||
}
|
||||
|
||||
access, err := openHostAccess(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer access.close()
|
||||
|
||||
selection := selectDirectoryWithOptions(t, access, root, ` - dir: .
|
||||
recursive: true
|
||||
follow: false
|
||||
hidden: false
|
||||
pattern: '*.yaml'
|
||||
`)
|
||||
if got, want := selectedPathsRelativeTo(selection, root), []string{"direct.yaml", "target/linked.yaml"}; !equalStrings(got, want) {
|
||||
t.Fatalf("filtered paths = %#v, want %#v", got, want)
|
||||
}
|
||||
selection = selectDirectoryWithOptions(t, access, root, ` - dir: visible-alias
|
||||
follow: false
|
||||
pattern: '*.yaml'
|
||||
`)
|
||||
if got, want := selectedPathsRelativeTo(selection, root), []string{"visible-alias/linked.yaml"}; !equalStrings(got, want) {
|
||||
t.Fatalf("explicit symlink root paths = %#v, want %#v", got, want)
|
||||
}
|
||||
|
||||
selection = selectDirectoryWithOptions(t, access, root, ` - dir: .
|
||||
recursive: true
|
||||
follow: true
|
||||
hidden: false
|
||||
pattern: '*.yaml'
|
||||
`)
|
||||
if got, want := selectedPathsRelativeTo(selection, root), []string{"direct.yaml", "selected.yaml", "target/linked.yaml", "visible-alias/linked.yaml"}; !equalStrings(got, want) {
|
||||
t.Fatalf("followed paths = %#v, want %#v", got, want)
|
||||
}
|
||||
|
||||
selection = selectDirectoryWithOptions(t, access, root, ` - dir: .
|
||||
recursive: true
|
||||
follow: true
|
||||
hidden: true
|
||||
pattern: '*.yaml'
|
||||
`)
|
||||
if got, want := selectedPathsRelativeTo(selection, root), []string{
|
||||
".hidden-alias/linked.yaml", ".private.yaml", ".private/nested.yaml", "direct.yaml",
|
||||
"selected.yaml", "target/linked.yaml", "visible-alias/linked.yaml",
|
||||
}; !equalStrings(got, want) {
|
||||
t.Fatalf("hidden-enabled paths = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectDirectoriesRejectEscapingAndCyclicLinks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("escape", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(outside, "outside.yaml"), []byte("value: outside\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join(root, "escape")); err != nil {
|
||||
t.Skipf("symlinks are unavailable: %v", err)
|
||||
}
|
||||
access, err := openHostAccess(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer access.close()
|
||||
_, diagnostics := selectDirectoryWithDiagnostics(t, access, root, ` - dir: .
|
||||
recursive: true
|
||||
follow: true
|
||||
pattern: '*.yaml'
|
||||
`)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportOutsideRoot {
|
||||
t.Fatalf("escape diagnostics = %#v, want import.outside_root", diagnostics)
|
||||
}
|
||||
selection := selectDirectoryWithOptions(t, access, root, ` - dir: .
|
||||
recursive: true
|
||||
follow: false
|
||||
pattern: '*.yaml'
|
||||
`)
|
||||
if len(selection.candidates) != 0 {
|
||||
t.Fatalf("follow:false selected an escaping symlink: %#v", selection.candidates)
|
||||
}
|
||||
})
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
}{
|
||||
{name: "self"},
|
||||
{name: "ancestor"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if test.name == "self" {
|
||||
if err := os.Symlink(".", filepath.Join(root, "loop")); err != nil {
|
||||
t.Skipf("symlinks are unavailable: %v", err)
|
||||
}
|
||||
} else {
|
||||
if err := os.Mkdir(filepath.Join(root, "nested"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("..", filepath.Join(root, "nested", "up")); err != nil {
|
||||
t.Skipf("symlinks are unavailable: %v", err)
|
||||
}
|
||||
}
|
||||
access, err := openHostAccess(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer access.close()
|
||||
_, diagnostics := selectDirectoryWithDiagnostics(t, access, root, ` - dir: .
|
||||
recursive: true
|
||||
follow: true
|
||||
`)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportCycle {
|
||||
t.Fatalf("cycle diagnostics = %#v, want import.cycle", diagnostics)
|
||||
}
|
||||
if diagnostics[0].Message != "directory traversal cycle detected" {
|
||||
t.Fatalf("cycle diagnostic exposes unexpected detail: %q", diagnostics[0].Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectDirectoriesRetainSeparateNoncyclicAliases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(root, "target"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "target", "item.yaml"), []byte("value: item\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, alias := range []string{"first", "second"} {
|
||||
if err := os.Symlink("target", filepath.Join(root, alias)); err != nil {
|
||||
t.Skipf("symlinks are unavailable: %v", err)
|
||||
}
|
||||
}
|
||||
access, err := openHostAccess(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer access.close()
|
||||
|
||||
selection := selectDirectoryWithOptions(t, access, root, ` - dir: .
|
||||
recursive: true
|
||||
follow: true
|
||||
pattern: '*.yaml'
|
||||
`)
|
||||
want := []string{"first/item.yaml", "second/item.yaml", "target/item.yaml"}
|
||||
if got := selectedPathsRelativeTo(selection, root); !equalStrings(got, want) {
|
||||
t.Fatalf("alias candidates = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectDirectoriesUseNativeReadLinkCapability(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
filesystem := &linkAwareMapFS{
|
||||
files: fstest.MapFS{
|
||||
"real": &fstest.MapFile{Mode: fs.ModeDir | 0o700},
|
||||
"real/item.yaml": &fstest.MapFile{Data: []byte("value: item\n")},
|
||||
"alias": &fstest.MapFile{Mode: fs.ModeSymlink, Data: []byte("real")},
|
||||
".hidden": &fstest.MapFile{Mode: fs.ModeDir | 0o700},
|
||||
".hidden/secret.yaml": &fstest.MapFile{Data: []byte("value: secret\n")},
|
||||
},
|
||||
links: map[string]string{"alias": "real"},
|
||||
}
|
||||
access := newNativeAccess(filesystem, ".", &namespaceToken{})
|
||||
selection := selectDirectoryWithOptions(t, access, ".", ` - dir: .
|
||||
recursive: true
|
||||
follow: true
|
||||
hidden: false
|
||||
pattern: '*.yaml'
|
||||
`)
|
||||
if got, want := selectedPaths(selection), []string{"alias/item.yaml", "real/item.yaml"}; !equalStrings(got, want) {
|
||||
t.Fatalf("native link candidates = %#v, want %#v", got, want)
|
||||
}
|
||||
cyclic := &linkAwareMapFS{
|
||||
files: fstest.MapFS{
|
||||
"loop": &fstest.MapFile{Mode: fs.ModeSymlink, Data: []byte(".")},
|
||||
},
|
||||
links: map[string]string{"loop": "."},
|
||||
}
|
||||
_, diagnostics := selectDirectoryWithDiagnostics(t, newNativeAccess(cyclic, ".", &namespaceToken{}), ".", ` - dir: .
|
||||
recursive: true
|
||||
follow: true
|
||||
`)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportCycle {
|
||||
t.Fatalf("native loop diagnostics = %#v, want import.cycle", diagnostics)
|
||||
}
|
||||
|
||||
plain := newNativeAccess(fstest.MapFS{"plain.yaml": &fstest.MapFile{Data: []byte("value: plain\n")}}, ".", &namespaceToken{})
|
||||
selection = selectDirectoryWithOptions(t, plain, ".", " - dir: .\n")
|
||||
if got, want := selectedPaths(selection), []string{"plain.yaml"}; !equalStrings(got, want) {
|
||||
t.Fatalf("plain native filesystem selection = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func selectDirectoryWithOptions(t *testing.T, access *filesystemAccess, base, declaration string) importSelection {
|
||||
t.Helper()
|
||||
selection, diagnostics := selectDirectoryWithDiagnostics(t, access, base, declaration)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("directory selection diagnostics = %#v", diagnostics)
|
||||
}
|
||||
return selection
|
||||
}
|
||||
|
||||
func selectDirectoryWithDiagnostics(t *testing.T, access *filesystemAccess, base, declaration string) (importSelection, []Diagnostic) {
|
||||
t.Helper()
|
||||
specs, diagnostics := parseImportSpecsYAML(t, declaration)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("parse diagnostics = %#v", diagnostics)
|
||||
}
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("policy diagnostics = %#v", diagnostics)
|
||||
}
|
||||
return selectDirectoryImportCandidates(access, base, specs[0], policy, defaultImportLimits(), &importAccounting{})
|
||||
}
|
||||
|
||||
func selectedPaths(selection importSelection) []string {
|
||||
paths := make([]string, 0, len(selection.candidates))
|
||||
for _, candidate := range selection.candidates {
|
||||
paths = append(paths, candidate.path)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func selectedPathsRelativeTo(selection importSelection, root string) []string {
|
||||
paths := make([]string, 0, len(selection.candidates))
|
||||
for _, candidate := range selection.candidates {
|
||||
relative, err := filepath.Rel(root, candidate.path)
|
||||
if err != nil {
|
||||
paths = append(paths, candidate.path)
|
||||
continue
|
||||
}
|
||||
paths = append(paths, filepath.ToSlash(relative))
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func equalStrings(left, right []string) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for index := range left {
|
||||
if left[index] != right[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type linkAwareMapFS struct {
|
||||
files fstest.MapFS
|
||||
links map[string]string
|
||||
}
|
||||
|
||||
func (f *linkAwareMapFS) Open(name string) (fs.File, error) {
|
||||
resolved, err := f.resolve(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return f.files.Open(resolved)
|
||||
}
|
||||
|
||||
func (f *linkAwareMapFS) ReadLink(name string) (string, error) {
|
||||
target, ok := f.links[name]
|
||||
if !ok {
|
||||
return "", &fs.PathError{Op: "readlink", Path: name, Err: fs.ErrInvalid}
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func (f *linkAwareMapFS) Lstat(name string) (fs.FileInfo, error) {
|
||||
if _, ok := f.links[name]; ok {
|
||||
return linkMapFileInfo{name: path.Base(name)}, nil
|
||||
}
|
||||
return fs.Stat(f.files, name)
|
||||
}
|
||||
|
||||
func (f *linkAwareMapFS) resolve(name string) (string, error) {
|
||||
if !fs.ValidPath(name) {
|
||||
return "", fs.ErrInvalid
|
||||
}
|
||||
resolved := ""
|
||||
seen := make(map[string]struct{})
|
||||
for _, segment := range strings.Split(name, "/") {
|
||||
candidate := path.Join(resolved, segment)
|
||||
if target, ok := f.links[candidate]; ok {
|
||||
if _, exists := seen[candidate]; exists {
|
||||
return "", fs.ErrInvalid
|
||||
}
|
||||
seen[candidate] = struct{}{}
|
||||
candidate = path.Clean(path.Join(path.Dir(candidate), target))
|
||||
}
|
||||
resolved = candidate
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
type linkMapFileInfo struct{ name string }
|
||||
|
||||
func (i linkMapFileInfo) Name() string { return i.name }
|
||||
func (linkMapFileInfo) Size() int64 { return 0 }
|
||||
func (linkMapFileInfo) Mode() fs.FileMode { return fs.ModeSymlink | 0o777 }
|
||||
func (linkMapFileInfo) ModTime() time.Time { return time.Time{} }
|
||||
func (linkMapFileInfo) IsDir() bool { return false }
|
||||
func (linkMapFileInfo) Sys() any { return nil }
|
||||
@@ -30,21 +30,3 @@ func visitBoundedImportDirectory(access *filesystemAccess, directory string, lim
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func importDirectoryEntryIsDirectory(entry fs.DirEntry) (bool, error) {
|
||||
mode := entry.Type()
|
||||
if mode&fs.ModeSymlink != 0 {
|
||||
return false, nil
|
||||
}
|
||||
if mode.IsDir() {
|
||||
return true, nil
|
||||
}
|
||||
if mode != 0 {
|
||||
return false, nil
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return info.IsDir(), nil
|
||||
}
|
||||
|
||||
212
internal/engine/filesystem_links.go
Normal file
212
internal/engine/filesystem_links.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
errImportDirectoryLinkCycle = errors.New("directory link cycle")
|
||||
errImportPathOutsideRoot = errors.New("resolved import path is outside the configuration root")
|
||||
)
|
||||
|
||||
// importEntryTarget describes a discovered entry without changing its lexical
|
||||
// candidate spelling. identity is resolved only to detect active traversal loops.
|
||||
type importEntryTarget struct {
|
||||
symlink bool
|
||||
dir bool
|
||||
identity string
|
||||
}
|
||||
|
||||
func (a *filesystemAccess) importEntryTarget(name string, entry fs.DirEntry, follow bool) (importEntryTarget, error) {
|
||||
if a.native == nil {
|
||||
relative, err := a.relative(name)
|
||||
if err != nil {
|
||||
return importEntryTarget{}, err
|
||||
}
|
||||
info, err := a.root.Lstat(relative)
|
||||
if err != nil {
|
||||
return importEntryTarget{}, err
|
||||
}
|
||||
symlink := info.Mode()&fs.ModeSymlink != 0
|
||||
if symlink && !follow {
|
||||
return importEntryTarget{symlink: true}, nil
|
||||
}
|
||||
if !symlink && !info.IsDir() {
|
||||
return importEntryTarget{}, nil
|
||||
}
|
||||
resolved, err := resolveHostImportPath(a, relative)
|
||||
if err != nil {
|
||||
return importEntryTarget{}, err
|
||||
}
|
||||
resolvedPath := filepath.Join(a.rootPath, filepath.FromSlash(resolved))
|
||||
info, err = a.root.Stat(filepath.FromSlash(resolved))
|
||||
if err != nil {
|
||||
return importEntryTarget{}, err
|
||||
}
|
||||
resolvedPath = filepath.Clean(resolvedPath)
|
||||
return importEntryTarget{symlink: symlink, dir: info.IsDir(), identity: resolvedPath}, nil
|
||||
}
|
||||
|
||||
linkFS, linksKnown := a.native.(fs.ReadLinkFS)
|
||||
if linksKnown {
|
||||
info, err := linkFS.Lstat(name)
|
||||
if err != nil {
|
||||
return importEntryTarget{}, err
|
||||
}
|
||||
symlink := info.Mode()&fs.ModeSymlink != 0
|
||||
if symlink && !follow {
|
||||
return importEntryTarget{symlink: true}, nil
|
||||
}
|
||||
if !symlink && !info.IsDir() {
|
||||
return importEntryTarget{}, nil
|
||||
}
|
||||
resolved, err := resolveNativeImportPath(a, linkFS, name)
|
||||
if err != nil {
|
||||
return importEntryTarget{}, err
|
||||
}
|
||||
if !a.contains(resolved) {
|
||||
return importEntryTarget{}, errImportPathOutsideRoot
|
||||
}
|
||||
info, err = fs.Stat(a.native, resolved)
|
||||
if err != nil {
|
||||
return importEntryTarget{}, err
|
||||
}
|
||||
return importEntryTarget{symlink: symlink, dir: info.IsDir(), identity: resolved}, nil
|
||||
}
|
||||
|
||||
symlink := entry != nil && entry.Type()&fs.ModeSymlink != 0
|
||||
if symlink && !follow {
|
||||
return importEntryTarget{symlink: true}, nil
|
||||
}
|
||||
if entry != nil && !symlink {
|
||||
mode := entry.Type()
|
||||
if mode == 0 {
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return importEntryTarget{}, err
|
||||
}
|
||||
mode = info.Mode()
|
||||
symlink = mode&fs.ModeSymlink != 0
|
||||
if symlink && !follow {
|
||||
return importEntryTarget{symlink: true}, nil
|
||||
}
|
||||
}
|
||||
if !symlink && !mode.IsDir() {
|
||||
return importEntryTarget{}, nil
|
||||
}
|
||||
if !symlink && mode.IsDir() {
|
||||
return importEntryTarget{dir: true, identity: name}, nil
|
||||
}
|
||||
}
|
||||
info, err := fs.Stat(a.native, name)
|
||||
if err != nil {
|
||||
return importEntryTarget{}, err
|
||||
}
|
||||
return importEntryTarget{symlink: symlink, dir: info.IsDir(), identity: name}, nil
|
||||
}
|
||||
|
||||
func resolveHostImportPath(access *filesystemAccess, name string) (string, error) {
|
||||
pending := strings.Split(filepath.ToSlash(name), "/")
|
||||
resolved := ""
|
||||
activeLinks := make(map[string]struct{})
|
||||
for len(pending) > 0 {
|
||||
segment := pending[0]
|
||||
pending = pending[1:]
|
||||
candidate := path.Join(resolved, segment)
|
||||
if candidate == "" {
|
||||
candidate = "."
|
||||
}
|
||||
info, err := access.root.Lstat(filepath.FromSlash(candidate))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if info.Mode()&fs.ModeSymlink == 0 {
|
||||
resolved = candidate
|
||||
continue
|
||||
}
|
||||
if _, exists := activeLinks[candidate]; exists {
|
||||
return "", errImportDirectoryLinkCycle
|
||||
}
|
||||
activeLinks[candidate] = struct{}{}
|
||||
target, err := access.root.Readlink(filepath.FromSlash(candidate))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if filepath.IsAbs(target) {
|
||||
return "", errImportPathOutsideRoot
|
||||
}
|
||||
targetPath := path.Clean(path.Join(path.Dir(candidate), filepath.ToSlash(target)))
|
||||
if !withinPortableRoot(targetPath) {
|
||||
return "", errImportPathOutsideRoot
|
||||
}
|
||||
remaining := append([]string(nil), pending...)
|
||||
pending = append(strings.Split(targetPath, "/"), remaining...)
|
||||
resolved = ""
|
||||
}
|
||||
if resolved == "" {
|
||||
resolved = "."
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func withinPortableRoot(name string) bool {
|
||||
return name == "." || name != ".." && !strings.HasPrefix(name, "../") && !strings.HasPrefix(name, "/")
|
||||
}
|
||||
|
||||
func resolveNativeImportPath(access *filesystemAccess, filesystem fs.ReadLinkFS, name string) (string, error) {
|
||||
if !fs.ValidPath(name) || !access.contains(name) {
|
||||
return "", fs.ErrInvalid
|
||||
}
|
||||
pending := strings.Split(name, "/")
|
||||
resolved := ""
|
||||
activeLinks := make(map[string]struct{})
|
||||
for len(pending) > 0 {
|
||||
segment := pending[0]
|
||||
pending = pending[1:]
|
||||
candidate := path.Join(resolved, segment)
|
||||
if candidate == "" {
|
||||
candidate = "."
|
||||
}
|
||||
if !access.contains(candidate) {
|
||||
return "", errImportPathOutsideRoot
|
||||
}
|
||||
info, err := filesystem.Lstat(candidate)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if info.Mode()&fs.ModeSymlink == 0 {
|
||||
resolved = candidate
|
||||
continue
|
||||
}
|
||||
if _, exists := activeLinks[candidate]; exists {
|
||||
return "", errImportDirectoryLinkCycle
|
||||
}
|
||||
activeLinks[candidate] = struct{}{}
|
||||
target, err := filesystem.ReadLink(candidate)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if target == "" || strings.HasPrefix(target, "/") {
|
||||
return "", errImportPathOutsideRoot
|
||||
}
|
||||
targetPath := path.Clean(path.Join(path.Dir(candidate), target))
|
||||
if !fs.ValidPath(targetPath) || !access.contains(targetPath) {
|
||||
return "", errImportPathOutsideRoot
|
||||
}
|
||||
remaining := append([]string(nil), pending...)
|
||||
pending = append(strings.Split(targetPath, "/"), remaining...)
|
||||
resolved = ""
|
||||
}
|
||||
if resolved == "" {
|
||||
resolved = "."
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func isImportLinkCycle(err error) bool {
|
||||
return errors.Is(err, errImportDirectoryLinkCycle)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type importSelectionOutcome uint8
|
||||
@@ -106,6 +107,10 @@ func selectDirectoryImportCandidates(access *filesystemAccess, basePath string,
|
||||
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)}
|
||||
}
|
||||
entries, missing, diagnostic := importDirectoryEntries(access, root.accessPath, root.site, limits, accounting)
|
||||
if diagnostic != nil {
|
||||
return importSelection{}, []Diagnostic{*diagnostic}
|
||||
@@ -113,11 +118,13 @@ func selectDirectoryImportCandidates(access *filesystemAccess, basePath string,
|
||||
if missing {
|
||||
continue
|
||||
}
|
||||
stack := []importDirectoryFrame{{directory: root, depth: 0, entries: entries}}
|
||||
stack := []importDirectoryFrame{{directory: root, depth: 0, entries: entries, identity: rootTarget.identity}}
|
||||
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
|
||||
}
|
||||
@@ -127,14 +134,26 @@ func selectDirectoryImportCandidates(access *filesystemAccess, basePath string,
|
||||
candidate := selectedImportCandidate{
|
||||
path: normalizedImportCandidatePath(access, accessPath), accessPath: accessPath, site: frame.directory.site,
|
||||
}
|
||||
isDirectory, entryErr := importDirectoryEntryIsDirectory(entry)
|
||||
if entryErr != nil {
|
||||
return importSelection{}, []Diagnostic{*importSelectionDiagnostic(frame.directory.site, CodeImportInvalid, "directory entry could not be inspected")}
|
||||
if !policy.hidden && strings.HasPrefix(entry.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
if isDirectory {
|
||||
target, entryErr := access.importEntryTarget(candidate.accessPath, entry, policy.follow)
|
||||
if entryErr != nil {
|
||||
if errors.Is(entryErr, fs.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
return importSelection{}, []Diagnostic{*importDirectoryTargetDiagnostic(frame.directory.site, entryErr)}
|
||||
}
|
||||
if target.symlink && !policy.follow {
|
||||
continue
|
||||
}
|
||||
if target.dir {
|
||||
if !directoryDepthAllows(policy, frame.depth) {
|
||||
continue
|
||||
}
|
||||
if _, exists := activeDirectories[target.identity]; exists {
|
||||
return importSelection{}, []Diagnostic{*importSelectionDiagnostic(frame.directory.site, CodeImportCycle, "directory traversal cycle detected")}
|
||||
}
|
||||
childMissing, childIsDirectory, childDiagnostic := validateNamedImportTarget(access, candidate, false, true)
|
||||
if childDiagnostic != nil {
|
||||
return importSelection{}, []Diagnostic{*childDiagnostic}
|
||||
@@ -147,7 +166,8 @@ func selectDirectoryImportCandidates(access *filesystemAccess, basePath string,
|
||||
return importSelection{}, []Diagnostic{*childDiagnostic}
|
||||
}
|
||||
if !vanished {
|
||||
stack = append(stack, importDirectoryFrame{directory: candidate, depth: frame.depth + 1, entries: childEntries})
|
||||
activeDirectories[target.identity] = struct{}{}
|
||||
stack = append(stack, importDirectoryFrame{directory: candidate, depth: frame.depth + 1, entries: childEntries, identity: target.identity})
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -188,6 +208,17 @@ type importDirectoryFrame struct {
|
||||
depth int
|
||||
entries []fs.DirEntry
|
||||
next int
|
||||
identity string
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user