diff --git a/internal/vfs/internal/internal.go b/internal/vfs/internal/internal.go index c761e20388..fd841de9bb 100644 --- a/internal/vfs/internal/internal.go +++ b/internal/vfs/internal/internal.go @@ -13,8 +13,8 @@ import ( ) type Common struct { - RootFor func(root string) fs.FS - Realpath func(path string) string + RootFor func(root string) fs.FS + IsReparsePoint func(path string) bool } func RootLength(p string) int { @@ -93,12 +93,12 @@ func (vfs *Common) GetAccessibleEntries(path string) (result vfs.Entries) { continue } - if entryType&fs.ModeIrregular != 0 && vfs.Realpath != nil { - // Could be a Windows junction. Try Realpath. - // TODO(jakebailey): use syscall.Win32FileAttributeData instead + if entryType&fs.ModeIrregular != 0 && vfs.IsReparsePoint != nil { + // Could be a Windows junction or other reparse point. + // Check using the OS-specific helper. fullPath := path + "/" + entry.Name() - if realpath := vfs.Realpath(fullPath); fullPath != realpath { - if stat := vfs.Stat(realpath); stat != nil { + if vfs.IsReparsePoint(fullPath) { + if stat := vfs.Stat(fullPath); stat != nil { addToResult(entry.Name(), stat.Mode()) } } diff --git a/internal/vfs/osvfs/helpers_test.go b/internal/vfs/osvfs/helpers_test.go new file mode 100644 index 0000000000..c1d4126f7e --- /dev/null +++ b/internal/vfs/osvfs/helpers_test.go @@ -0,0 +1,27 @@ +package osvfs + +import ( + "os" + "os/exec" + "runtime" + "strings" + "testing" + + "gotest.tools/v3/assert" +) + +func mklink(tb testing.TB, target, link string, isDir bool) { + tb.Helper() + + if runtime.GOOS == "windows" && isDir { + // Don't use os.Symlink on Windows, as it creates a "real" symlink, not a junction. + assert.NilError(tb, exec.Command("cmd", "/c", "mklink", "/J", link, target).Run()) + } else { + err := os.Symlink(target, link) + if err != nil && !isDir && runtime.GOOS == "windows" && strings.Contains(err.Error(), "A required privilege is not held by the client") { + tb.Log(err) + tb.Skip("file symlink support is not enabled without elevation or developer mode") + } + assert.NilError(tb, err) + } +} diff --git a/internal/vfs/osvfs/os.go b/internal/vfs/osvfs/os.go index d811d6ff71..81468db06d 100644 --- a/internal/vfs/osvfs/os.go +++ b/internal/vfs/osvfs/os.go @@ -21,8 +21,8 @@ func FS() vfs.FS { var osVFS vfs.FS = &osFS{ common: internal.Common{ - RootFor: os.DirFS, - Realpath: osFSRealpath, + RootFor: os.DirFS, + IsReparsePoint: isReparsePoint, }, } diff --git a/internal/vfs/osvfs/realpath_test.go b/internal/vfs/osvfs/realpath_test.go index e0f01c3173..b97911c6ea 100644 --- a/internal/vfs/osvfs/realpath_test.go +++ b/internal/vfs/osvfs/realpath_test.go @@ -4,8 +4,6 @@ import ( "os" "os/exec" "path/filepath" - "runtime" - "strings" "testing" "github.com/microsoft/typescript-go/internal/tspath" @@ -54,22 +52,6 @@ func setupSymlinks(tb testing.TB) (targetFile, linkFile string) { return targetFile, linkFile } -func mklink(tb testing.TB, target, link string, isDir bool) { - tb.Helper() - - if runtime.GOOS == "windows" && isDir { - // Don't use os.Symlink on Windows, as it creates a "real" symlink, not a junction. - assert.NilError(tb, exec.Command("cmd", "/c", "mklink", "/J", link, target).Run()) - } else { - err := os.Symlink(target, link) - if err != nil && !isDir && runtime.GOOS == "windows" && strings.Contains(err.Error(), "A required privilege is not held by the client") { - tb.Log(err) - tb.Skip("file symlink support is not enabled without elevation or developer mode") - } - assert.NilError(tb, err) - } -} - func BenchmarkRealpath(b *testing.B) { targetFile, linkFile := setupSymlinks(b) diff --git a/internal/vfs/osvfs/reparsepoint_other.go b/internal/vfs/osvfs/reparsepoint_other.go new file mode 100644 index 0000000000..1206b6bc36 --- /dev/null +++ b/internal/vfs/osvfs/reparsepoint_other.go @@ -0,0 +1,6 @@ +//go:build !windows + +package osvfs + +// Only Windows has reparse points; leave this nil for other OSes. +var isReparsePoint func(path string) bool diff --git a/internal/vfs/osvfs/reparsepoint_windows.go b/internal/vfs/osvfs/reparsepoint_windows.go new file mode 100644 index 0000000000..12a1e59cb0 --- /dev/null +++ b/internal/vfs/osvfs/reparsepoint_windows.go @@ -0,0 +1,29 @@ +package osvfs + +import ( + "syscall" + "unsafe" +) + +func isReparsePoint(path string) bool { + if len(path) >= 248 { + path = `\\?\` + path + } + + pathUTF16, err := syscall.UTF16PtrFromString(path) + if err != nil { + return false + } + + var data syscall.Win32FileAttributeData + err = syscall.GetFileAttributesEx( + pathUTF16, + syscall.GetFileExInfoStandard, + (*byte)(unsafe.Pointer(&data)), + ) + if err != nil { + return false + } + + return data.FileAttributes&syscall.FILE_ATTRIBUTE_REPARSE_POINT != 0 +} diff --git a/internal/vfs/osvfs/reparsepoint_windows_test.go b/internal/vfs/osvfs/reparsepoint_windows_test.go new file mode 100644 index 0000000000..c1dba9ecd8 --- /dev/null +++ b/internal/vfs/osvfs/reparsepoint_windows_test.go @@ -0,0 +1,169 @@ +package osvfs + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "gotest.tools/v3/assert" +) + +func TestIsReparsePoint(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + + t.Run("regular file", func(t *testing.T) { + t.Parallel() + file := filepath.Join(tmp, "regular.txt") + assert.NilError(t, os.WriteFile(file, []byte("hello"), 0o666)) + assert.Equal(t, isReparsePoint(file), false) + }) + + t.Run("regular directory", func(t *testing.T) { + t.Parallel() + dir := filepath.Join(tmp, "regular-dir") + assert.NilError(t, os.MkdirAll(dir, 0o777)) + assert.Equal(t, isReparsePoint(dir), false) + }) + + t.Run("junction point", func(t *testing.T) { + t.Parallel() + target := filepath.Join(tmp, "junction-target") + link := filepath.Join(tmp, "junction-link") + assert.NilError(t, os.MkdirAll(target, 0o777)) + mklink(t, target, link, true) + assert.Equal(t, isReparsePoint(link), true) + }) + + t.Run("file symlink", func(t *testing.T) { + t.Parallel() + target := filepath.Join(tmp, "symlink-target.txt") + link := filepath.Join(tmp, "symlink-link.txt") + assert.NilError(t, os.WriteFile(target, []byte("hello"), 0o666)) + mklink(t, target, link, false) + assert.Equal(t, isReparsePoint(link), true) + }) + + t.Run("directory symlink", func(t *testing.T) { + t.Parallel() + target := filepath.Join(tmp, "dir-symlink-target") + link := filepath.Join(tmp, "dir-symlink-link") + assert.NilError(t, os.MkdirAll(target, 0o777)) + mklink(t, target, link, false) + assert.Equal(t, isReparsePoint(link), true) + }) + + t.Run("nonexistent path", func(t *testing.T) { + t.Parallel() + nonexistent := filepath.Join(tmp, "does-not-exist") + assert.Equal(t, isReparsePoint(nonexistent), false) + }) + + t.Run("empty path", func(t *testing.T) { + t.Parallel() + assert.Equal(t, isReparsePoint(""), false) + }) + + t.Run("invalid path with null byte", func(t *testing.T) { + t.Parallel() + assert.Equal(t, isReparsePoint("invalid\x00path"), false) + }) +} + +func TestIsReparsePointLongPath(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + + // Create a deeply nested path that exceeds 248 characters + longPathBase := tmp + pathComponent := "very_long_directory_name_to_exceed_max_path_limit_abcdefghijklmnopqrstuvwxyz" + + for len(longPathBase) < 250 { + longPathBase = filepath.Join(longPathBase, pathComponent) + } + + target := filepath.Join(longPathBase, "target") + link := filepath.Join(longPathBase, "link") + + // Use \\?\ prefix to enable long path support for mklink + longTarget := `\\?\` + target + longLink := `\\?\` + link + + assert.NilError(t, os.MkdirAll(longTarget, 0o777)) + assert.NilError(t, exec.Command("cmd", "/c", "mklink", "/J", longLink, longTarget).Run()) + + // With long path support enabled, this should work even for paths >= 248 chars + assert.Equal(t, isReparsePoint(link), true) +} + +func TestIsReparsePointNestedInSymlink(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + + // Create a structure: target/inner-target, link -> target, then check link/inner-link + target := filepath.Join(tmp, "target") + innerTarget := filepath.Join(target, "inner-target") + assert.NilError(t, os.MkdirAll(innerTarget, 0o777)) + + link := filepath.Join(tmp, "link") + mklink(t, target, link, true) + + // Create a junction inside the target + innerLink := filepath.Join(target, "inner-link") + mklink(t, innerTarget, innerLink, true) + + // Check the junction through the symlink path + nestedPath := filepath.Join(link, "inner-link") + assert.Equal(t, isReparsePoint(nestedPath), true) +} + +func TestIsReparsePointRelativePath(t *testing.T) { //nolint:paralleltest // Cannot use t.Parallel() with t.Chdir() + tmp := t.TempDir() + t.Chdir(tmp) + + target := "target-rel" + link := "link-rel" + assert.NilError(t, os.MkdirAll(target, 0o777)) + mklink(t, target, link, true) + + assert.Equal(t, isReparsePoint(link), true) + assert.Equal(t, isReparsePoint(target), false) +} + +func BenchmarkIsSymlinkOrJunction(b *testing.B) { + tmp := b.TempDir() + + regularFile := filepath.Join(tmp, "regular.txt") + assert.NilError(b, os.WriteFile(regularFile, []byte("hello"), 0o666)) + + target := filepath.Join(tmp, "target") + link := filepath.Join(tmp, "link") + assert.NilError(b, os.MkdirAll(target, 0o777)) + assert.NilError(b, exec.Command("cmd", "/c", "mklink", "/J", link, target).Run()) + + b.Run("regular file", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + isReparsePoint(regularFile) + } + }) + + b.Run("junction", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + isReparsePoint(link) + } + }) + + b.Run("nonexistent", func(b *testing.B) { + b.ReportAllocs() + nonexistent := filepath.Join(tmp, "does-not-exist") + for b.Loop() { + isReparsePoint(nonexistent) + } + }) +}