diff --git a/internal/engine/directory_links_test.go b/internal/engine/directory_links_test.go new file mode 100644 index 0000000..4a46493 --- /dev/null +++ b/internal/engine/directory_links_test.go @@ -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 } diff --git a/internal/engine/directory_walk.go b/internal/engine/directory_walk.go index 97c566d..f928274 100644 --- a/internal/engine/directory_walk.go +++ b/internal/engine/directory_walk.go @@ -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 -} diff --git a/internal/engine/filesystem_links.go b/internal/engine/filesystem_links.go new file mode 100644 index 0000000..f4fb1b4 --- /dev/null +++ b/internal/engine/filesystem_links.go @@ -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) +} diff --git a/internal/engine/import_selection.go b/internal/engine/import_selection.go index 9e5ec31..d86d1f9 100644 --- a/internal/engine/import_selection.go +++ b/internal/engine/import_selection.go @@ -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) {