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) } cleanupCandidateTree(t, tree) 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) } cleanupCandidateTree(t, tree) 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) } cleanupCandidateTree(t, tree) 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) } cleanupCandidateTree(t, tree) 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) } } func TestResolveDestinationMapsAbsolutePathsIntoCandidate(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{ "absolute-file": filepath.ToSlash(filepath.Join(repository, "README.md")), "absolute-directory": filepath.ToSlash(filepath.Join(repository, "nested", "inner")), "absolute-outside": filepath.ToSlash(repository + "-neighbor/README.md"), "docs/link": "../nested/inner", "absolute-parent": filepath.ToSlash(repository) + "/docs/link/../file.md", } { 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 absolute links") tree, err := ReadCandidateTree(t.Context(), repository, strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD"))) if err != nil { t.Fatal(err) } cleanupCandidateTree(t, tree) if err := os.Remove(filepath.Join(repository, "README.md")); err != nil { t.Fatal(err) } writeTreeFile(t, repository, "live-only.md", []byte("not committed")) neighbor := repository + "-neighbor" if err := os.MkdirAll(neighbor, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(neighbor, "README.md"), []byte("outside"), 0o644); err != nil { t.Fatal(err) } t.Cleanup(func() { if err := os.RemoveAll(neighbor); err != nil { t.Error(err) } }) root := filepath.ToSlash(repository) for _, test := range []struct { destination string kind DestinationKind resolved string }{ {root + "/README.md", DestinationEntry, "README.md"}, {root, DestinationDirectory, "."}, {root + "/nested/inner", DestinationDirectory, "nested/inner"}, {"absolute-file", DestinationEntry, "README.md"}, {"absolute-directory/item.txt", DestinationEntry, "nested/inner/item.txt"}, {root + "/docs/link/../file.md", DestinationEntry, "nested/file.md"}, {"absolute-parent", DestinationEntry, "nested/file.md"}, {root + "/live-only.md", DestinationMissing, "live-only.md"}, {root + "-neighbor/README.md", DestinationOutsideCheckout, root + "-neighbor/README.md"}, {"absolute-outside", DestinationOutsideCheckout, root + "-neighbor/README.md"}, } { 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 || got.Path != test.resolved { t.Fatalf("resolution = %#v, %v; want %s %q", got, err, test.kind, test.resolved) } }) } }