233 lines
9.3 KiB
Go
233 lines
9.3 KiB
Go
package validation
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestResolveDestinationClassifiesCommittedPaths(t *testing.T) {
|
|
repository := newTreeRepository(t)
|
|
writeTreeFile(t, repository, "README.md", []byte("root\n"))
|
|
writeTreeFile(t, repository, "docs/page.md", []byte("source\n"))
|
|
writeTreeFile(t, repository, "docs/target.md", []byte("target\n"))
|
|
writeTreeFile(t, repository, "docs/nested/item.txt", []byte("nested\n"))
|
|
writeTreeFile(t, repository, "docs/dir/item.txt", []byte("directory\n"))
|
|
for name, target := range map[string]string{
|
|
"docs/inside": "target.md",
|
|
"docs/through-dir": "nested",
|
|
"docs/outside": "../../neighbor/file",
|
|
"docs/absolute": "/outside/file",
|
|
"docs/dangling": "missing-target",
|
|
"docs/cycle-a": "cycle-b",
|
|
"docs/cycle-b": "cycle-a",
|
|
} {
|
|
if err := os.Symlink(target, filepath.Join(repository, filepath.FromSlash(name))); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
submoduleSource := newTreeRepository(t)
|
|
writeTreeFile(t, submoduleSource, "sub.txt", []byte("submodule\n"))
|
|
gitTree(t, submoduleSource, "add", "--all")
|
|
gitTree(t, submoduleSource, "commit", "-m", "create submodule source")
|
|
submoduleOID := strings.TrimSpace(gitTree(t, submoduleSource, "rev-parse", "HEAD"))
|
|
gitTree(t, repository, "add", "--all")
|
|
gitTree(t, repository, "update-index", "--add", "--cacheinfo", "160000,"+submoduleOID+",docs/submodule")
|
|
gitTree(t, repository, "commit", "-m", "capture destination fixtures")
|
|
commit := strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD"))
|
|
tree, err := ReadCandidateTree(context.Background(), repository, commit)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Remove(filepath.Join(repository, "docs", "inside")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink("../../neighbor/file", filepath.Join(repository, "docs", "inside")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
source string
|
|
destination string
|
|
kind DestinationKind
|
|
path string
|
|
}{
|
|
{name: "relative file", source: "docs/page.md", destination: "target.md", kind: DestinationEntry, path: "docs/target.md"},
|
|
{name: "parent traversal inside root", source: "docs/page.md", destination: "../README.md", kind: DestinationEntry, path: "README.md"},
|
|
{name: "outside root", source: "docs/page.md", destination: "../../neighbor/file", kind: DestinationOutsideCheckout, path: "../neighbor/file"},
|
|
{name: "repository root directory", source: "docs/page.md", destination: "..", kind: DestinationDirectory, path: "."},
|
|
{name: "directory", source: "docs/page.md", destination: "dir", kind: DestinationDirectory, path: "docs/dir"},
|
|
{name: "symlink to file", source: "docs/page.md", destination: "inside", kind: DestinationEntry, path: "docs/target.md"},
|
|
{name: "symlinked directory", source: "docs/page.md", destination: "through-dir/item.txt", kind: DestinationEntry, path: "docs/nested/item.txt"},
|
|
{name: "absolute symlink", source: "docs/page.md", destination: "absolute", kind: DestinationOutsideCheckout, path: "/outside/file"},
|
|
{name: "missing entry", source: "docs/page.md", destination: "missing", kind: DestinationMissing, path: "docs/missing"},
|
|
{name: "dangling symlink", source: "docs/page.md", destination: "dangling", kind: DestinationMissing, path: "docs/missing-target"},
|
|
{name: "symlink cycle", source: "docs/page.md", destination: "cycle-a", kind: DestinationSymlinkCycle, path: "docs/cycle-a"},
|
|
{name: "submodule contents", source: "docs/page.md", destination: "submodule/child", kind: DestinationSubmodule, path: "docs/submodule/child"},
|
|
}
|
|
for _, test := range cases {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
got, err := tree.ResolveDestination(context.Background(), test.source, test.destination)
|
|
if err != nil {
|
|
t.Fatalf("ResolveDestination() error = %v", err)
|
|
}
|
|
if got.Kind != test.kind || got.Path != test.path {
|
|
t.Fatalf("ResolveDestination() = %#v, want kind %q path %q", got, test.kind, test.path)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResolveDestinationHandlesExpandingCyclesAndRepeatedLinks(t *testing.T) {
|
|
repository := newTreeRepository(t)
|
|
writeTreeFile(t, repository, "README.md", []byte("source\n"))
|
|
for name, target := range map[string]string{
|
|
"direct": "direct/child",
|
|
"mutual-a": "mutual-b/child",
|
|
"mutual-b": "mutual-a/child",
|
|
"alias": ".",
|
|
} {
|
|
if err := os.Symlink(target, filepath.Join(repository, name)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
gitTree(t, repository, "add", "--all")
|
|
gitTree(t, repository, "commit", "-m", "capture recursive links")
|
|
tree, err := ReadCandidateTree(t.Context(), repository, strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD")))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, test := range []struct {
|
|
destination string
|
|
kind DestinationKind
|
|
}{
|
|
{"direct", DestinationSymlinkCycle},
|
|
{"mutual-a", DestinationSymlinkCycle},
|
|
{"alias/alias/README.md", DestinationEntry},
|
|
} {
|
|
t.Run(test.destination, func(t *testing.T) {
|
|
type result struct {
|
|
destination Destination
|
|
err error
|
|
}
|
|
done := make(chan result, 1)
|
|
ctx, cancel := context.WithTimeout(t.Context(), time.Second)
|
|
defer cancel()
|
|
go func() {
|
|
destination, err := tree.ResolveDestination(ctx, "README.md", test.destination)
|
|
done <- result{destination, err}
|
|
}()
|
|
select {
|
|
case got := <-done:
|
|
if got.err != nil || got.destination.Kind != test.kind {
|
|
t.Fatalf("resolution = %#v, %v; want %s", got.destination, got.err, test.kind)
|
|
}
|
|
if test.kind == DestinationEntry && got.destination.Path != "README.md" {
|
|
t.Fatalf("finite repeated link resolved to %q", got.destination.Path)
|
|
}
|
|
case <-ctx.Done():
|
|
t.Fatal("symlink resolution did not finish")
|
|
}
|
|
})
|
|
}
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
cancel()
|
|
if _, err := tree.ResolveDestination(ctx, "README.md", "alias/README.md"); !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("canceled resolution error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestResolveDestinationIgnoresNeighboringFiles(t *testing.T) {
|
|
repository := newTreeRepository(t)
|
|
writeTreeFile(t, repository, "docs/page.md", []byte("source\n"))
|
|
if err := os.Symlink("../../neighbor/file", filepath.Join(repository, "docs", "outside")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
gitTree(t, repository, "add", "--all")
|
|
gitTree(t, repository, "commit", "-m", "capture outside link")
|
|
commit := strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD"))
|
|
tree, err := ReadCandidateTree(context.Background(), repository, commit)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
gotWithoutNeighbor, err := tree.ResolveDestination(context.Background(), "docs/page.md", "outside")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
neighbor := filepath.Join(filepath.Dir(repository), "neighbor", "file")
|
|
if err := os.MkdirAll(filepath.Dir(neighbor), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(neighbor, []byte("outside source\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
gotWithNeighbor, err := tree.ResolveDestination(context.Background(), "docs/page.md", "outside")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if gotWithNeighbor != gotWithoutNeighbor || gotWithNeighbor.Kind != DestinationOutsideCheckout {
|
|
t.Fatalf("neighbor changed resolution: absent=%#v present=%#v", gotWithoutNeighbor, gotWithNeighbor)
|
|
}
|
|
}
|
|
|
|
func TestResolveDestinationPreservesParentTraversalOrder(t *testing.T) {
|
|
repository := newTreeRepository(t)
|
|
for _, name := range []string{"README.md", "docs/file.md", "nested/file.md", "nested/inner/item.txt"} {
|
|
writeTreeFile(t, repository, name, []byte("captured\n"))
|
|
}
|
|
for name, target := range map[string]string{
|
|
"docs/link": "../nested/inner",
|
|
"docs/parent-link": "link/../file.md",
|
|
"escape": "../neighbor",
|
|
} {
|
|
if err := os.Symlink(target, filepath.Join(repository, filepath.FromSlash(name))); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
gitTree(t, repository, "add", "--all")
|
|
gitTree(t, repository, "commit", "-m", "capture parent traversal")
|
|
tree, err := ReadCandidateTree(t.Context(), repository, strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD")))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, test := range []struct {
|
|
destination string
|
|
kind DestinationKind
|
|
resolved string
|
|
}{
|
|
{"docs/link/../file.md", DestinationEntry, "nested/file.md"},
|
|
{"docs/parent-link", DestinationEntry, "nested/file.md"},
|
|
{"escape/../README.md", DestinationOutsideCheckout, ""},
|
|
{"missing/../README.md", DestinationMissing, ""},
|
|
{"README.md/../README.md", DestinationMissing, ""},
|
|
{"README.md/.", DestinationMissing, ""},
|
|
} {
|
|
t.Run(test.destination, func(t *testing.T) {
|
|
got, err := tree.ResolveDestination(t.Context(), "README.md", test.destination)
|
|
if err != nil || got.Kind != test.kind || (test.resolved != "" && got.Path != test.resolved) {
|
|
t.Fatalf("resolution = %#v, %v; want %s %q", got, err, test.kind, test.resolved)
|
|
}
|
|
})
|
|
}
|
|
before, err := tree.ResolveDestination(t.Context(), "README.md", "escape/../README.md")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
neighbor := filepath.Join(filepath.Dir(repository), "neighbor")
|
|
if err := os.MkdirAll(neighbor, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(neighbor, "file.md"), []byte("neighbor"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
after, err := tree.ResolveDestination(t.Context(), "README.md", "escape/../README.md")
|
|
if err != nil || before != after {
|
|
t.Fatalf("neighbor changed resolution: %#v -> %#v, %v", before, after, err)
|
|
}
|
|
}
|