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
|
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"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type importSelectionOutcome uint8
|
type importSelectionOutcome uint8
|
||||||
@@ -106,6 +107,10 @@ func selectDirectoryImportCandidates(access *filesystemAccess, basePath string,
|
|||||||
if !isDirectory {
|
if !isDirectory {
|
||||||
return importSelection{}, []Diagnostic{*importSelectionDiagnostic(root.site, CodeImportInvalid, "directory search root is not a directory")}
|
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)
|
entries, missing, diagnostic := importDirectoryEntries(access, root.accessPath, root.site, limits, accounting)
|
||||||
if diagnostic != nil {
|
if diagnostic != nil {
|
||||||
return importSelection{}, []Diagnostic{*diagnostic}
|
return importSelection{}, []Diagnostic{*diagnostic}
|
||||||
@@ -113,11 +118,13 @@ func selectDirectoryImportCandidates(access *filesystemAccess, basePath string,
|
|||||||
if missing {
|
if missing {
|
||||||
continue
|
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 {
|
for len(stack) != 0 {
|
||||||
frameIndex := len(stack) - 1
|
frameIndex := len(stack) - 1
|
||||||
frame := &stack[frameIndex]
|
frame := &stack[frameIndex]
|
||||||
if frame.next >= len(frame.entries) {
|
if frame.next >= len(frame.entries) {
|
||||||
|
delete(activeDirectories, frame.identity)
|
||||||
stack = stack[:frameIndex]
|
stack = stack[:frameIndex]
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -127,14 +134,26 @@ func selectDirectoryImportCandidates(access *filesystemAccess, basePath string,
|
|||||||
candidate := selectedImportCandidate{
|
candidate := selectedImportCandidate{
|
||||||
path: normalizedImportCandidatePath(access, accessPath), accessPath: accessPath, site: frame.directory.site,
|
path: normalizedImportCandidatePath(access, accessPath), accessPath: accessPath, site: frame.directory.site,
|
||||||
}
|
}
|
||||||
isDirectory, entryErr := importDirectoryEntryIsDirectory(entry)
|
if !policy.hidden && strings.HasPrefix(entry.Name(), ".") {
|
||||||
if entryErr != nil {
|
continue
|
||||||
return importSelection{}, []Diagnostic{*importSelectionDiagnostic(frame.directory.site, CodeImportInvalid, "directory entry could not be inspected")}
|
|
||||||
}
|
}
|
||||||
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) {
|
if !directoryDepthAllows(policy, frame.depth) {
|
||||||
continue
|
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)
|
childMissing, childIsDirectory, childDiagnostic := validateNamedImportTarget(access, candidate, false, true)
|
||||||
if childDiagnostic != nil {
|
if childDiagnostic != nil {
|
||||||
return importSelection{}, []Diagnostic{*childDiagnostic}
|
return importSelection{}, []Diagnostic{*childDiagnostic}
|
||||||
@@ -147,7 +166,8 @@ func selectDirectoryImportCandidates(access *filesystemAccess, basePath string,
|
|||||||
return importSelection{}, []Diagnostic{*childDiagnostic}
|
return importSelection{}, []Diagnostic{*childDiagnostic}
|
||||||
}
|
}
|
||||||
if !vanished {
|
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
|
continue
|
||||||
}
|
}
|
||||||
@@ -188,6 +208,17 @@ type importDirectoryFrame struct {
|
|||||||
depth int
|
depth int
|
||||||
entries []fs.DirEntry
|
entries []fs.DirEntry
|
||||||
next int
|
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) {
|
func importDirectoryEntries(access *filesystemAccess, directory string, site importFieldSite, limits importLimits, accounting *importAccounting) ([]fs.DirEntry, bool, *Diagnostic) {
|
||||||
|
|||||||
Reference in New Issue
Block a user