From dd027bbd285936d0c2d321e6f1ea9909b13dba22 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Mon, 3 Nov 2025 19:16:28 -0800 Subject: [PATCH 01/11] Replace Realpath with more efficient GetFileAttributesEx --- internal/vfs/internal/internal.go | 14 +++++++------- internal/vfs/osvfs/os.go | 4 ++-- internal/vfs/osvfs/symlink_other.go | 10 ++++++++++ internal/vfs/osvfs/symlink_windows.go | 28 +++++++++++++++++++++++++++ 4 files changed, 47 insertions(+), 9 deletions(-) create mode 100644 internal/vfs/osvfs/symlink_other.go create mode 100644 internal/vfs/osvfs/symlink_windows.go diff --git a/internal/vfs/internal/internal.go b/internal/vfs/internal/internal.go index c761e20388..5376a91182 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 + IsSymlinkOrJunction 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.IsSymlinkOrJunction != 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.IsSymlinkOrJunction(fullPath) { + if stat := vfs.Stat(fullPath); stat != nil { addToResult(entry.Name(), stat.Mode()) } } diff --git a/internal/vfs/osvfs/os.go b/internal/vfs/osvfs/os.go index d811d6ff71..fd37954693 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, + IsSymlinkOrJunction: isSymlinkOrJunction, }, } diff --git a/internal/vfs/osvfs/symlink_other.go b/internal/vfs/osvfs/symlink_other.go new file mode 100644 index 0000000000..19909e5cf5 --- /dev/null +++ b/internal/vfs/osvfs/symlink_other.go @@ -0,0 +1,10 @@ +//go:build !windows + +package osvfs + +// isSymlinkOrJunction always returns false on non-Windows platforms. +// On Unix-like systems, symlinks are already properly detected by the +// fs.ModeSymlink bit in the directory entry type, so this check is not needed. +func isSymlinkOrJunction(path string) bool { + return false +} diff --git a/internal/vfs/osvfs/symlink_windows.go b/internal/vfs/osvfs/symlink_windows.go new file mode 100644 index 0000000000..9a4ffc921d --- /dev/null +++ b/internal/vfs/osvfs/symlink_windows.go @@ -0,0 +1,28 @@ +package osvfs + +import ( + "syscall" + "unsafe" +) + +// isSymlinkOrJunction checks if the given path is a symlink or junction point +// on Windows by checking the FILE_ATTRIBUTE_REPARSE_POINT attribute. +// This is more efficient than calling realpath and comparing paths. +func isSymlinkOrJunction(path string) bool { + 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 +} From 84a02445d1914f6c35193348581cce52560e0847 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Mon, 3 Nov 2025 19:29:40 -0800 Subject: [PATCH 02/11] Nil it out, comment --- internal/vfs/osvfs/symlink_other.go | 5 +---- internal/vfs/osvfs/symlink_windows.go | 1 - 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/internal/vfs/osvfs/symlink_other.go b/internal/vfs/osvfs/symlink_other.go index 19909e5cf5..799c60e22f 100644 --- a/internal/vfs/osvfs/symlink_other.go +++ b/internal/vfs/osvfs/symlink_other.go @@ -2,9 +2,6 @@ package osvfs -// isSymlinkOrJunction always returns false on non-Windows platforms. // On Unix-like systems, symlinks are already properly detected by the // fs.ModeSymlink bit in the directory entry type, so this check is not needed. -func isSymlinkOrJunction(path string) bool { - return false -} +var isSymlinkOrJunction func(path string) bool diff --git a/internal/vfs/osvfs/symlink_windows.go b/internal/vfs/osvfs/symlink_windows.go index 9a4ffc921d..66581fccce 100644 --- a/internal/vfs/osvfs/symlink_windows.go +++ b/internal/vfs/osvfs/symlink_windows.go @@ -7,7 +7,6 @@ import ( // isSymlinkOrJunction checks if the given path is a symlink or junction point // on Windows by checking the FILE_ATTRIBUTE_REPARSE_POINT attribute. -// This is more efficient than calling realpath and comparing paths. func isSymlinkOrJunction(path string) bool { pathUTF16, err := syscall.UTF16PtrFromString(path) if err != nil { From 2527991c17544e5a6862a67b1ca309324eb2d96e Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Sun, 9 Nov 2025 23:18:54 -0800 Subject: [PATCH 03/11] Tessts --- internal/vfs/osvfs/symlink_windows.go | 5 + internal/vfs/osvfs/symlink_windows_test.go | 176 +++++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 internal/vfs/osvfs/symlink_windows_test.go diff --git a/internal/vfs/osvfs/symlink_windows.go b/internal/vfs/osvfs/symlink_windows.go index 66581fccce..70af059901 100644 --- a/internal/vfs/osvfs/symlink_windows.go +++ b/internal/vfs/osvfs/symlink_windows.go @@ -8,6 +8,11 @@ import ( // isSymlinkOrJunction checks if the given path is a symlink or junction point // on Windows by checking the FILE_ATTRIBUTE_REPARSE_POINT attribute. func isSymlinkOrJunction(path string) bool { + // TODO: show that this works + // if len(path) >= 248 { + // path = `\\?\` + path + // } + pathUTF16, err := syscall.UTF16PtrFromString(path) if err != nil { return false diff --git a/internal/vfs/osvfs/symlink_windows_test.go b/internal/vfs/osvfs/symlink_windows_test.go new file mode 100644 index 0000000000..c8aff0afd0 --- /dev/null +++ b/internal/vfs/osvfs/symlink_windows_test.go @@ -0,0 +1,176 @@ +package osvfs + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "gotest.tools/v3/assert" +) + +func TestIsSymlinkOrJunction(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + + t.Run("regular file", func(t *testing.T) { + file := filepath.Join(tmp, "regular.txt") + assert.NilError(t, os.WriteFile(file, []byte("hello"), 0o666)) + assert.Equal(t, isSymlinkOrJunction(file), false) + }) + + t.Run("regular directory", func(t *testing.T) { + dir := filepath.Join(tmp, "regular-dir") + assert.NilError(t, os.MkdirAll(dir, 0o777)) + assert.Equal(t, isSymlinkOrJunction(dir), false) + }) + + t.Run("junction point", func(t *testing.T) { + 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, isSymlinkOrJunction(link), true) + }) + + t.Run("file symlink", func(t *testing.T) { + target := filepath.Join(tmp, "symlink-target.txt") + link := filepath.Join(tmp, "symlink-link.txt") + assert.NilError(t, os.WriteFile(target, []byte("hello"), 0o666)) + + err := os.Symlink(target, link) + if err != nil && strings.Contains(err.Error(), "A required privilege is not held by the client") { + t.Log(err) + t.Skip("file symlink support is not enabled without elevation or developer mode") + } + assert.NilError(t, err) + assert.Equal(t, isSymlinkOrJunction(link), true) + }) + + t.Run("directory symlink", func(t *testing.T) { + target := filepath.Join(tmp, "dir-symlink-target") + link := filepath.Join(tmp, "dir-symlink-link") + assert.NilError(t, os.MkdirAll(target, 0o777)) + assert.NilError(t, os.Symlink(target, link)) + assert.Equal(t, isSymlinkOrJunction(link), true) + }) + + t.Run("nonexistent path", func(t *testing.T) { + nonexistent := filepath.Join(tmp, "does-not-exist") + assert.Equal(t, isSymlinkOrJunction(nonexistent), false) + }) + + t.Run("empty path", func(t *testing.T) { + assert.Equal(t, isSymlinkOrJunction(""), false) + }) + + t.Run("invalid path with null byte", func(t *testing.T) { + assert.Equal(t, isSymlinkOrJunction("invalid\x00path"), false) + }) +} + +func TestIsSymlinkOrJunctionLongPath(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, isSymlinkOrJunction(link), true) +} + +func TestIsSymlinkOrJunctionNestedInSymlink(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, isSymlinkOrJunction(nestedPath), true) +} + +func TestIsSymlinkOrJunctionRelativePath(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + originalWd, err := os.Getwd() + assert.NilError(t, err) + defer func() { + assert.NilError(t, os.Chdir(originalWd)) + }() + + assert.NilError(t, os.Chdir(tmp)) + + target := "target-rel" + link := "link-rel" + assert.NilError(t, os.MkdirAll(target, 0o777)) + mklink(t, target, link, true) + + assert.Equal(t, isSymlinkOrJunction(link), true) + assert.Equal(t, isSymlinkOrJunction(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() { + isSymlinkOrJunction(regularFile) + } + }) + + b.Run("junction", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + isSymlinkOrJunction(link) + } + }) + + b.Run("nonexistent", func(b *testing.B) { + b.ReportAllocs() + nonexistent := filepath.Join(tmp, "does-not-exist") + for b.Loop() { + isSymlinkOrJunction(nonexistent) + } + }) +} From 8e945e422a9f873a87e50f3b3e615d8c4795f597 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Mon, 10 Nov 2025 10:21:47 -0800 Subject: [PATCH 04/11] Fix tests a little --- internal/vfs/osvfs/helpers_test.go | 27 ++++++++++++++++++++++ internal/vfs/osvfs/realpath_test.go | 18 --------------- internal/vfs/osvfs/symlink_windows_test.go | 11 ++------- 3 files changed, 29 insertions(+), 27 deletions(-) create mode 100644 internal/vfs/osvfs/helpers_test.go 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/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/symlink_windows_test.go b/internal/vfs/osvfs/symlink_windows_test.go index c8aff0afd0..18ebc9722c 100644 --- a/internal/vfs/osvfs/symlink_windows_test.go +++ b/internal/vfs/osvfs/symlink_windows_test.go @@ -4,7 +4,6 @@ import ( "os" "os/exec" "path/filepath" - "strings" "testing" "gotest.tools/v3/assert" @@ -39,13 +38,7 @@ func TestIsSymlinkOrJunction(t *testing.T) { target := filepath.Join(tmp, "symlink-target.txt") link := filepath.Join(tmp, "symlink-link.txt") assert.NilError(t, os.WriteFile(target, []byte("hello"), 0o666)) - - err := os.Symlink(target, link) - if err != nil && strings.Contains(err.Error(), "A required privilege is not held by the client") { - t.Log(err) - t.Skip("file symlink support is not enabled without elevation or developer mode") - } - assert.NilError(t, err) + mklink(t, target, link, false) assert.Equal(t, isSymlinkOrJunction(link), true) }) @@ -53,7 +46,7 @@ func TestIsSymlinkOrJunction(t *testing.T) { target := filepath.Join(tmp, "dir-symlink-target") link := filepath.Join(tmp, "dir-symlink-link") assert.NilError(t, os.MkdirAll(target, 0o777)) - assert.NilError(t, os.Symlink(target, link)) + mklink(t, target, link, false) assert.Equal(t, isSymlinkOrJunction(link), true) }) From 7c7e29bd2bfc5efb47f0a399a42f8d8b6d8c3dec Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Mon, 10 Nov 2025 10:27:28 -0800 Subject: [PATCH 05/11] lint --- internal/vfs/osvfs/symlink_windows_test.go | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/internal/vfs/osvfs/symlink_windows_test.go b/internal/vfs/osvfs/symlink_windows_test.go index 18ebc9722c..106ef81ccb 100644 --- a/internal/vfs/osvfs/symlink_windows_test.go +++ b/internal/vfs/osvfs/symlink_windows_test.go @@ -15,18 +15,21 @@ func TestIsSymlinkOrJunction(t *testing.T) { 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, isSymlinkOrJunction(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, isSymlinkOrJunction(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)) @@ -35,6 +38,7 @@ func TestIsSymlinkOrJunction(t *testing.T) { }) 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)) @@ -43,6 +47,7 @@ func TestIsSymlinkOrJunction(t *testing.T) { }) 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)) @@ -51,15 +56,18 @@ func TestIsSymlinkOrJunction(t *testing.T) { }) t.Run("nonexistent path", func(t *testing.T) { + t.Parallel() nonexistent := filepath.Join(tmp, "does-not-exist") assert.Equal(t, isSymlinkOrJunction(nonexistent), false) }) t.Run("empty path", func(t *testing.T) { + t.Parallel() assert.Equal(t, isSymlinkOrJunction(""), false) }) t.Run("invalid path with null byte", func(t *testing.T) { + t.Parallel() assert.Equal(t, isSymlinkOrJunction("invalid\x00path"), false) }) } @@ -113,17 +121,9 @@ func TestIsSymlinkOrJunctionNestedInSymlink(t *testing.T) { assert.Equal(t, isSymlinkOrJunction(nestedPath), true) } -func TestIsSymlinkOrJunctionRelativePath(t *testing.T) { - t.Parallel() - +func TestIsSymlinkOrJunctionRelativePath(t *testing.T) { //nolint:paralleltest // Cannot use t.Parallel() with t.Chdir() tmp := t.TempDir() - originalWd, err := os.Getwd() - assert.NilError(t, err) - defer func() { - assert.NilError(t, os.Chdir(originalWd)) - }() - - assert.NilError(t, os.Chdir(tmp)) + t.Chdir(tmp) target := "target-rel" link := "link-rel" From bbe99688022843beacee05ce9c4b54246f6912fb Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Mon, 10 Nov 2025 11:27:39 -0800 Subject: [PATCH 06/11] Install microsoft go fork to see if CI catches it --- .github/actions/setup-go/action.yml | 39 +- .github/actions/setup-go/go-install.ps1 | 899 ++++++++++++++++++++++++ go.mod | 2 + go.sum | 2 + 4 files changed, 937 insertions(+), 5 deletions(-) create mode 100644 .github/actions/setup-go/go-install.ps1 diff --git a/.github/actions/setup-go/action.yml b/.github/actions/setup-go/action.yml index aa4d0ea3b7..0dd477dc06 100644 --- a/.github/actions/setup-go/action.yml +++ b/.github/actions/setup-go/action.yml @@ -4,7 +4,7 @@ description: Setup Go inputs: go-version: description: Go version range to set up. - default: '>=1.25.0' + default: 'go1.25' create: description: Create the cache default: 'false' @@ -18,10 +18,39 @@ runs: steps: - name: Install Go id: install-go - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 - with: - go-version: ${{ inputs.go-version }} - cache: false + shell: pwsh + run: | + ${{ github.action_path }}/go-install.ps1 -Version ${{ inputs.go-version }} + + $goVersionOutput = go version + Write-Host $goVersionOutput + # Extract version like "1.23.4" from "go version go1.23.4 windows/amd64" + if ($goVersionOutput -match 'go version go([\d\.]+)') { + $exactVersion = $matches[1] + "go-version=$exactVersion" >> $env:GITHUB_OUTPUT + Write-Host "Exact Go version: $exactVersion" + } else { + Write-Error "Failed to parse Go version from: $goVersionOutput" + exit 1 + } + + # Add Go bin directory to PATH for subsequent steps + # TODO: remove once go-install.ps1 supports GITHUB_PATH + $goRoot = go env GOROOT + $goBinPath = Join-Path $goRoot "bin" + Write-Host "Adding to GITHUB_PATH: $goBinPath" + $goBinPath >> $env:GITHUB_PATH + + - name: Verify Microsoft Go + shell: pwsh + run: | + $goPath = (Get-Command go).Source + Write-Host "Go executable path: $goPath" + if ($goPath -notlike "*microsoft-go*") { + Write-Error "Go installation is not from microsoft-go. Path: $goPath" + exit 1 + } + Write-Host "✓ Verified: Microsoft Go is active" # Avoid hardcoding the cache keys more than once. - name: Get cache info diff --git a/.github/actions/setup-go/go-install.ps1 b/.github/actions/setup-go/go-install.ps1 new file mode 100644 index 0000000000..ddebb164aa --- /dev/null +++ b/.github/actions/setup-go/go-install.ps1 @@ -0,0 +1,899 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +<# +.SYNOPSIS + Installs Microsoft build of Go +.DESCRIPTION + Installs the Microsoft build of Go toolset. + + Note that the intended use of this script is for Continuous Integration (CI) scenarios, where: + - The toolset needs to be installed without user interaction and without admin rights. + - The toolset installation doesn't need to persist across multiple CI runs. + Visit https://github.com/microsoft/go for a list of other ways to install Microsoft build of Go. + +.PARAMETER Version + Default: Latest + Download the specified version. Supports some aliases. Possible values: + - Latest - the most recent major version. + - Previous - the second most recent major version. + - 2-part version in format go1.A - represents a specific major version. + examples: go1.18, go1.23 + - 3-part version in format go1.A.B - latest revision of a specific release. + examples: go1.18.0, go1.23.1 + - 4-part version in format go1.A.B-C - a specific revision of Microsoft build of Go, immutable. + examples: go1.18.0-1, go1.23.1-3 + Microsoft build of Go doesn't publish prereleases, so they are not available. +.PARAMETER InstallDir + Path to where to install Microsoft build of Go. Note that if a directory is given, GOROOT is placed + directly in that directory. + Default: - a folder automatically selected inside LocalApplicationData as evaluated by PowerShell. + Example auto on Windows: C:\Users\myself\AppData\Local\microsoft-go\ + Example auto on Linux: /home/myself/.local/share/microsoft-go/ + If OS or Architecture are not , the path includes OS and Architecture. This avoids + overlapping installations but still allows for a shorter path for ordinary situations. +.PARAMETER OS + Default: - this value represents currently running OS + Operating system of prebuilt toolset binaries to be installed. + Possible values are: , windows, linux, darwin +.PARAMETER Architecture + Default: - this value represents currently running OS architecture + Architecture of prebuilt toolset binaries to be installed. + Possible values are: , amd64, x64, 386, x86, arm64, arm +.PARAMETER DryRun + If set, it will not perform installation. Instead, it displays what command line to use to + consistently install currently requested version of Microsoft build of Go. For example, if you specify + Version 'Latest', it will print a command with the specific 4-part version so this command can + be used deterministicly in a build script. + It also prints the location the binaries would have been installed to. +.PARAMETER NoPath + By default, this script will update the environment variable PATH for the current process to + include the binaries folder inside installation folder. + If set, it will print the binaries location but not set any environment variable. +.PARAMETER AzurePipelinePath + If set, it will print an Azure DevOps logging command that causes the Azure DevOps to update the + PATH environment variable of subsequent build steps to include the binaries folder. +.PARAMETER ProxyAddress + If set, it will use the proxy when making web requests +.PARAMETER ProxyUseDefaultCredentials + Default: false + Use default credentials when using ProxyAddress. +.PARAMETER ProxyBypassList + If set, when using ProxyAddress, this comma separated url list is passed to the underlying + HttpClientHandler. +.PARAMETER DownloadTimeout + Determines timeout duration in seconds for downloading the toolset file. + Default: 1200 seconds (20 minutes) +.PARAMETER KeepArchive + If set, the downloaded file is kept. +.PARAMETER ArchivePath + A path to use to store the toolset archive file, a zip or tar.gz. + Default: a generated random filename in the system's temporary directory. +.PARAMETER Help + Displays this help message. +.PARAMETER Verbose + Displays diagnostics information. +.EXAMPLE + go-install.ps1 + Installs the latest released Microsoft build of Go version. +.EXAMPLE + go-install.ps1 -Version Previous + Installs the latest version of the previous major (1.X) version of Microsoft build of Go. +#> +[cmdletbinding()] +param( + [string]$Version="Latest", + [Alias('i')][string]$InstallDir="", + [string]$OS="", + [string]$Architecture="", + [switch]$DryRun, + [switch]$NoPath, + [switch]$AzurePipelinePath, + [string]$ProxyAddress, + [switch]$ProxyUseDefaultCredentials, + [string[]]$ProxyBypassList=@(), + [int]$DownloadTimeout=1200, + [switch]$KeepArchive, + [string]$ArchivePath, + [switch]$Help +) + +Set-StrictMode -Version Latest +$ErrorActionPreference="Stop" +$ProgressPreference="SilentlyContinue" + +$MicrosoftGoInstallScriptVersion = "0.0.1" + +function Say($str) { + try { + Write-Host "go-install: $str" + } + catch { + # Some platforms cannot utilize Write-Host (Azure Functions, for instance). Fall back to Write-Output + Write-Output "go-install: $str" + } +} + +function Say-Warning($str) { + try { + Write-Warning "go-install: $str" + } + catch { + # Some platforms cannot utilize Write-Warning (Azure Functions, for instance). Fall back to Write-Output + Write-Output "go-install: Warning: $str" + } +} + +# Writes a line with error style settings. +# Use this function to show a human-readable comment along with an exception. +function Say-Error($str) { + try { + # Write-Error is quite oververbose for the purpose of the function, let's write one line with error style settings. + $Host.UI.WriteErrorLine("go-install: $str") + } + catch { + Write-Output "go-install: Error: $str" + } +} + +function Say-Verbose($str) { + try { + Write-Verbose "go-install: $str" + } + catch { + # Some platforms cannot utilize Write-Verbose (Azure Functions, for instance). Fall back to Write-Output + Write-Output "go-install: $str" + } +} + +function Measure-Action($name, $block) { + $time = Measure-Command $block + $totalSeconds = $time.TotalSeconds + Say-Verbose "⏱ Action '$name' took $totalSeconds seconds" +} + +function Get-Remote-File-Size($zipUri) { + try { + $response = Invoke-WebRequest -Uri $zipUri -Method Head + $fileSize = $response.Headers["Content-Length"] + if ((![string]::IsNullOrEmpty($fileSize))) { + Say "Remote file $zipUri size is $fileSize bytes." + + return $fileSize + } + } + catch { + Say-Verbose "Content-Length header was not extracted for $zipUri." + } + + return $null +} + +function Say-Invocation($Invocation) { + $command = $Invocation.MyCommand; + $args = (($Invocation.BoundParameters.Keys | foreach { "-$_ `"$($Invocation.BoundParameters[$_])`"" }) -join " ") + Say-Verbose "$command $args" +} + +function Invoke-With-Retry([ScriptBlock]$ScriptBlock, [System.Threading.CancellationToken]$cancellationToken = [System.Threading.CancellationToken]::None, [int]$MaxAttempts = 3, [int]$SecondsBetweenAttempts = 1) { + $Attempts = 0 + $local:startTime = $(get-date) + + while ($true) { + try { + return & $ScriptBlock + } + catch { + $Attempts++ + if (($Attempts -lt $MaxAttempts) -and -not $cancellationToken.IsCancellationRequested) { + Start-Sleep $SecondsBetweenAttempts + } + else { + $local:elapsedTime = $(get-date) - $local:startTime + if (($local:elapsedTime.TotalSeconds - $DownloadTimeout) -gt 0 -and -not $cancellationToken.IsCancellationRequested) { + throw New-Object System.TimeoutException("Failed to reach the server: connection timeout: default timeout is $DownloadTimeout second(s)"); + } + throw; + } + } + } +} + +function Get-Machine-Architecture() { + Say-Invocation $MyInvocation + + # Try the .NET API. If we don't get anything, this is probably PowerShell on Windows. + try { + $Architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture + if ($Architecture) { + # Possible values: https://learn.microsoft.com/en-us/dotnet/api/system.runtime.interopservices.architecture + return $Architecture.ToString().ToLowerInvariant() + } + } + catch { + Say-Verbose "Failed to get the machine architecture using .NET API. Falling back to environment variables." + } + + # On PS x86, PROCESSOR_ARCHITECTURE reports x86 even on x64 systems. + # To get the correct architecture, we need to use PROCESSOR_ARCHITEW6432. + # PS x64 doesn't define this, so we fall back to PROCESSOR_ARCHITECTURE. + # Possible values: amd64, x64, x86, arm64, arm + if( $ENV:PROCESSOR_ARCHITEW6432 -ne $null ) { + return $ENV:PROCESSOR_ARCHITEW6432 + } + + return $ENV:PROCESSOR_ARCHITECTURE +} + +function Get-CLIArchitecture-From-Architecture([string]$Architecture) { + Say-Invocation $MyInvocation + + if ($Architecture -eq "") { + $Architecture = Get-Machine-Architecture + } + + switch ($Architecture.ToLowerInvariant()) { + { ($_ -eq "amd64") -or ($_ -eq "x64") } { return "amd64" } + { ($_ -eq "386") -or ($_ -eq "x86") } { return "386" } + { $_ -eq "arm" } { return "armv6l" } + { $_ -eq "arm64" } { return "arm64" } + default { throw "Architecture '$Architecture' not supported. If you think this is a bug, report it at https://github.com/microsoft/go/issues" } + } +} + +function Get-CLIOS-From-OS([string]$OS) { + Say-Invocation $MyInvocation + + if (!(Test-Path -LiteralPath 'variable:IsWindows')) { + # If we don't have IsWindows, this is Windows PowerShell (powershell), not PowerShell Core (pwsh). + # So, we can't use the variable, but we know we're on Windows. + $IsWindows = $true + } + + if ($OS -eq "") { + if ($IsWindows -or [System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) { + $OS = "windows" + } + elseif ($IsLinux) { + $OS = "linux" + } + elseif ($IsMacOS) { + $OS = "darwin" + } + else { + throw "Unable to automatically determine the OS." + } + } + + switch ($OS.ToLowerInvariant()) { + { $_ -eq "windows" } { return "windows" } + { $_ -eq "linux" } { return "linux" } + { $_ -eq "darwin" } { return "darwin" } + default { throw "OS '$OS' not supported. If you think this is a bug, report it at https://github.com/microsoft/go/issues" } + } +} + +function Get-GeneratedArchivePath([string]$CLIOS) { + Say-Invocation $MyInvocation + + $Extension = switch ($CLIOS) { + "windows" { ".zip" } + default { ".tar.gz" } + } + + return [System.IO.Path]::combine([System.IO.Path]::GetTempPath(), [System.IO.Path]::GetRandomFileName()) + $Extension +} + +function Fetch-SupportedVersion([string]$StableKey) { + # Figure out what's latest by querying the list of release branches. + $ReleaseBranchData = DownloadJson "https://aka.ms/golang/release/latest/release-branch-links.json" + + # Find first thing in the array of objects where the key by name is true. + foreach ($branch in $ReleaseBranchData) { + if (Get-OrNull $branch $StableKey) { + return $branch.version + } + } + + throw "Failed to find a branch where '$StableKey' is true." +} + +function Get-NormalizedVersion([string]$Version) { + Say-Invocation $MyInvocation + + if ([string]::IsNullOrEmpty($Version)) { + return "" + } + switch ($Version.ToLowerInvariant()) { + { $_ -eq "latest" } { return Fetch-SupportedVersion -StableKey "latestStable" } + { $_ -eq "previous" } { return Fetch-SupportedVersion -StableKey "previousStable" } + { $_ -like "go1.*" } { return $_ } + default { throw "Version '$Version' not recognized. Missing 'go' prefix? If you think this is a bug, report it at https://github.com/microsoft/go/issues" } + } +} + +function Load-Assembly([string] $Assembly) { + try { + Add-Type -Assembly $Assembly | Out-Null + } + catch { + # On Nano Server, Powershell Core Edition is used. Add-Type is unable to resolve base class assemblies because they are not GAC'd. + # Loading the base class assemblies is not unnecessary as the types will automatically get resolved. + } +} + +function GetHTTPResponse([Uri] $Uri, [bool]$HeaderOnly, [bool]$DisableRedirect) +{ + $cts = New-Object System.Threading.CancellationTokenSource + + $downloadScript = { + + $HttpClient = $null + + try { + # HttpClient is used vs Invoke-WebRequest in order to support Nano Server which doesn't support the Invoke-WebRequest cmdlet. + Load-Assembly -Assembly System.Net.Http + + if (-not $ProxyAddress) { + try { + # Despite no proxy being explicitly specified, we may still be behind a default proxy + $DefaultProxy = [System.Net.WebRequest]::DefaultWebProxy; + if($DefaultProxy -and (-not $DefaultProxy.IsBypassed($Uri))) { + if ($null -ne $DefaultProxy.GetProxy($Uri)) { + $ProxyAddress = $DefaultProxy.GetProxy($Uri).OriginalString + } else { + $ProxyAddress = $null + } + $ProxyUseDefaultCredentials = $true + } + } + catch { + # Eat the exception and move forward as the above code is an attempt + # at resolving the DefaultProxy that may not have been a problem. + $ProxyAddress = $null + Say-Verbose("Exception ignored: $_.Exception.Message - moving forward...") + } + } + + $HttpClientHandler = New-Object System.Net.Http.HttpClientHandler + if ($ProxyAddress) { + $HttpClientHandler.Proxy = New-Object System.Net.WebProxy -Property @{ + Address=$ProxyAddress; + UseDefaultCredentials=$ProxyUseDefaultCredentials; + BypassList = $ProxyBypassList; + } + } + if ($DisableRedirect) { + $HttpClientHandler.AllowAutoRedirect = $false + } + $HttpClient = New-Object System.Net.Http.HttpClient -ArgumentList $HttpClientHandler + + # Default timeout for HttpClient is 100s. For a 50 MB download this assumes 500 KB/s average, any less will time out + # Defaulting to 20 minutes allows it to work over much slower connections. + $HttpClient.Timeout = New-TimeSpan -Seconds $DownloadTimeout + + if ($HeaderOnly){ + $completionOption = [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead + } + else { + $completionOption = [System.Net.Http.HttpCompletionOption]::ResponseContentRead + } + + $Task = $HttpClient.GetAsync("$Uri", $completionOption).ConfigureAwait("false"); + $Response = $Task.GetAwaiter().GetResult(); + + if (($null -eq $Response) -or ((-not $HeaderOnly) -and (-not ($Response.IsSuccessStatusCode)))) { + # The feed credential is potentially sensitive info. Do not log FeedCredential to console output. + $DownloadException = [System.Exception] "Unable to download $Uri." + + if ($null -ne $Response) { + $DownloadException.Data["StatusCode"] = [int] $Response.StatusCode + $DownloadException.Data["ErrorMessage"] = "Unable to download $Uri. Returned HTTP status code: " + $DownloadException.Data["StatusCode"] + + if (404 -eq [int] $Response.StatusCode) { + $cts.Cancel() + } + } + + throw $DownloadException + } + + return $Response + } + catch [System.Net.Http.HttpRequestException] { + $DownloadException = [System.Exception] "Unable to download $Uri." + + # Pick up the exception message and inner exceptions' messages if they exist + $CurrentException = $PSItem.Exception + $ErrorMsg = $CurrentException.Message + "`r`n" + while ($CurrentException.InnerException) { + $CurrentException = $CurrentException.InnerException + $ErrorMsg += $CurrentException.Message + "`r`n" + } + + # Check if there is an issue concerning TLS. + if ($ErrorMsg -like "*SSL/TLS*") { + $ErrorMsg += "Ensure that TLS 1.2 or higher is enabled to use this script.`r`n" + } + + $DownloadException.Data["ErrorMessage"] = $ErrorMsg + throw $DownloadException + } + finally { + if ($null -ne $HttpClient) { + $HttpClient.Dispose() + } + } + } + + try { + return Invoke-With-Retry $downloadScript $cts.Token + } + finally { + if ($null -ne $cts) { + $cts.Dispose() + } + } +} + +function Resolve-Installation-Path([string]$InstallDir) { + Say-Invocation $MyInvocation + + if ($InstallDir -eq "") { + $Dir = Join-Path -Path ([Environment]::GetFolderPath('LocalApplicationData')) -ChildPath "microsoft-go" + if ($OS -ne "" -or $Architecture -ne "") { + $Dir = Join-Path -Path $Dir -ChildPath "$($CLIOS)_$CLIArchitecture" + } + return $Dir + } + return $InstallDir +} + +function Resolve-Versioned-Installation-Path([string]$InstallRoot, [string]$SpecificVersion) { + Say-Invocation $MyInvocation + + return Join-Path -Path $InstallRoot -ChildPath "go$SpecificVersion" +} + +function Is-ToolsetInstalled([string]$InstallRoot, [string]$SpecificVersion) { + Say-Invocation $MyInvocation + + $GoToolsetPath = Resolve-Versioned-Installation-Path $InstallRoot $SpecificVersion + $GoBinPath = (Join-Path $GoToolsetPath "bin") + Say-Verbose "Is-ToolsetInstalled: GoToolsetPath=$GoToolsetPath" + # A few basic checks to see if a likely usable toolset is installed. + # If these fail, it will be reinstalled. + return (Test-Path $GoToolsetPath -PathType Container) -and + ( + (Test-Path (Join-Path $GoBinPath "go") -PathType Leaf) -or + (Test-Path (Join-Path $GoBinPath "go.exe") -PathType Leaf) + ) +} + +function Get-Absolute-Path([string]$RelativeOrAbsolutePath) { + # Too much spam + # Say-Invocation $MyInvocation + + return $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($RelativeOrAbsolutePath) +} + +function Extract-Zip([string]$ArchivePath, [string]$OutPath) { + Say-Invocation $MyInvocation + + Load-Assembly -Assembly System.IO.Compression.FileSystem + [System.IO.Compression.ZipFile]::ExtractToDirectory($ArchivePath, $OutPath) +} + +function Extract-TarGz([string]$ArchivePath, [string]$OutPath) { + Say-Invocation $MyInvocation + + if (-not (Test-Path $OutPath)) { + New-Item -ItemType Directory -Force -Path $OutPath + } + + try { + & tar -C $OutPath -xzf $ArchivePath + if ($LASTEXITCODE -ne 0) { + throw "tar exit code: $LASTEXITCODE" + } + } + catch { + throw "Failed to extract the tar.gz archive `"$ArchivePath`". Error: $_" + } +} + +function Extract-ToolsetArchive([string]$ArchivePath, [string]$InstallRoot, [string]$SpecificVersion) { + Say-Invocation $MyInvocation + + $GoRootPath = Resolve-Versioned-Installation-Path $InstallRoot $SpecificVersion + # First extract to a temporary directory to avoid partial extraction to the final location. This + # makes it so rerunning the script fixes a problem in case of an interruption. Don't use + # systemwide temp directory because Move-Item from there has been observed to fail on Linux. + $TempGoExtractDir = Join-Path $InstallRoot ".tmp-extract" + $TempGoRootPath = Resolve-Versioned-Installation-Path $TempGoExtractDir $SpecificVersion + + # Clean up directories from a previous attempt. + if (Test-Path $GoRootPath) { + Remove-Item $GoRootPath -Recurse -Force + } + if (Test-Path $TempGoExtractDir) { + Remove-Item $TempGoExtractDir -Recurse -Force + } + if (Test-Path $TempGoRootPath) { + Remove-Item $TempGoRootPath -Recurse -Force + } + + try { + switch ([System.IO.Path]::GetExtension($ArchivePath).ToLowerInvariant()) { + ".zip" { Extract-Zip $ArchivePath $TempGoRootPath } + ".gz" { Extract-TarGz $ArchivePath $TempGoRootPath } + default { throw "Unsupported archive type: $ArchivePath" } + } + + # Move contents of inner "go" dir to the output path to avoid unwanted extra dir. + Move-Item (Join-Path $TempGoRootPath "go") $GoRootPath + + $GoRootPath = "" + } + finally { + if ($GoRootPath -ne "" -and (Test-Path $GoRootPath)) { + Remove-Item $GoRootPath -Recurse -Force + } + if (Test-Path $TempGoExtractDir) { + Remove-Item $TempGoExtractDir -Recurse -Force + } + if (Test-Path $TempGoRootPath) { + Remove-Item $TempGoRootPath -Recurse -Force + } + } +} + +function DownloadJson([string]$Source) { + $Text = DownloadString $Source + + try { + return ConvertFrom-Json $Text + } + catch { + Say-Verbose "Failed to parse the JSON response from '$Source': $Text" + throw $_ + } +} + +function DownloadString([string]$Source) { + $Stream = $null + $Reader = $null + + # To make sure errors are accurate and useful, attempt to get the target first. This prevents a + # situation where we succesfully download bing.com after a failed redirect, try to parse the + # HTML as JSON, and present a confusing error message. + if ($Source -like "https://aka.ms/*") { + $DirectSource = Get-AkaMSRedirectTarget $Source + if (!$DirectSource) { + throw "Failed to aka.ms redirect for URL: $Source" + } + $Source = $DirectSource + } + + try { + $Response = GetHTTPResponse -Uri $Source + $Stream = $Response.Content.ReadAsStreamAsync().Result + $Reader = New-Object System.IO.StreamReader($Stream) + return $Reader.ReadToEnd() + } + finally { + if ($null -ne $Stream) { + $Stream.Dispose() + } + if ($null -ne $Reader) { + $Reader.Dispose() + } + } +} + +function DownloadFile($Source, [string]$OutPath) { + if ($Source -notlike "http*") { + # Using System.IO.Path.GetFullPath to get the current directory + # does not work in this context - $pwd gives the current directory + if (![System.IO.Path]::IsPathRooted($Source)) { + $Source = $(Join-Path -Path $pwd -ChildPath $Source) + } + $Source = Get-Absolute-Path $Source + Say "Copying file from $Source to $OutPath" + Copy-Item $Source $OutPath + return + } + + $Stream = $null + + try { + $Response = GetHTTPResponse -Uri $Source + $Stream = $Response.Content.ReadAsStreamAsync().Result + $File = [System.IO.File]::Create($OutPath) + $Stream.CopyTo($File) + $File.Close() + + ValidateRemoteLocalFileSizes -LocalFileOutPath $OutPath -SourceUri $Source + } + finally { + if ($null -ne $Stream) { + $Stream.Dispose() + } + } +} + +function ValidateRemoteLocalFileSizes([string]$LocalFileOutPath, $SourceUri) { + try { + $remoteFileSize = Get-Remote-File-Size -zipUri $SourceUri + $fileSize = [long](Get-Item $LocalFileOutPath).Length + Say "Downloaded file $SourceUri size is $fileSize bytes." + + if ((![string]::IsNullOrEmpty($remoteFileSize)) -and !([string]::IsNullOrEmpty($fileSize)) ) { + if ($remoteFileSize -ne $fileSize) { + Say "The remote and local file sizes are not equal. Remote file size is $remoteFileSize bytes and local size is $fileSize bytes. The local package may be corrupted." + } + else { + Say "The remote and local file sizes are equal." + } + } + else { + Say "Either downloaded or local package size can not be measured. One of them may be corrupted." + } + } + catch { + Say "Either downloaded or local package size can not be measured. One of them may be corrupted." + } +} + +function Remove-FileSafely($Path) { + try { + if (Test-Path $Path) { + Remove-Item $Path + Say-Verbose "The temporary file `"$Path`" was removed." + } + else { + Say-Verbose "The temporary file `"$Path`" does not exist, therefore is not removed." + } + } + catch { + Say-Warning "Failed to remove the temporary file: `"$Path`", remove it manually." + } +} + +function Prepend-ToolsetPathEnv([string]$InstallRoot, [string]$SpecificVersion) { + Say-Invocation $MyInvocation + + $GoRootPath = Resolve-Versioned-Installation-Path $InstallRoot $SpecificVersion + $BinPath = Get-Absolute-Path (Join-Path -Path $GoRootPath -ChildPath "bin") + + if (-Not $NoPath) { + $SuffixedBinPath = $BinPath + [System.IO.Path]::PathSeparator + if (-Not $env:PATH.Contains($SuffixedBinPath)) { + Say "Adding to current process PATH: $BinPath" + Say "Note: This change will not be visible if PowerShell was run as a child process." + $env:PATH = $SuffixedBinPath + $env:PATH + Say-Verbose "The current process PATH is now `"$env:PATH`"." + } + else { + Say "Current process PATH already contains `"$BinPath`"" + } + } + else { + Say "Binaries can be found in $BinPath" + } + + if ($AzurePipelinePath) { + Say "Running an Azure Pipelines logging command to prepend `"$BinPath`" to the PATH." + Say "##vso[task.prependpath]$BinPath" + } +} + +function PrintDryRunOutput($Invocation) { + $RepeatableCommand = ".\$ScriptName -Version `"go$SpecificVersion`" -InstallDir `"$InstallRoot`" -OS `"$CLIOS`" -Architecture `"$CLIArchitecture`"" + + foreach ($key in $Invocation.BoundParameters.Keys) { + if (-not (@("Version","InstallDir","OS","Architecture","DryRun") -contains $key)) { + $RepeatableCommand+=" -$key `"$($Invocation.BoundParameters[$key])`"" + } + } + Say "Repeatable invocation: $RepeatableCommand" +} + +function Get-AkaMSRedirectTarget([string] $akaMsLink) { + $akaMsDownloadLink=$null + + for ($maxRedirections = 9; $maxRedirections -ge 0; $maxRedirections--) + { + #get HTTP response + #do not pass credentials as a part of the $akaMsLink and do not apply credentials in the GetHTTPResponse function + #otherwise the redirect link would have credentials as well + #it would result in applying credentials twice to the resulting link and thus breaking it, and in echoing credentials to the output as a part of redirect link + $Response= GetHTTPResponse -Uri $akaMsLink -HeaderOnly $true -DisableRedirect $true -DisableFeedCredential $true + Say-Verbose "Received response:`n$Response" + + if ([string]::IsNullOrEmpty($Response)) { + Say-Verbose "The link '$akaMsLink' is not valid: failed to get redirect location. The resource is not available." + return $null + } + + #if HTTP code is 301 (Moved Permanently), the redirect link exists + if ($Response.StatusCode -eq 301) + { + try { + $akaMsDownloadLink = $Response.Headers.GetValues("Location")[0] + + if ([string]::IsNullOrEmpty($akaMsDownloadLink)) { + Say-Verbose "The link '$akaMsLink' is not valid: server returned 301 (Moved Permanently), but the headers do not contain the redirect location." + return $null + } + + Say-Verbose "The redirect location retrieved: '$akaMsDownloadLink'." + # This may yet be a link to another redirection. Attempt to retrieve the page again. + $akaMsLink = $akaMsDownloadLink + continue + } + catch { + Say-Verbose "The link '$akaMsLink' is not valid: failed to get redirect location." + return $null + } + } + elseif ((($Response.StatusCode -lt 300) -or ($Response.StatusCode -ge 400)) -and (-not [string]::IsNullOrEmpty($akaMsDownloadLink))) + { + # Redirections have ended. + return $akaMsDownloadLink + } + + Say-Verbose "The link '$akaMsLink' is not valid: failed to retrieve the redirection location." + return $null + } + + Say-Verbose "Aka.ms links have redirected more than the maximum allowed redirections. This may be caused by a cyclic redirection of aka.ms links." + return $null +} + +# Strict mode means attempting to access a JSON key that doesn't exist fails harshly. +# This utility helps make JSON access a bit more concise under those rules. +# https://github.com/PowerShell/PowerShell/issues/10875 +function Get-OrNull($Target, $Property) { + if ($Target -and $Target.PSObject.Properties[$Property]) { + return $Target.PSObject.Properties[$Property].Value + } + return $null +} + +function Get-AssetInformation([string]$NormalizedVersion, [string]$OS, [string]$Architecture) { + Say-Invocation $MyInvocation + + #construct aka.ms link like "https://aka.ms/golang/release/latest/go1.23.assets.json" + $AkaMsLink = "https://aka.ms/golang/release/latest" + $AkaMsLink +="/$NormalizedVersion.assets.json" + Say-Verbose "Constructed assets.json aka.ms link: '$AkaMsLink'." + + $Assets = DownloadJson $AkaMsLink + $MatchingArches = @($Assets.arches | Where-Object { + $Env = Get-OrNull $_ 'env' + return (Get-OrNull $Env 'GOOS') -eq $OS -and + (Get-OrNull $Env 'GOARCH') -eq $Architecture + }) + + foreach ($arch in $MatchingArches) { + Say-Verbose "Matching env '$($arch.env)'." + } + + if ($MatchingArches.Count -ne 1) { + throw "Failed to find exactly one matching asset for OS '$OS' and architecture '$Architecture'." + } + + return ($MatchingArches[0], $Assets.version) +} + +function Prepare-Install-Directory { + New-Item -ItemType Directory -Force -Path $InstallRoot | Out-Null +} + +# The following marker is used by microsoft/go-infra tests to insert more logic that runs before any +# installation happens and may stop the script before installation. This allows unit testing without +# adding additional inputs and complexity only used by tests. + +# [END OF FUNCTIONS] + +if ($Help) { + Get-Help $PSCommandPath -Examples + exit +} + +Say "Microsoft build of Go Install Script version $MicrosoftGoInstallScriptVersion" + +Say-Verbose "Note that the intended use of this script is for Continuous Integration (CI) scenarios, where:" +Say-Verbose "- The toolset needs to be installed without user interaction and without admin rights." +Say-Verbose "- The toolset installation doesn't need to persist across multiple CI runs." +Say-Verbose "Visit https://github.com/microsoft/go for a list of other ways to install Microsoft build of Go.`r`n" + +Measure-Action "Product discovery" { + $script:CLIArchitecture = Get-CLIArchitecture-From-Architecture $Architecture + $script:CLIOS = Get-CLIOS-From-OS $OS + $script:NormalizedVersion = Get-NormalizedVersion $Version + Say-Verbose "Normalized version: '$NormalizedVersion'" +} + +if ($ArchivePath -eq "") { + $ArchivePath = Get-GeneratedArchivePath $CLIOS + Say-Verbose "Generated archive path: $ArchivePath" +} + +$InstallRoot = Resolve-Installation-Path $InstallDir +Say-Verbose "InstallRoot: $InstallRoot" + +$ScriptName = $MyInvocation.MyCommand.Name + +Say "Fetching information for version '$Version'." +($Arch, $SpecificVersion) = Get-AssetInformation $NormalizedVersion $CLIOS $CLIArchitecture + +$DownloadLink = $Arch.url +Say-Verbose "Found download link $DownloadLink with version $SpecificVersion" + +if (-Not $DryRun) { + Say-Verbose "Checking if the version $SpecificVersion is already installed" + if (Is-ToolsetInstalled -InstallRoot $InstallRoot -SpecificVersion $SpecificVersion) { + Say "Microsoft build of Go version '$SpecificVersion' is already installed." + Measure-Action "Setting up shell environment" { Prepend-ToolsetPathEnv -InstallRoot $InstallRoot -SpecificVersion $SpecificVersion } + return + } +} + +if ($DryRun) { + PrintDryRunOutput $MyInvocation + return +} + +Measure-Action "Installation directory preparation" { Prepare-Install-Directory } + +Say-Verbose "Zip path: $ArchivePath" + +Say-Verbose "Downloading link $DownloadLink" + +try { + Measure-Action "Package download" { DownloadFile -Source $DownloadLink -OutPath $ArchivePath } + Say-Verbose "Download succeeded." +} +catch { + $StatusCode = $null + $ErrorMessage = $null + + if ($PSItem.Exception.Data.Contains("StatusCode")) { + $StatusCode = $PSItem.Exception.Data["StatusCode"] + } + + if ($PSItem.Exception.Data.Contains("ErrorMessage")) { + $ErrorMessage = $PSItem.Exception.Data["ErrorMessage"] + } else { + $ErrorMessage = $PSItem.Exception.Message + } + + if (-not $KeepArchive) { + Remove-FileSafely -Path $ArchivePath + } + + throw "Downloading has failed with error:`nUri: $DownloadLink`nStatusCode: $StatusCode`nError: $ErrorMessage" +} + +Say "Extracting the archive." +Measure-Action "Archive extraction" { Extract-ToolsetArchive -ArchivePath $ArchivePath -InstallRoot $InstallRoot -SpecificVersion $SpecificVersion } + +Say-Verbose "Checking installation: version = $SpecificVersion" +$isAssetInstalled = Is-ToolsetInstalled -InstallRoot $InstallRoot -SpecificVersion $SpecificVersion + +# Version verification failed. More likely something is wrong either with the downloaded content or with the verification algorithm. +if (!$isAssetInstalled) { + Say-Error "Failed to verify that the toolset was installed.`nInstallation source: $DownloadLink.`nInstallation location: $InstallRoot.`nReport the bug at https://github.com/microsoft/go/issues." + throw "Toolset with version $SpecificVersion failed to install with an unknown error." +} + +if (-not $KeepArchive) { + Remove-FileSafely -Path $ArchivePath +} + +Measure-Action "Setting up environment PATH to find 'go' command" { Prepend-ToolsetPathEnv -InstallRoot $InstallRoot -SpecificVersion $SpecificVersion } + +Say "Installed version is $SpecificVersion" +Say "Installation finished" diff --git a/go.mod b/go.mod index 8f5243215c..ec1f97e392 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( require ( github.com/klauspost/cpuid/v2 v2.0.9 // indirect github.com/matryer/moq v0.6.0 // indirect + github.com/microsoft/go-infra/goinstallscript v1.0.0 // indirect golang.org/x/mod v0.27.0 // indirect golang.org/x/tools v0.36.0 // indirect mvdan.cc/gofumpt v0.9.1 // indirect @@ -25,6 +26,7 @@ require ( tool ( github.com/matryer/moq + github.com/microsoft/go-infra/goinstallscript golang.org/x/tools/cmd/stringer mvdan.cc/gofumpt ) diff --git a/go.sum b/go.sum index 78724d28be..57e4cc59b2 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/matryer/moq v0.6.0 h1:FCccG09c3o4cg3gnrZ+7ty5Pa/sjmN24BMHp/0pwhjQ= github.com/matryer/moq v0.6.0/go.mod h1:iEVhY/XBwFG/nbRyEf0oV+SqnTHZJ5wectzx7yT+y98= +github.com/microsoft/go-infra/goinstallscript v1.0.0 h1:LFakXWLma5+OlsGRvEMxoI6OpF8KMZACzNMKZ5NEmkk= +github.com/microsoft/go-infra/goinstallscript v1.0.0/go.mod h1:SFsdKAEHdmGsGoh8FkksVaxoQ3rnnJ/TBqN09Ml/0Cw= github.com/peter-evans/patience v0.3.0 h1:rX0JdJeepqdQl1Sk9c9uvorjYYzL2TfgLX1adqYm9cA= github.com/peter-evans/patience v0.3.0/go.mod h1:Kmxu5sY1NmBLFSStvXjX1wS9mIv7wMcP/ubucyMOAu0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= From 1b1dc856eb549ddc6868279e818fb8a9d7eb27fb Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Mon, 10 Nov 2025 11:53:01 -0800 Subject: [PATCH 07/11] Pull in fix from go-infra PR --- .github/actions/setup-go/action.yml | 15 ++++----------- .github/actions/setup-go/go-install.ps1 | 9 +++++++++ go.mod | 2 -- go.sum | 2 -- 4 files changed, 13 insertions(+), 15 deletions(-) diff --git a/.github/actions/setup-go/action.yml b/.github/actions/setup-go/action.yml index 0dd477dc06..b77aac8527 100644 --- a/.github/actions/setup-go/action.yml +++ b/.github/actions/setup-go/action.yml @@ -20,7 +20,7 @@ runs: id: install-go shell: pwsh run: | - ${{ github.action_path }}/go-install.ps1 -Version ${{ inputs.go-version }} + ${{ github.action_path }}/go-install.ps1 -Version ${{ inputs.go-version }} -GitHubActionsPath $goVersionOutput = go version Write-Host $goVersionOutput @@ -34,13 +34,6 @@ runs: exit 1 } - # Add Go bin directory to PATH for subsequent steps - # TODO: remove once go-install.ps1 supports GITHUB_PATH - $goRoot = go env GOROOT - $goBinPath = Join-Path $goRoot "bin" - Write-Host "Adding to GITHUB_PATH: $goBinPath" - $goBinPath >> $env:GITHUB_PATH - - name: Verify Microsoft Go shell: pwsh run: | @@ -57,9 +50,9 @@ runs: shell: bash id: cache-info env: - MODULES_KEY: go-modules-${{ runner.os }}-${{ steps.install-go.outputs.go-version }}-${{ hashFiles('**/go.sum', '**/.custom-gcl.yml') }} - LINT_KEY: golangci-lint-${{ runner.os }}-${{ steps.install-go.outputs.go-version }}-${{ hashFiles('**/go.sum', '**/.custom-gcl.yml') }} - BUILD_KEY: go-build-cache-${{ runner.os }}-${{ steps.install-go.outputs.go-version }} + MODULES_KEY: go-modules-${{ runner.os }}-msft-${{ steps.install-go.outputs.go-version }}-${{ hashFiles('**/go.sum', '**/.custom-gcl.yml') }} + LINT_KEY: golangci-lint-${{ runner.os }}-msft-${{ steps.install-go.outputs.go-version }}-${{ hashFiles('**/go.sum', '**/.custom-gcl.yml') }} + BUILD_KEY: go-build-cache-${{ runner.os }}-msft-${{ steps.install-go.outputs.go-version }} run: | echo "modules-key=$MODULES_KEY" >> $GITHUB_OUTPUT echo "lint-key=$LINT_KEY" >> $GITHUB_OUTPUT diff --git a/.github/actions/setup-go/go-install.ps1 b/.github/actions/setup-go/go-install.ps1 index ddebb164aa..8a58699318 100644 --- a/.github/actions/setup-go/go-install.ps1 +++ b/.github/actions/setup-go/go-install.ps1 @@ -53,6 +53,9 @@ .PARAMETER AzurePipelinePath If set, it will print an Azure DevOps logging command that causes the Azure DevOps to update the PATH environment variable of subsequent build steps to include the binaries folder. +.PARAMETER GitHubActionsPath + If set, it will append the binaries folder to the GITHUB_PATH environment file, causing GitHub Actions + to update the PATH environment variable of subsequent build steps to include the binaries folder. .PARAMETER ProxyAddress If set, it will use the proxy when making web requests .PARAMETER ProxyUseDefaultCredentials @@ -89,6 +92,7 @@ param( [switch]$DryRun, [switch]$NoPath, [switch]$AzurePipelinePath, + [switch]$GitHubActionsPath, [string]$ProxyAddress, [switch]$ProxyUseDefaultCredentials, [string[]]$ProxyBypassList=@(), @@ -686,6 +690,11 @@ function Prepend-ToolsetPathEnv([string]$InstallRoot, [string]$SpecificVersion) Say "Running an Azure Pipelines logging command to prepend `"$BinPath`" to the PATH." Say "##vso[task.prependpath]$BinPath" } + + if ($GitHubActionsPath) { + Say "Appending `"$BinPath`" to the GITHUB_PATH file." + $BinPath | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + } } function PrintDryRunOutput($Invocation) { diff --git a/go.mod b/go.mod index ec1f97e392..8f5243215c 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,6 @@ require ( require ( github.com/klauspost/cpuid/v2 v2.0.9 // indirect github.com/matryer/moq v0.6.0 // indirect - github.com/microsoft/go-infra/goinstallscript v1.0.0 // indirect golang.org/x/mod v0.27.0 // indirect golang.org/x/tools v0.36.0 // indirect mvdan.cc/gofumpt v0.9.1 // indirect @@ -26,7 +25,6 @@ require ( tool ( github.com/matryer/moq - github.com/microsoft/go-infra/goinstallscript golang.org/x/tools/cmd/stringer mvdan.cc/gofumpt ) diff --git a/go.sum b/go.sum index 57e4cc59b2..78724d28be 100644 --- a/go.sum +++ b/go.sum @@ -14,8 +14,6 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/matryer/moq v0.6.0 h1:FCccG09c3o4cg3gnrZ+7ty5Pa/sjmN24BMHp/0pwhjQ= github.com/matryer/moq v0.6.0/go.mod h1:iEVhY/XBwFG/nbRyEf0oV+SqnTHZJ5wectzx7yT+y98= -github.com/microsoft/go-infra/goinstallscript v1.0.0 h1:LFakXWLma5+OlsGRvEMxoI6OpF8KMZACzNMKZ5NEmkk= -github.com/microsoft/go-infra/goinstallscript v1.0.0/go.mod h1:SFsdKAEHdmGsGoh8FkksVaxoQ3rnnJ/TBqN09Ml/0Cw= github.com/peter-evans/patience v0.3.0 h1:rX0JdJeepqdQl1Sk9c9uvorjYYzL2TfgLX1adqYm9cA= github.com/peter-evans/patience v0.3.0/go.mod h1:Kmxu5sY1NmBLFSStvXjX1wS9mIv7wMcP/ubucyMOAu0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= From 5d59ec804ceb9e9f0e4bebd5621820bf0f50a577 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Mon, 10 Nov 2025 12:09:50 -0800 Subject: [PATCH 08/11] Uncomment fix now that CI proves it matters --- internal/vfs/osvfs/symlink_windows.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/internal/vfs/osvfs/symlink_windows.go b/internal/vfs/osvfs/symlink_windows.go index 70af059901..9be0ac5aa6 100644 --- a/internal/vfs/osvfs/symlink_windows.go +++ b/internal/vfs/osvfs/symlink_windows.go @@ -8,10 +8,9 @@ import ( // isSymlinkOrJunction checks if the given path is a symlink or junction point // on Windows by checking the FILE_ATTRIBUTE_REPARSE_POINT attribute. func isSymlinkOrJunction(path string) bool { - // TODO: show that this works - // if len(path) >= 248 { - // path = `\\?\` + path - // } + if len(path) >= 248 { + path = `\\?\` + path + } pathUTF16, err := syscall.UTF16PtrFromString(path) if err != nil { From d30517ab2d80e86438efb845c5abcbf28314f340 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Mon, 10 Nov 2025 12:16:22 -0800 Subject: [PATCH 09/11] Move impl --- internal/vfs/internal/internal.go | 8 ++-- internal/vfs/osvfs/os.go | 4 +- ...symlink_other.go => reparsepoint_other.go} | 2 +- ...ink_windows.go => reparsepoint_windows.go} | 4 +- ...s_test.go => reparsepoint_windows_test.go} | 38 +++++++++---------- 5 files changed, 28 insertions(+), 28 deletions(-) rename internal/vfs/osvfs/{symlink_other.go => reparsepoint_other.go} (79%) rename internal/vfs/osvfs/{symlink_windows.go => reparsepoint_windows.go} (80%) rename internal/vfs/osvfs/{symlink_windows_test.go => reparsepoint_windows_test.go} (78%) diff --git a/internal/vfs/internal/internal.go b/internal/vfs/internal/internal.go index 5376a91182..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 - IsSymlinkOrJunction func(path string) bool + RootFor func(root string) fs.FS + IsReparsePoint func(path string) bool } func RootLength(p string) int { @@ -93,11 +93,11 @@ func (vfs *Common) GetAccessibleEntries(path string) (result vfs.Entries) { continue } - if entryType&fs.ModeIrregular != 0 && vfs.IsSymlinkOrJunction != nil { + 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 vfs.IsSymlinkOrJunction(fullPath) { + if vfs.IsReparsePoint(fullPath) { if stat := vfs.Stat(fullPath); stat != nil { addToResult(entry.Name(), stat.Mode()) } diff --git a/internal/vfs/osvfs/os.go b/internal/vfs/osvfs/os.go index fd37954693..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, - IsSymlinkOrJunction: isSymlinkOrJunction, + RootFor: os.DirFS, + IsReparsePoint: isReparsePoint, }, } diff --git a/internal/vfs/osvfs/symlink_other.go b/internal/vfs/osvfs/reparsepoint_other.go similarity index 79% rename from internal/vfs/osvfs/symlink_other.go rename to internal/vfs/osvfs/reparsepoint_other.go index 799c60e22f..e66c65bf49 100644 --- a/internal/vfs/osvfs/symlink_other.go +++ b/internal/vfs/osvfs/reparsepoint_other.go @@ -4,4 +4,4 @@ package osvfs // On Unix-like systems, symlinks are already properly detected by the // fs.ModeSymlink bit in the directory entry type, so this check is not needed. -var isSymlinkOrJunction func(path string) bool +var isReparsePoint func(path string) bool diff --git a/internal/vfs/osvfs/symlink_windows.go b/internal/vfs/osvfs/reparsepoint_windows.go similarity index 80% rename from internal/vfs/osvfs/symlink_windows.go rename to internal/vfs/osvfs/reparsepoint_windows.go index 9be0ac5aa6..fd4685a740 100644 --- a/internal/vfs/osvfs/symlink_windows.go +++ b/internal/vfs/osvfs/reparsepoint_windows.go @@ -5,9 +5,9 @@ import ( "unsafe" ) -// isSymlinkOrJunction checks if the given path is a symlink or junction point +// isReparsePoint checks if the given path is a symlink or junction point // on Windows by checking the FILE_ATTRIBUTE_REPARSE_POINT attribute. -func isSymlinkOrJunction(path string) bool { +func isReparsePoint(path string) bool { if len(path) >= 248 { path = `\\?\` + path } diff --git a/internal/vfs/osvfs/symlink_windows_test.go b/internal/vfs/osvfs/reparsepoint_windows_test.go similarity index 78% rename from internal/vfs/osvfs/symlink_windows_test.go rename to internal/vfs/osvfs/reparsepoint_windows_test.go index 106ef81ccb..c1dba9ecd8 100644 --- a/internal/vfs/osvfs/symlink_windows_test.go +++ b/internal/vfs/osvfs/reparsepoint_windows_test.go @@ -9,7 +9,7 @@ import ( "gotest.tools/v3/assert" ) -func TestIsSymlinkOrJunction(t *testing.T) { +func TestIsReparsePoint(t *testing.T) { t.Parallel() tmp := t.TempDir() @@ -18,14 +18,14 @@ func TestIsSymlinkOrJunction(t *testing.T) { t.Parallel() file := filepath.Join(tmp, "regular.txt") assert.NilError(t, os.WriteFile(file, []byte("hello"), 0o666)) - assert.Equal(t, isSymlinkOrJunction(file), false) + 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, isSymlinkOrJunction(dir), false) + assert.Equal(t, isReparsePoint(dir), false) }) t.Run("junction point", func(t *testing.T) { @@ -34,7 +34,7 @@ func TestIsSymlinkOrJunction(t *testing.T) { link := filepath.Join(tmp, "junction-link") assert.NilError(t, os.MkdirAll(target, 0o777)) mklink(t, target, link, true) - assert.Equal(t, isSymlinkOrJunction(link), true) + assert.Equal(t, isReparsePoint(link), true) }) t.Run("file symlink", func(t *testing.T) { @@ -43,7 +43,7 @@ func TestIsSymlinkOrJunction(t *testing.T) { link := filepath.Join(tmp, "symlink-link.txt") assert.NilError(t, os.WriteFile(target, []byte("hello"), 0o666)) mklink(t, target, link, false) - assert.Equal(t, isSymlinkOrJunction(link), true) + assert.Equal(t, isReparsePoint(link), true) }) t.Run("directory symlink", func(t *testing.T) { @@ -52,27 +52,27 @@ func TestIsSymlinkOrJunction(t *testing.T) { link := filepath.Join(tmp, "dir-symlink-link") assert.NilError(t, os.MkdirAll(target, 0o777)) mklink(t, target, link, false) - assert.Equal(t, isSymlinkOrJunction(link), true) + 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, isSymlinkOrJunction(nonexistent), false) + assert.Equal(t, isReparsePoint(nonexistent), false) }) t.Run("empty path", func(t *testing.T) { t.Parallel() - assert.Equal(t, isSymlinkOrJunction(""), false) + assert.Equal(t, isReparsePoint(""), false) }) t.Run("invalid path with null byte", func(t *testing.T) { t.Parallel() - assert.Equal(t, isSymlinkOrJunction("invalid\x00path"), false) + assert.Equal(t, isReparsePoint("invalid\x00path"), false) }) } -func TestIsSymlinkOrJunctionLongPath(t *testing.T) { +func TestIsReparsePointLongPath(t *testing.T) { t.Parallel() tmp := t.TempDir() @@ -96,10 +96,10 @@ func TestIsSymlinkOrJunctionLongPath(t *testing.T) { 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, isSymlinkOrJunction(link), true) + assert.Equal(t, isReparsePoint(link), true) } -func TestIsSymlinkOrJunctionNestedInSymlink(t *testing.T) { +func TestIsReparsePointNestedInSymlink(t *testing.T) { t.Parallel() tmp := t.TempDir() @@ -118,10 +118,10 @@ func TestIsSymlinkOrJunctionNestedInSymlink(t *testing.T) { // Check the junction through the symlink path nestedPath := filepath.Join(link, "inner-link") - assert.Equal(t, isSymlinkOrJunction(nestedPath), true) + assert.Equal(t, isReparsePoint(nestedPath), true) } -func TestIsSymlinkOrJunctionRelativePath(t *testing.T) { //nolint:paralleltest // Cannot use t.Parallel() with t.Chdir() +func TestIsReparsePointRelativePath(t *testing.T) { //nolint:paralleltest // Cannot use t.Parallel() with t.Chdir() tmp := t.TempDir() t.Chdir(tmp) @@ -130,8 +130,8 @@ func TestIsSymlinkOrJunctionRelativePath(t *testing.T) { //nolint:paralleltest / assert.NilError(t, os.MkdirAll(target, 0o777)) mklink(t, target, link, true) - assert.Equal(t, isSymlinkOrJunction(link), true) - assert.Equal(t, isSymlinkOrJunction(target), false) + assert.Equal(t, isReparsePoint(link), true) + assert.Equal(t, isReparsePoint(target), false) } func BenchmarkIsSymlinkOrJunction(b *testing.B) { @@ -148,14 +148,14 @@ func BenchmarkIsSymlinkOrJunction(b *testing.B) { b.Run("regular file", func(b *testing.B) { b.ReportAllocs() for b.Loop() { - isSymlinkOrJunction(regularFile) + isReparsePoint(regularFile) } }) b.Run("junction", func(b *testing.B) { b.ReportAllocs() for b.Loop() { - isSymlinkOrJunction(link) + isReparsePoint(link) } }) @@ -163,7 +163,7 @@ func BenchmarkIsSymlinkOrJunction(b *testing.B) { b.ReportAllocs() nonexistent := filepath.Join(tmp, "does-not-exist") for b.Loop() { - isSymlinkOrJunction(nonexistent) + isReparsePoint(nonexistent) } }) } From 726a8114003aa89ebd26749ce8e92354c7c1514e Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Mon, 10 Nov 2025 12:17:21 -0800 Subject: [PATCH 10/11] Update comments --- internal/vfs/osvfs/reparsepoint_other.go | 3 +-- internal/vfs/osvfs/reparsepoint_windows.go | 2 -- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/internal/vfs/osvfs/reparsepoint_other.go b/internal/vfs/osvfs/reparsepoint_other.go index e66c65bf49..1206b6bc36 100644 --- a/internal/vfs/osvfs/reparsepoint_other.go +++ b/internal/vfs/osvfs/reparsepoint_other.go @@ -2,6 +2,5 @@ package osvfs -// On Unix-like systems, symlinks are already properly detected by the -// fs.ModeSymlink bit in the directory entry type, so this check is not needed. +// 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 index fd4685a740..12a1e59cb0 100644 --- a/internal/vfs/osvfs/reparsepoint_windows.go +++ b/internal/vfs/osvfs/reparsepoint_windows.go @@ -5,8 +5,6 @@ import ( "unsafe" ) -// isReparsePoint checks if the given path is a symlink or junction point -// on Windows by checking the FILE_ATTRIBUTE_REPARSE_POINT attribute. func isReparsePoint(path string) bool { if len(path) >= 248 { path = `\\?\` + path From 6c9d09cdbff664cf9a566e396f739001100d9201 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Mon, 10 Nov 2025 12:18:44 -0800 Subject: [PATCH 11/11] one more last thing --- .github/actions/setup-go/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/setup-go/action.yml b/.github/actions/setup-go/action.yml index b77aac8527..c373b454bb 100644 --- a/.github/actions/setup-go/action.yml +++ b/.github/actions/setup-go/action.yml @@ -3,7 +3,7 @@ description: Setup Go inputs: go-version: - description: Go version range to set up. + description: Go version to set up in go-install.ps1 format default: 'go1.25' create: description: Create the cache