316 lines
11 KiB
Go
316 lines
11 KiB
Go
package engine
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
func TestImportLinkResolutionTracksActiveExpansions(t *testing.T) {
|
|
root := t.TempDir()
|
|
if err := os.Mkdir(filepath.Join(root, "parts"), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for name, target := range map[string]string{"parts/up": "..", "self": "self", "first": "second", "second": "first"} {
|
|
if err := os.Symlink(target, filepath.Join(root, filepath.FromSlash(name))); err != nil {
|
|
t.Skipf("symlinks unavailable: %v", err)
|
|
}
|
|
}
|
|
host, err := openHostAccess(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer host.close()
|
|
filesystem := os.DirFS(root)
|
|
links, ok := filesystem.(fs.ReadLinkFS)
|
|
if !ok {
|
|
t.Fatal("native directory filesystem does not expose link inspection")
|
|
}
|
|
native := newNativeAccess(filesystem, ".", &namespaceToken{})
|
|
for name, resolve := range map[string]func(string) (string, error){
|
|
"host": func(name string) (string, error) { return resolveHostImportPath(host, name) },
|
|
"native": func(name string) (string, error) { return resolveNativeImportPath(native, links, name) },
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
if got, err := resolve("parts/up/parts/up/parts"); err != nil || got != "parts" {
|
|
t.Errorf("finite link reuse = %q, %v", got, err)
|
|
}
|
|
for _, cycle := range []string{"self", "first"} {
|
|
if _, err := resolve(cycle); !errors.Is(err, errImportDirectoryLinkCycle) {
|
|
t.Errorf("%s cycle = %v", cycle, err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNativeLinkResolutionMemoizesCompletedDependencies(t *testing.T) {
|
|
// The small graph stays within host symlink-depth limits while exceeding the
|
|
// bounded linear operation allowance without completed-target reuse.
|
|
const linkCount = 5
|
|
root := t.TempDir()
|
|
configurationRoot := filepath.Join(root, "workspace", "config")
|
|
for _, directory := range []string{
|
|
filepath.Join(configurationRoot, "graph"),
|
|
filepath.Join(configurationRoot, "selected"),
|
|
} {
|
|
if err := os.MkdirAll(directory, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
for index := 0; index < linkCount; index++ {
|
|
name := fmt.Sprintf("s%02d", index)
|
|
target := "."
|
|
if index > 0 {
|
|
previous := fmt.Sprintf("s%02d", index-1)
|
|
target = previous + "/" + previous
|
|
}
|
|
if err := os.Symlink(target, filepath.Join(configurationRoot, "graph", name)); err != nil {
|
|
t.Skipf("symlinks unavailable: %v", err)
|
|
}
|
|
}
|
|
if err := os.WriteFile(filepath.Join(configurationRoot, "graph", "item.yml"), []byte("value: linked\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink(fmt.Sprintf("../graph/s%02d", linkCount-1), filepath.Join(configurationRoot, "selected", "entry")); err != nil {
|
|
t.Skipf("symlinks unavailable: %v", err)
|
|
}
|
|
|
|
for _, selector := range []string{"directory", "glob"} {
|
|
t.Run(selector, func(t *testing.T) {
|
|
base := os.DirFS(root)
|
|
if _, ok := base.(fs.ReadLinkFS); !ok {
|
|
t.Skip("host directory filesystem does not expose link inspection")
|
|
}
|
|
filesystem := newCountingReadLinkFS(base, linkCount*20+40)
|
|
access := newNativeAccess(filesystem, "workspace/config", &namespaceToken{})
|
|
if _, err := access.canonical("workspace/config/selected"); err != nil {
|
|
t.Fatalf("selected directory canonicalization failed before selection: %v", err)
|
|
}
|
|
var diagnostics []Diagnostic
|
|
if selector == "directory" {
|
|
_, diagnostics = selectDirectoryWithDiagnostics(t, access, "workspace/config", " - dir: selected\n follow: true\n")
|
|
} else {
|
|
_, diagnostics = selectGlobForTestWithDiagnostics(t, access, "workspace/config", " - glob: 'selected/*/*.yml'\n follow: true\n")
|
|
}
|
|
if len(diagnostics) != 0 {
|
|
t.Fatalf("selection diagnostics = %#v after %d link operations", diagnostics, filesystem.operations)
|
|
}
|
|
if filesystem.operations == 0 || filesystem.operations > filesystem.limit {
|
|
t.Fatalf("link-resolution operations = %d, want 1..%d", filesystem.operations, filesystem.limit)
|
|
}
|
|
if filesystem.readLinks < linkCount {
|
|
t.Fatalf("link reads = %d, want the selector to resolve the repeated dependency graph", filesystem.readLinks)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNativeLinkResolutionRejectsEscapesAndSkipsWhenFollowingIsDisabled(t *testing.T) {
|
|
root := t.TempDir()
|
|
configurationRoot := filepath.Join(root, "workspace", "config")
|
|
selected := filepath.Join(configurationRoot, "selected")
|
|
if err := os.MkdirAll(selected, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink("../../../outside", filepath.Join(selected, "escape")); err != nil {
|
|
t.Skipf("symlinks unavailable: %v", err)
|
|
}
|
|
base := os.DirFS(root)
|
|
if _, ok := base.(fs.ReadLinkFS); !ok {
|
|
t.Skip("host directory filesystem does not expose link inspection")
|
|
}
|
|
filesystem := newCountingReadLinkFS(base, 100)
|
|
access := newNativeAccess(filesystem, "workspace/config", &namespaceToken{})
|
|
if _, err := resolveNativeImportPath(access, filesystem.links, "workspace/config/selected/escape"); !errors.Is(err, errImportPathOutsideRoot) {
|
|
t.Fatalf("escaping native link error = %v, want outside-root rejection", err)
|
|
}
|
|
|
|
filesystem.readLinks = 0
|
|
_, diagnostics := selectDirectoryWithDiagnostics(t, access, "workspace/config", " - dir: selected\n follow: false\n")
|
|
if len(diagnostics) != 0 || filesystem.readLinks != 0 {
|
|
t.Fatalf("follow-disabled selection diagnostics=%#v readlink calls=%d", diagnostics, filesystem.readLinks)
|
|
}
|
|
}
|
|
|
|
func TestHostLinkResolutionMemoizesCompletedDependencies(t *testing.T) {
|
|
// The small graph stays within host symlink-depth limits while exceeding the
|
|
// bounded linear operation allowance without completed-target reuse.
|
|
const linkCount = 5
|
|
root := t.TempDir()
|
|
configurationRoot := filepath.Join(root, "workspace", "config")
|
|
graph := filepath.Join(configurationRoot, "graph")
|
|
selected := filepath.Join(configurationRoot, "selected")
|
|
for _, directory := range []string{graph, selected} {
|
|
if err := os.MkdirAll(directory, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
for index := 0; index < linkCount; index++ {
|
|
name := fmt.Sprintf("s%02d", index)
|
|
target := "."
|
|
if index > 0 {
|
|
previous := fmt.Sprintf("s%02d", index-1)
|
|
target = previous + "/" + previous
|
|
}
|
|
if err := os.Symlink(target, filepath.Join(graph, name)); err != nil {
|
|
t.Skipf("symlinks unavailable: %v", err)
|
|
}
|
|
}
|
|
if err := os.WriteFile(filepath.Join(graph, "item.yml"), []byte("value: linked\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
aliasTarget := fmt.Sprintf("../graph/s%02d", linkCount-1)
|
|
if err := os.Symlink(aliasTarget, filepath.Join(selected, "entry")); err != nil {
|
|
t.Skipf("symlinks unavailable: %v", err)
|
|
}
|
|
containedAbsolute := filepath.Join(graph, "s00")
|
|
if err := os.Symlink(containedAbsolute, filepath.Join(selected, "absolute")); err != nil {
|
|
t.Skipf("absolute symlinks unavailable: %v", err)
|
|
}
|
|
outside := t.TempDir()
|
|
if err := os.Symlink(outside, filepath.Join(selected, "escape")); err != nil {
|
|
t.Skipf("symlinks unavailable: %v", err)
|
|
}
|
|
|
|
access, err := openHostAccess(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer access.close()
|
|
name := filepath.ToSlash(filepath.Join("workspace", "config", "selected", "entry"))
|
|
operations := hostImportLinkOperations(access)
|
|
var operationCount int
|
|
lstat := operations.lstat
|
|
readlink := operations.readlink
|
|
operations.lstat = func(candidate string) (fs.FileInfo, error) {
|
|
operationCount++
|
|
if operationCount > linkCount*20+40 {
|
|
return nil, errors.New("host link operation limit exceeded")
|
|
}
|
|
return lstat(candidate)
|
|
}
|
|
operations.readlink = func(candidate string) (string, error) {
|
|
operationCount++
|
|
if operationCount > linkCount*20+40 {
|
|
return "", errors.New("host link operation limit exceeded")
|
|
}
|
|
return readlink(candidate)
|
|
}
|
|
resolved, err := resolveImportLinkPath(name, operations)
|
|
if err != nil || resolved != filepath.ToSlash(filepath.Join("workspace", "config", "graph")) {
|
|
t.Fatalf("host repeated-link resolution = %q, %v", resolved, err)
|
|
}
|
|
if operationCount == 0 || operationCount > linkCount*20+40 {
|
|
t.Fatalf("host link operations = %d, want 1..%d", operationCount, linkCount*20+40)
|
|
}
|
|
|
|
contained, err := resolveHostImportPath(access, filepath.Join("workspace", "config", "selected", "absolute"))
|
|
if err != nil || contained != filepath.ToSlash(filepath.Join("workspace", "config", "graph")) {
|
|
t.Fatalf("contained absolute link = %q, %v", contained, err)
|
|
}
|
|
if _, err := resolveHostImportPath(access, filepath.Join("workspace", "config", "selected", "escape")); !errors.Is(err, errImportPathOutsideRoot) {
|
|
t.Fatalf("escaping absolute link error = %v, want outside-root rejection", err)
|
|
}
|
|
|
|
for _, selector := range []string{"directory", "glob"} {
|
|
t.Run(selector, func(t *testing.T) {
|
|
var diagnostics []Diagnostic
|
|
if selector == "directory" {
|
|
_, diagnostics = selectDirectoryWithDiagnostics(t, access, root, " - dir: workspace/config/selected/entry\n follow: true\n")
|
|
} else {
|
|
_, diagnostics = selectGlobForTestWithDiagnostics(t, access, root, " - glob: 'workspace/config/selected/entry/*.yml'\n follow: true\n")
|
|
}
|
|
if len(diagnostics) != 0 {
|
|
t.Fatalf("host selector diagnostics = %#v", diagnostics)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHostLinkResolutionKeepsNamespacesIndependent(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
target string
|
|
}{
|
|
{name: "left", target: "left-target"},
|
|
{name: "right", target: "right-target"},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
root := t.TempDir()
|
|
if err := os.Mkdir(filepath.Join(root, test.target), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink(test.target, filepath.Join(root, "alias")); err != nil {
|
|
t.Skipf("symlinks unavailable: %v", err)
|
|
}
|
|
access, err := openHostAccess(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer access.close()
|
|
if got, err := resolveHostImportPath(access, "alias"); err != nil || got != test.target {
|
|
t.Fatalf("namespace link = %q, %v; want %q", got, err, test.target)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
type countingReadLinkFS struct {
|
|
filesystem fs.FS
|
|
links fs.ReadLinkFS
|
|
operations int
|
|
readLinks int
|
|
limit int
|
|
}
|
|
|
|
func newCountingReadLinkFS(filesystem fs.FS, limit int) *countingReadLinkFS {
|
|
links, ok := filesystem.(fs.ReadLinkFS)
|
|
if !ok {
|
|
panic("test filesystem does not support link inspection")
|
|
}
|
|
return &countingReadLinkFS{filesystem: filesystem, links: links, limit: limit}
|
|
}
|
|
|
|
func (f *countingReadLinkFS) Open(name string) (fs.File, error) { return f.filesystem.Open(name) }
|
|
|
|
func (f *countingReadLinkFS) Stat(name string) (fs.FileInfo, error) {
|
|
if statter, ok := f.filesystem.(fs.StatFS); ok {
|
|
return statter.Stat(name)
|
|
}
|
|
return fs.Stat(f.filesystem, name)
|
|
}
|
|
|
|
func (f *countingReadLinkFS) ReadDir(name string) ([]fs.DirEntry, error) {
|
|
if reader, ok := f.filesystem.(fs.ReadDirFS); ok {
|
|
return reader.ReadDir(name)
|
|
}
|
|
return fs.ReadDir(f.filesystem, name)
|
|
}
|
|
|
|
func (f *countingReadLinkFS) count() error {
|
|
f.operations++
|
|
if f.operations > f.limit {
|
|
return errors.New("native link operation limit exceeded")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *countingReadLinkFS) Lstat(name string) (fs.FileInfo, error) {
|
|
if err := f.count(); err != nil {
|
|
return nil, err
|
|
}
|
|
return f.links.Lstat(name)
|
|
}
|
|
|
|
func (f *countingReadLinkFS) ReadLink(name string) (string, error) {
|
|
if err := f.count(); err != nil {
|
|
return "", err
|
|
}
|
|
f.readLinks++
|
|
return f.links.ReadLink(name)
|
|
}
|