summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorBrad Fitzpatrick <bradfitz@tailscale.com>2026-04-06 03:41:47 +0000
committerBrad Fitzpatrick <brad@danga.com>2026-04-08 14:08:30 -0700
commit8a9840d6a8c109f4867277fb53a41ef4257140b3 (patch)
treeb6f135a83d43c791397086a9997b81432917fe65
parent814161303f529f2b8ee1ab96fb11e93160eb818c (diff)
downloadtailscale-8a9840d6a8c109f4867277fb53a41ef4257140b3.tar.xz
tailscale-8a9840d6a8c109f4867277fb53a41ef4257140b3.zip
tool: replace go.cmd with a 19KB Rust go.exe wrapper
go.cmd used cmd.exe to invoke PowerShell, which mangled arguments: cmd.exe treats ^ as an escape character (so -run "^$" became -run "$", running all tests instead of none) and = signs also caused issues in the PowerShell→cmd.exe argument passing layer. Replace it with a tiny no_std Rust binary (19KB, 32-bit x86 for universal Windows compat: x86/x64/ARM64) that directly invokes the Tailscale Go toolchain via CreateProcessW. The raw command line from GetCommandLineW is passed through to CreateProcessW with only argv[0] replaced, so arguments are never parsed or re-escaped. The binary also handles first-run toolchain download natively using curl.exe and tar.exe (both ship with Windows 10+), so PowerShell is no longer required for normal operation. The PowerShell fallback is only used for the rare TS_USE_GOCROSS=1 path. PowerShell prefers go.exe over go.cmd when resolving ./tool/go, so this is a drop-in replacement. With go.exe in place, the CI can use the natural -bench=. -benchtime=1x -run="^$" flags directly. Also removes tool/go-win.ps1 which is now unused. Updates #19255 Change-Id: I80da23285b74796e7694b89cff29a9fa0eaa6281 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
-rw-r--r--.github/workflows/test.yml8
-rw-r--r--tool/go-win.ps164
-rw-r--r--tool/go.cmd36
-rwxr-xr-xtool/go.exebin0 -> 18944 bytes
-rw-r--r--tool/go.exe.README.txt20
-rw-r--r--tool/goexe/.cargo/config.toml2
-rw-r--r--tool/goexe/.gitignore2
-rw-r--r--tool/goexe/Cargo.lock7
-rw-r--r--tool/goexe/Cargo.toml11
-rw-r--r--tool/goexe/Makefile28
-rw-r--r--tool/goexe/src/main.rs686
11 files changed, 757 insertions, 107 deletions
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index f73e8178d..38ebd1291 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -270,13 +270,7 @@ jobs:
- name: bench all
if: matrix.key == 'win-bench'
working-directory: src
- # Don't use -bench=. -benchtime=1x.
- # Somewhere in the layers (powershell?)
- # the equals signs cause great confusion.
- # Don't use -run "^$" either; the ^ is cmd.exe's escape
- # character, so go.cmd's cmd.exe layer eats it, turning
- # -run "^$" into -run "$" which matches all test names.
- run: ./tool/go test ./... -bench . -benchtime 1x -run XXXXNothingXXXX
+ run: ./tool/go test ./... -bench=. -benchtime=1x -run="^$"
env:
NOPWSHDEBUG: "true" # to quiet tool/gocross/gocross-wrapper.ps1 in CI
diff --git a/tool/go-win.ps1 b/tool/go-win.ps1
deleted file mode 100644
index 49313ffba..000000000
--- a/tool/go-win.ps1
+++ /dev/null
@@ -1,64 +0,0 @@
-<#
- go.ps1 – Tailscale Go toolchain fetching wrapper for Windows/PowerShell
- • Reads go.toolchain.rev one dir above this script
- • If the requested commit hash isn't cached, downloads and unpacks
- https://github.com/tailscale/go/releases/download/build-${REV}/${OS}-${ARCH}.tar.gz
- • Finally execs the toolchain's "go" binary, forwarding all args & exit-code
-#>
-
-param(
- [Parameter(ValueFromRemainingArguments = $true)]
- [string[]] $Args
-)
-
-Set-StrictMode -Version Latest
-$ErrorActionPreference = 'Stop'
-
-if ($env:CI -eq 'true' -and $env:NODEBUG -ne 'true') {
- $VerbosePreference = 'Continue'
-}
-
-$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..')
-$REV = (Get-Content (Join-Path $repoRoot 'go.toolchain.rev') -Raw).Trim()
-
-if ([IO.Path]::IsPathRooted($REV)) {
- $toolchain = $REV
-} else {
- if (-not [string]::IsNullOrWhiteSpace($env:TSGO_CACHE_ROOT)) {
- $cacheRoot = $env:TSGO_CACHE_ROOT
- } else {
- $cacheRoot = Join-Path $env:USERPROFILE '.cache\tsgo'
- }
-
- $toolchain = Join-Path $cacheRoot $REV
- $marker = "$toolchain.extracted"
-
- if (-not (Test-Path $marker)) {
- Write-Host "# Downloading Go toolchain $REV" -ForegroundColor Cyan
- if (Test-Path $toolchain) { Remove-Item -Recurse -Force $toolchain }
-
- # Removing the marker file again (even though it shouldn't still exist)
- # because the equivalent Bash script also does so (to guard against
- # concurrent cache fills?).
- # TODO(bradfitz): remove this and add some proper locking instead?
- if (Test-Path $marker ) { Remove-Item -Force $marker }
-
- New-Item -ItemType Directory -Path $cacheRoot -Force | Out-Null
-
- $url = "https://github.com/tailscale/go/releases/download/build-$REV/windows-amd64.tar.gz"
- $tgz = "$toolchain.tar.gz"
- Invoke-WebRequest -Uri $url -OutFile $tgz -UseBasicParsing -ErrorAction Stop
-
- New-Item -ItemType Directory -Path $toolchain -Force | Out-Null
- tar --strip-components=1 -xzf $tgz -C $toolchain
- Remove-Item $tgz
- Set-Content -Path $marker -Value $REV
- }
-}
-
-$goExe = Join-Path $toolchain 'bin\go.exe'
-if (-not (Test-Path $goExe)) { throw "go executable not found at $goExe" }
-
-& $goExe @Args
-exit $LASTEXITCODE
-
diff --git a/tool/go.cmd b/tool/go.cmd
deleted file mode 100644
index b7b5d0483..000000000
--- a/tool/go.cmd
+++ /dev/null
@@ -1,36 +0,0 @@
-@echo off
-rem Checking for PowerShell Core using PowerShell for Windows...
-powershell -NoProfile -NonInteractive -Command "& {Get-Command -Name pwsh -ErrorAction Stop}" > NUL
-if ERRORLEVEL 1 (
- rem Ask the user whether they should install the dependencies. Note that this
- rem code path never runs in CI because pwsh is always explicitly installed.
-
- rem Time out after 5 minutes, defaulting to 'N'
- choice /c yn /t 300 /d n /m "PowerShell Core is required. Install now"
- if ERRORLEVEL 2 (
- echo Aborting due to unmet dependencies.
- exit /b 1
- )
-
- rem Check for a .NET Core runtime using PowerShell for Windows...
- powershell -NoProfile -NonInteractive -Command "& {if (-not (dotnet --list-runtimes | Select-String 'Microsoft\.NETCore\.App' -Quiet)) {exit 1}}" > NUL
- rem Install .NET Core if missing to provide PowerShell Core's runtime library.
- if ERRORLEVEL 1 (
- rem Time out after 5 minutes, defaulting to 'N'
- choice /c yn /t 300 /d n /m "PowerShell Core requires .NET Core for its runtime library. Install now"
- if ERRORLEVEL 2 (
- echo Aborting due to unmet dependencies.
- exit /b 1
- )
-
- winget install --accept-package-agreements --id Microsoft.DotNet.Runtime.8 -e --source winget
- )
-
- rem Now install PowerShell Core.
- winget install --accept-package-agreements --id Microsoft.PowerShell -e --source winget
- if ERRORLEVEL 0 echo Please re-run this script within a new console session to pick up PATH changes.
- rem Either way we didn't build, so return 1.
- exit /b 1
-)
-
-pwsh -NoProfile -ExecutionPolicy Bypass "%~dp0..\tool\gocross\gocross-wrapper.ps1" %*
diff --git a/tool/go.exe b/tool/go.exe
new file mode 100755
index 000000000..39e90fb9a
--- /dev/null
+++ b/tool/go.exe
Binary files differ
diff --git a/tool/go.exe.README.txt b/tool/go.exe.README.txt
new file mode 100644
index 000000000..3f4988599
--- /dev/null
+++ b/tool/go.exe.README.txt
@@ -0,0 +1,20 @@
+What is go.exe, and why's a 32-bit x86 Windows binary checked into the repo?
+
+See https://github.com/tailscale/tailscale/pull/19256
+
+In summary, our previous attempts to provide a version of ./tool/go (a
+shell script) on Windows with PowerShell and cmd.exe both were
+lacking.
+
+So now we we're regrettably checking in a binary to the tree. Its
+source code is in ./tool/goexe. It's written in Rust without std so
+it's very small (smaller than plenty of of our source code files!) and
+it's 32-bit x86 so it runs on 32-bit x86, 64-bit x86, and arm64 Windows
+where it's emulated.
+
+This binary is not required, but it's used by our build system and
+people working on Tailscale who are used to being able to run
+"./tool/go" and have it do the right hermetic thing, using the correct
+Go toolchain.
+
+
diff --git a/tool/goexe/.cargo/config.toml b/tool/goexe/.cargo/config.toml
new file mode 100644
index 000000000..89c173a9a
--- /dev/null
+++ b/tool/goexe/.cargo/config.toml
@@ -0,0 +1,2 @@
+[target.i686-pc-windows-gnu]
+rustflags = ["-C", "link-args=-nostartfiles -lkernel32"]
diff --git a/tool/goexe/.gitignore b/tool/goexe/.gitignore
new file mode 100644
index 000000000..97342e3ae
--- /dev/null
+++ b/tool/goexe/.gitignore
@@ -0,0 +1,2 @@
+/target/
+/go.exe
diff --git a/tool/goexe/Cargo.lock b/tool/goexe/Cargo.lock
new file mode 100644
index 000000000..de27c2876
--- /dev/null
+++ b/tool/goexe/Cargo.lock
@@ -0,0 +1,7 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "go"
+version = "0.1.0"
diff --git a/tool/goexe/Cargo.toml b/tool/goexe/Cargo.toml
new file mode 100644
index 000000000..77beede96
--- /dev/null
+++ b/tool/goexe/Cargo.toml
@@ -0,0 +1,11 @@
+[package]
+name = "go"
+version = "0.1.0"
+edition = "2024"
+
+[profile.release]
+opt-level = "z"
+lto = true
+codegen-units = 1
+panic = "abort"
+strip = true
diff --git a/tool/goexe/Makefile b/tool/goexe/Makefile
new file mode 100644
index 000000000..a1f6f1f3b
--- /dev/null
+++ b/tool/goexe/Makefile
@@ -0,0 +1,28 @@
+# Builds tool/go.exe, a thin wrapper that execs the Tailscale Go
+# toolchain without going through cmd.exe (which mangles ^ and other
+# special characters in arguments).
+# See https://github.com/tailscale/tailscale/issues/19255
+#
+# Built as no_std Rust with raw Win32 API calls for minimal size (~17KB).
+# The resulting go.exe is checked into the repo at tool/go.exe.
+#
+# Built as 32-bit x86 so one binary runs on x86, x64 (via WoW64),
+# and ARM64 (via Windows x86 emulation).
+#
+# Requirements:
+# rustup target add i686-pc-windows-gnu
+# apt install gcc-mingw-w64-i686 (or equivalent)
+
+RUST_TARGET = i686-pc-windows-gnu
+
+.PHONY: all clean
+
+all: go.exe
+
+go.exe: src/main.rs Cargo.toml
+ cargo build --release --target $(RUST_TARGET)
+ cp target/$(RUST_TARGET)/release/go.exe $@
+
+clean:
+ rm -f go.exe
+ rm -rf target
diff --git a/tool/goexe/src/main.rs b/tool/goexe/src/main.rs
new file mode 100644
index 000000000..da8a6a8bb
--- /dev/null
+++ b/tool/goexe/src/main.rs
@@ -0,0 +1,686 @@
+// Copyright (c) Tailscale Inc & contributors
+// SPDX-License-Identifier: BSD-3-Clause
+
+//! A thin wrapper that finds and execs the Tailscale Go toolchain without
+//! going through cmd.exe, avoiding its argument mangling (cmd.exe treats ^
+//! as an escape character, breaking -run "^$" and similar, and = signs
+//! also cause issues in PowerShell→cmd.exe argument passing).
+//! See https://github.com/tailscale/tailscale/issues/19255.
+//!
+//! This replaces tool/go.cmd. When PowerShell resolves `./tool/go`, it
+//! prefers go.exe over go.cmd, so this binary is used automatically.
+//!
+//! Built as no_std with raw Win32 API calls for minimal binary size (~17KB).
+//! Built as 32-bit x86 so one binary runs on x86, x64 (via WoW64), and
+//! ARM64 (via Windows x86 emulation).
+//!
+//! The raw command line from GetCommandLineW is passed through directly to
+//! CreateProcessW (after swapping out argv[0]), so arguments are never
+//! parsed or re-escaped, preserving them exactly as the caller specified.
+
+#![no_std]
+#![no_main]
+#![windows_subsystem = "console"]
+// Every function in this program calls raw Win32 FFI; requiring unsafe
+// blocks inside each unsafe fn would be pure noise.
+#![allow(unsafe_op_in_unsafe_fn)]
+
+use core::ptr;
+
+// Win32 constants.
+
+/// https://learn.microsoft.com/en-us/windows/win32/secauthz/generic-access-rights
+const GENERIC_READ: u32 = 0x80000000;
+const GENERIC_WRITE: u32 = 0x40000000;
+/// https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilew (dwCreationDisposition)
+const OPEN_EXISTING: u32 = 3;
+const CREATE_ALWAYS: u32 = 2;
+/// https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilew (dwShareMode)
+const FILE_SHARE_READ: u32 = 1;
+/// Returned by CreateFileW on failure.
+const INVALID_HANDLE_VALUE: isize = -1;
+/// Returned by GetFileAttributesW when the file does not exist.
+/// https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-getfileattributesw
+const INVALID_FILE_ATTRIBUTES: u32 = 0xFFFFFFFF;
+/// https://learn.microsoft.com/en-us/windows/win32/api/synchapi/nf-synchapi-waitforsingleobject
+const INFINITE: u32 = 0xFFFFFFFF;
+
+/// https://learn.microsoft.com/en-us/windows/console/getstdhandle
+const STD_INPUT_HANDLE: u32 = (-10i32) as u32;
+const STD_OUTPUT_HANDLE: u32 = (-11i32) as u32;
+const STD_ERROR_HANDLE: u32 = (-12i32) as u32;
+
+/// Indicates that the hStdInput/hStdOutput/hStdError fields in STARTUPINFOW
+/// contain valid handles.
+/// https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/ns-processthreadsapi-startupinfow
+const STARTF_USESTDHANDLES: u32 = 0x00000100;
+
+/// https://learn.microsoft.com/en-us/windows/win32/api/sysinfoapi/ns-sysinfoapi-system_info
+const PROCESSOR_ARCHITECTURE_INTEL: u16 = 0;
+const PROCESSOR_ARCHITECTURE_AMD64: u16 = 9;
+const PROCESSOR_ARCHITECTURE_ARM64: u16 = 12;
+
+/// Exit code used when this wrapper panics, to distinguish from child
+/// process failures.
+const EXIT_CODE_PANIC: u32 = 0xFE;
+
+// Win32 struct definitions.
+
+/// STARTUPINFOW — passed to CreateProcessW to configure the child process.
+/// https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/ns-processthreadsapi-startupinfow
+#[repr(C)]
+struct StartupInfoW {
+ cb: u32, // Size of this struct in bytes.
+ reserved: usize, // lpReserved (must be NULL).
+ desktop: usize, // lpDesktop
+ title: usize, // lpTitle
+ x: u32, // dwX
+ y: u32, // dwY
+ x_size: u32, // dwXSize
+ y_size: u32, // dwYSize
+ x_count_chars: u32, // dwXCountChars
+ y_count_chars: u32, // dwYCountChars
+ fill_attribute: u32,// dwFillAttribute
+ flags: u32, // dwFlags (e.g. STARTF_USESTDHANDLES)
+ show_window: u16, // wShowWindow
+ cb_reserved2: u16, // cbReserved2
+ reserved2: usize, // lpReserved2
+ std_input: isize, // hStdInput (HANDLE)
+ std_output: isize, // hStdOutput (HANDLE)
+ std_error: isize, // hStdError (HANDLE)
+}
+
+/// PROCESS_INFORMATION — filled by CreateProcessW with handles to the new process/thread.
+/// https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/ns-processthreadsapi-process_information
+#[repr(C)]
+struct ProcessInformation {
+ process: isize, // hProcess (HANDLE)
+ thread: isize, // hThread (HANDLE)
+ process_id: u32, // dwProcessId
+ thread_id: u32, // dwThreadId
+}
+
+/// SYSTEM_INFO — returned by GetNativeSystemInfo.
+/// https://learn.microsoft.com/en-us/windows/win32/api/sysinfoapi/ns-sysinfoapi-system_info
+#[repr(C)]
+struct SystemInfo {
+ processor_architecture: u16, // wProcessorArchitecture
+ _reserved: u16,
+ _page_size: u32,
+ _min_app_addr: usize,
+ _max_app_addr: usize,
+ _active_processor_mask: usize,
+ _number_of_processors: u32,
+ _processor_type: u32,
+ _allocation_granularity: u32,
+ _processor_level: u16,
+ _processor_revision: u16,
+}
+
+// Win32 API declarations (all from kernel32.dll unless noted).
+
+unsafe extern "system" {
+ /// Returns the fully qualified path of the running executable.
+ /// https://learn.microsoft.com/en-us/windows/win32/api/libloaderapi/nf-libloaderapi-getmodulefilenamew
+ fn GetModuleFileNameW(module: isize, filename: *mut u16, size: u32) -> u32;
+
+ /// Opens or creates a file, returning a HANDLE.
+ /// https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilew
+ fn CreateFileW(
+ name: *const u16,
+ access: u32,
+ share: u32,
+ security: usize,
+ disposition: u32,
+ flags: u32,
+ template: usize,
+ ) -> isize;
+
+ /// Reads bytes from a file handle.
+ /// https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-readfile
+ fn ReadFile(
+ file: isize,
+ buffer: *mut u8,
+ to_read: u32,
+ read: *mut u32,
+ overlapped: usize,
+ ) -> i32;
+
+ /// Closes a kernel object handle.
+ /// https://learn.microsoft.com/en-us/windows/win32/api/handleapi/nf-handleapi-closehandle
+ fn CloseHandle(handle: isize) -> i32;
+
+ /// Returns file attributes, or INVALID_FILE_ATTRIBUTES if not found.
+ /// Used here as a lightweight file-existence check.
+ /// https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-getfileattributesw
+ fn GetFileAttributesW(name: *const u16) -> u32;
+
+ /// Retrieves the value of an environment variable.
+ /// https://learn.microsoft.com/en-us/windows/win32/api/processenv/nf-processenv-getenvironmentvariablew
+ fn GetEnvironmentVariableW(name: *const u16, buffer: *mut u16, size: u32) -> u32;
+
+ /// Sets or deletes an environment variable (pass null value to delete).
+ /// https://learn.microsoft.com/en-us/windows/win32/api/processenv/nf-processenv-setenvironmentvariablew
+ fn SetEnvironmentVariableW(name: *const u16, value: *const u16) -> i32;
+
+ /// Creates a new process and its primary thread.
+ /// https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessw
+ fn CreateProcessW(
+ app: *const u16,
+ cmd: *mut u16,
+ proc_attr: usize,
+ thread_attr: usize,
+ inherit: i32,
+ flags: u32,
+ env: usize,
+ dir: usize,
+ startup: *const StartupInfoW,
+ info: *mut ProcessInformation,
+ ) -> i32;
+
+ /// Waits until a handle is signaled (process exits) or timeout elapses.
+ /// https://learn.microsoft.com/en-us/windows/win32/api/synchapi/nf-synchapi-waitforsingleobject
+ fn WaitForSingleObject(handle: isize, ms: u32) -> u32;
+
+ /// Retrieves the exit code of a process.
+ /// https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-getexitcodeprocess
+ fn GetExitCodeProcess(process: isize, code: *mut u32) -> i32;
+
+ /// Terminates the calling process with the given exit code.
+ /// https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-exitprocess
+ fn ExitProcess(code: u32) -> !;
+
+ /// Returns a handle to stdin, stdout, or stderr.
+ /// https://learn.microsoft.com/en-us/windows/console/getstdhandle
+ fn GetStdHandle(id: u32) -> isize;
+
+ /// Returns a pointer to the command-line string for the current process.
+ /// https://learn.microsoft.com/en-us/windows/win32/api/processenv/nf-processenv-getcommandlinew
+ fn GetCommandLineW() -> *const u16;
+
+ /// Writes bytes to a file handle (used here for stderr output).
+ /// https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-writefile
+ fn WriteFile(
+ file: isize,
+ buffer: *const u8,
+ to_write: u32,
+ written: *mut u32,
+ overlapped: usize,
+ ) -> i32;
+
+ /// Creates a directory.
+ /// https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createdirectoryw
+ fn CreateDirectoryW(path: *const u16, security: usize) -> i32;
+
+ /// Deletes a file.
+ /// https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-deletefilew
+ fn DeleteFileW(path: *const u16) -> i32;
+
+ /// Returns system info including processor architecture, using the
+ /// native architecture even when called from a WoW64 process.
+ /// https://learn.microsoft.com/en-us/windows/win32/api/sysinfoapi/nf-sysinfoapi-getnativesysteminfo
+ fn GetNativeSystemInfo(info: *mut SystemInfo);
+}
+
+// A fixed-capacity UTF-16 buffer for building null-terminated wide strings
+// to pass to Win32 APIs. All Win32-facing methods automatically null-terminate.
+//
+// Callers push ASCII (&[u8]) or wide (&WBuf) content; the buffer handles
+// the ASCII-to-UTF-16 widening internally, keeping encoding concerns in
+// one place.
+
+struct WBuf<const N: usize> {
+ buf: [u16; N],
+ len: usize,
+}
+
+impl<const N: usize> WBuf<N> {
+ fn new() -> Self {
+ Self {
+ buf: [0; N],
+ len: 0,
+ }
+ }
+
+ /// Null-terminated pointer for Win32 APIs.
+ fn as_ptr(&mut self) -> *const u16 {
+ self.buf[self.len] = 0;
+ self.buf.as_ptr()
+ }
+
+ /// Mutable null-terminated pointer (for CreateProcessW's lpCommandLine).
+ fn as_mut_ptr(&mut self) -> *mut u16 {
+ self.buf[self.len] = 0;
+ self.buf.as_mut_ptr()
+ }
+
+ /// Append ASCII bytes, widening each byte to UTF-16.
+ fn push_ascii(&mut self, s: &[u8]) -> &mut Self {
+ for &b in s {
+ self.buf[self.len] = b as u16;
+ self.len += 1;
+ }
+ self
+ }
+
+ /// Append the contents of another WBuf.
+ fn push_wbuf<const M: usize>(&mut self, other: &WBuf<M>) -> &mut Self {
+ self.buf[self.len..self.len + other.len].copy_from_slice(&other.buf[..other.len]);
+ self.len += other.len;
+ self
+ }
+
+ /// Append raw UTF-16 content from a pointer until null terminator.
+ /// Used for appending the tail of GetCommandLineW.
+ unsafe fn push_ptr(&mut self, mut p: *const u16) -> &mut Self {
+ loop {
+ let c = *p;
+ if c == 0 {
+ break;
+ }
+ self.buf[self.len] = c;
+ self.len += 1;
+ p = p.add(1);
+ }
+ self
+ }
+
+ /// Find the last path separator (\ or /) and truncate to it,
+ /// effectively navigating to the parent directory.
+ fn pop_path_component(&mut self) -> bool {
+ let mut i = self.len;
+ while i > 0 {
+ i -= 1;
+ if self.buf[i] == b'\\' as u16 || self.buf[i] == b'/' as u16 {
+ self.len = i;
+ return true;
+ }
+ }
+ false
+ }
+
+ /// Check whether a file exists at "<self>\<suffix>".
+ unsafe fn file_exists_with(&mut self, suffix: &[u8]) -> bool {
+ let saved = self.len;
+ self.push_ascii(suffix);
+ let result = GetFileAttributesW(self.as_ptr()) != INVALID_FILE_ATTRIBUTES;
+ self.len = saved;
+ result
+ }
+}
+
+/// Check if an environment variable equals an expected ASCII value.
+/// Neither name nor val should include a null terminator.
+unsafe fn env_eq(name: &[u8], val: &[u8]) -> bool {
+ let mut name_w = WBuf::<64>::new();
+ name_w.push_ascii(name);
+ let mut buf = [0u16; 64];
+ let n = GetEnvironmentVariableW(name_w.as_ptr(), buf.as_mut_ptr(), buf.len() as u32) as usize;
+ if n != val.len() {
+ return false;
+ }
+ for (i, &b) in val.iter().enumerate() {
+ if buf[i] != b as u16 {
+ return false;
+ }
+ }
+ true
+}
+
+/// Get an environment variable's value into a WBuf.
+/// Returns the number of characters written (0 if not set).
+unsafe fn get_env<const N: usize>(name: &[u8], dst: &mut WBuf<N>) -> usize {
+ let mut name_w = WBuf::<64>::new();
+ name_w.push_ascii(name);
+ let n = GetEnvironmentVariableW(
+ name_w.as_ptr(),
+ dst.buf.as_mut_ptr(),
+ dst.buf.len() as u32,
+ ) as usize;
+ dst.len = n;
+ n
+}
+
+/// Unset an environment variable.
+unsafe fn unset_env(name: &[u8]) {
+ let mut name_w = WBuf::<64>::new();
+ name_w.push_ascii(name);
+ SetEnvironmentVariableW(name_w.as_ptr(), ptr::null());
+}
+
+/// C runtime entry point for MinGW/MSVC. Called before main() would be.
+/// We use #[no_main] so we define this directly.
+#[unsafe(no_mangle)]
+pub extern "C" fn mainCRTStartup() -> ! {
+ unsafe { main_impl() }
+}
+
+unsafe fn main_impl() -> ! {
+ // Get our own exe path, e.g. "C:\Users\...\tailscale\tool\go.exe".
+ let mut exe = WBuf::<4096>::new();
+ exe.len = GetModuleFileNameW(0, exe.buf.as_mut_ptr(), exe.buf.len() as u32) as usize;
+ if exe.len == 0 {
+ die(b"GetModuleFileNameW failed\n");
+ }
+
+ // Walk up directories from our exe location to find the repo root,
+ // identified by the presence of "go.toolchain.rev".
+ exe.pop_path_component(); // strip filename, e.g. "...\tool"
+ let repo_root = loop {
+ if !exe.file_exists_with(b"\\go.toolchain.rev") {
+ if !exe.pop_path_component() {
+ die(b"could not find go.toolchain.rev\n");
+ }
+ continue;
+ }
+ break WBuf::<4096> {
+ buf: exe.buf,
+ len: exe.len,
+ };
+ };
+
+ // Read the toolchain revision hash from go.toolchain.rev (or
+ // go.toolchain.next.rev if TS_GO_NEXT=1).
+ let mut rev_path = WBuf::<4096>::new();
+ rev_path.push_wbuf(&repo_root);
+ if env_eq(b"TS_GO_NEXT", b"1") {
+ rev_path.push_ascii(b"\\go.toolchain.next.rev");
+ } else {
+ rev_path.push_ascii(b"\\go.toolchain.rev");
+ }
+
+ let mut rev_buf = [0u8; 256];
+ let rev = read_file_trimmed(&mut rev_path, &mut rev_buf);
+
+ // Build the toolchain path. The rev is normally a git hash, and
+ // the toolchain lives at %USERPROFILE%\.cache\tsgo\<hash>.
+ // If the rev starts with "/" or "\" it's an absolute path to a
+ // local toolchain (used for testing).
+ let mut toolchain = WBuf::<4096>::new();
+ if rev.first() == Some(&b'/') || rev.first() == Some(&b'\\') {
+ toolchain.push_ascii(rev);
+ } else {
+ if get_env(b"USERPROFILE", &mut toolchain) == 0 {
+ die(b"USERPROFILE not set\n");
+ }
+ toolchain.push_ascii(b"\\.cache\\tsgo\\");
+ toolchain.push_ascii(rev);
+ }
+
+ // If the toolchain hasn't been downloaded yet (no ".extracted" marker),
+ // download it. For TS_USE_GOCROSS=1, fall back to PowerShell since
+ // that path also needs to build gocross.
+ if !toolchain.file_exists_with(b".extracted") {
+ if env_eq(b"TS_USE_GOCROSS", b"1") {
+ fallback_pwsh(&repo_root);
+ }
+ download_toolchain(&toolchain, rev);
+ }
+
+ // Build the path to the real go.exe binary inside the toolchain,
+ // or to gocross.exe if TS_USE_GOCROSS=1.
+ let mut go_exe = WBuf::<4096>::new();
+ if env_eq(b"TS_USE_GOCROSS", b"1") {
+ go_exe.push_wbuf(&repo_root).push_ascii(b"\\gocross.exe");
+ } else {
+ go_exe.push_wbuf(&toolchain).push_ascii(b"\\bin\\go.exe");
+ }
+
+ // Unset GOROOT to avoid breaking builds that depend on our Go
+ // fork's patches (e.g. net/). The Go toolchain sets GOROOT
+ // internally from its own location.
+ unset_env(b"GOROOT");
+
+ // Build the new command line by replacing argv[0] with the real
+ // go.exe path. We take the raw command line from GetCommandLineW
+ // and pass the args portion through untouched — no parsing or
+ // re-escaping — so special characters like ^ and = survive intact.
+ let raw_cmd = GetCommandLineW();
+ let args_tail = skip_argv0(raw_cmd);
+
+ let mut cmd = WBuf::<32768>::new();
+ cmd.push_ascii(b"\"");
+ cmd.push_wbuf(&go_exe);
+ cmd.push_ascii(b"\"");
+ cmd.push_ptr(args_tail);
+
+ // Exec: create the child process, wait for it, and exit with its code.
+ let code = run_and_wait(go_exe.as_ptr(), &mut cmd, ptr::null());
+ ExitProcess(code);
+}
+
+/// Download the Go toolchain tarball from GitHub and extract it.
+/// Uses curl.exe and tar.exe which ship with Windows 10+.
+unsafe fn download_toolchain(toolchain: &WBuf<4096>, rev: &[u8]) {
+ stderr(b"# Downloading Go toolchain ");
+ stderr(rev);
+ stderr(b"\n");
+
+ // Create parent directories (%USERPROFILE%\.cache\tsgo).
+ // CreateDirectoryW is fine if the dir already exists.
+ let mut dir = WBuf::<4096>::new();
+ get_env(b"USERPROFILE", &mut dir);
+ dir.push_ascii(b"\\.cache");
+ CreateDirectoryW(dir.as_ptr(), 0);
+ dir.push_ascii(b"\\tsgo");
+ CreateDirectoryW(dir.as_ptr(), 0);
+
+ // Create the toolchain directory itself.
+ let mut tc_dir = WBuf::<4096>::new();
+ tc_dir.push_wbuf(toolchain);
+ CreateDirectoryW(tc_dir.as_ptr(), 0);
+
+ // Detect host architecture via GetNativeSystemInfo (gives real arch
+ // even from a WoW64 32-bit process).
+ let mut si: SystemInfo = core::mem::zeroed();
+ GetNativeSystemInfo(&mut si);
+ let arch: &[u8] = match si.processor_architecture {
+ PROCESSOR_ARCHITECTURE_AMD64 => b"amd64",
+ PROCESSOR_ARCHITECTURE_ARM64 => b"arm64",
+ PROCESSOR_ARCHITECTURE_INTEL => b"386",
+ _ => die(b"unsupported architecture\n"),
+ };
+
+ // Build tarball path: <toolchain>.tar.gz
+ let mut tgz = WBuf::<4096>::new();
+ tgz.push_wbuf(toolchain).push_ascii(b".tar.gz");
+
+ // Build URL:
+ // https://github.com/tailscale/go/releases/download/build-<rev>/windows-<arch>.tar.gz
+ let mut url = [0u8; 512];
+ let mut u = 0;
+ for part in [
+ b"https://github.com/tailscale/go/releases/download/build-" as &[u8],
+ rev,
+ b"/windows-",
+ arch,
+ b".tar.gz",
+ ] {
+ url[u..u + part.len()].copy_from_slice(part);
+ u += part.len();
+ }
+
+ // Run: curl.exe -fsSL -o <tgz> <url>
+ let mut cmd = WBuf::<32768>::new();
+ cmd.push_ascii(b"curl.exe -fsSL -o \"");
+ cmd.push_wbuf(&tgz);
+ cmd.push_ascii(b"\" ");
+ cmd.push_ascii(&url[..u]);
+
+ let code = run_and_wait(ptr::null(), &mut cmd, ptr::null());
+ if code != 0 {
+ die(b"curl failed to download Go toolchain\n");
+ }
+
+ // Run: tar.exe --strip-components=1 -xf <tgz>
+ // with working directory set to the toolchain dir.
+ let mut cmd = WBuf::<32768>::new();
+ cmd.push_ascii(b"tar.exe --strip-components=1 -xf \"");
+ cmd.push_wbuf(&tgz);
+ cmd.push_ascii(b"\"");
+
+ let code = run_and_wait(ptr::null(), &mut cmd, tc_dir.as_ptr());
+ if code != 0 {
+ die(b"tar failed to extract Go toolchain\n");
+ }
+
+ // Write the .extracted marker file.
+ let mut marker = WBuf::<4096>::new();
+ marker.push_wbuf(toolchain).push_ascii(b".extracted");
+ let fh = CreateFileW(marker.as_ptr(), GENERIC_WRITE, 0, 0, CREATE_ALWAYS, 0, 0);
+ if fh != INVALID_HANDLE_VALUE {
+ let mut written: u32 = 0;
+ WriteFile(fh, rev.as_ptr(), rev.len() as u32, &mut written, 0);
+ CloseHandle(fh);
+ }
+
+ // Clean up the tarball.
+ DeleteFileW(tgz.as_ptr());
+}
+
+/// Spawn a child process, wait for it, and return its exit code.
+/// If app is null, CreateProcessW searches PATH using the command line.
+/// If dir is null, the child inherits the current directory.
+unsafe fn run_and_wait(app: *const u16, cmd: &mut WBuf<32768>, dir: *const u16) -> u32 {
+ let si = StartupInfoW {
+ cb: core::mem::size_of::<StartupInfoW>() as u32,
+ reserved: 0,
+ desktop: 0,
+ title: 0,
+ x: 0,
+ y: 0,
+ x_size: 0,
+ y_size: 0,
+ x_count_chars: 0,
+ y_count_chars: 0,
+ fill_attribute: 0,
+ flags: STARTF_USESTDHANDLES,
+ show_window: 0,
+ cb_reserved2: 0,
+ reserved2: 0,
+ std_input: GetStdHandle(STD_INPUT_HANDLE),
+ std_output: GetStdHandle(STD_OUTPUT_HANDLE),
+ std_error: GetStdHandle(STD_ERROR_HANDLE),
+ };
+ let mut pi = ProcessInformation {
+ process: 0,
+ thread: 0,
+ process_id: 0,
+ thread_id: 0,
+ };
+
+ if CreateProcessW(
+ app,
+ cmd.as_mut_ptr(),
+ 0,
+ 0,
+ 1, // bInheritHandles = TRUE
+ 0,
+ 0,
+ dir as usize,
+ &si,
+ &mut pi,
+ ) == 0
+ {
+ die(b"CreateProcess failed\n");
+ }
+
+ WaitForSingleObject(pi.process, INFINITE);
+ let mut code: u32 = 1;
+ GetExitCodeProcess(pi.process, &mut code);
+ CloseHandle(pi.process);
+ CloseHandle(pi.thread);
+ code
+}
+
+/// Fall back to PowerShell for the full bootstrap flow (downloading the
+/// toolchain, optionally building gocross, and then running go):
+/// pwsh -NoProfile -ExecutionPolicy Bypass "<repo>\tool\gocross\gocross-wrapper.ps1" <args...>
+unsafe fn fallback_pwsh(repo_root: &WBuf<4096>) -> ! {
+ let raw_cmd = GetCommandLineW();
+ let args_tail = skip_argv0(raw_cmd);
+
+ let mut cmd = WBuf::<32768>::new();
+ cmd.push_ascii(b"pwsh -NoProfile -ExecutionPolicy Bypass \"");
+ cmd.push_wbuf(repo_root);
+ cmd.push_ascii(b"\\tool\\gocross\\gocross-wrapper.ps1\"");
+ cmd.push_ptr(args_tail);
+
+ // Pass null for lpApplicationName so CreateProcessW searches PATH for "pwsh".
+ let code = run_and_wait(ptr::null(), &mut cmd, ptr::null());
+ ExitProcess(code);
+}
+
+/// Read an entire file (expected to be small ASCII, e.g. a git hash) into buf,
+/// and return the trimmed content as a byte slice.
+unsafe fn read_file_trimmed<'a, const N: usize>(
+ path: &mut WBuf<N>,
+ buf: &'a mut [u8],
+) -> &'a [u8] {
+ let h = CreateFileW(
+ path.as_ptr(),
+ GENERIC_READ,
+ FILE_SHARE_READ,
+ 0,
+ OPEN_EXISTING,
+ 0,
+ 0,
+ );
+ if h == INVALID_HANDLE_VALUE {
+ die(b"cannot open go.toolchain.rev\n");
+ }
+ let mut n: u32 = 0;
+ ReadFile(h, buf.as_mut_ptr(), buf.len() as u32, &mut n, 0);
+ CloseHandle(h);
+
+ let s = &buf[..n as usize];
+ let start = s.iter().position(|b| !b.is_ascii_whitespace()).unwrap_or(s.len());
+ let end = s.iter().rposition(|b| !b.is_ascii_whitespace()).map_or(start, |i| i + 1);
+ &s[start..end]
+}
+
+/// Advance past argv[0] in a raw Windows command line string.
+///
+/// Windows command lines are a single string; argv[0] may be quoted
+/// (if the path contains spaces) or unquoted.
+/// See https://learn.microsoft.com/en-us/cpp/c-language/parsing-c-command-line-arguments
+unsafe fn skip_argv0(cmd: *const u16) -> *const u16 {
+ let mut p = cmd;
+ if *p == b'"' as u16 {
+ // Quoted argv[0]: advance past closing quote.
+ p = p.add(1);
+ while *p != 0 && *p != b'"' as u16 {
+ p = p.add(1);
+ }
+ if *p == b'"' as u16 {
+ p = p.add(1);
+ }
+ } else {
+ // Unquoted argv[0]: advance to first whitespace.
+ while *p != 0 && *p != b' ' as u16 && *p != b'\t' as u16 {
+ p = p.add(1);
+ }
+ }
+ // Return pointer to the rest (typically starts with a space before
+ // the first real argument, or is empty if there are no arguments).
+ p
+}
+
+/// Write bytes to stderr.
+unsafe fn stderr(msg: &[u8]) {
+ let h = GetStdHandle(STD_ERROR_HANDLE);
+ let mut n: u32 = 0;
+ WriteFile(h, msg.as_ptr(), msg.len() as u32, &mut n, 0);
+}
+
+/// Write an error message to stderr and terminate with exit code 1.
+unsafe fn die(msg: &[u8]) -> ! {
+ stderr(b"tool/go: ");
+ stderr(msg);
+ ExitProcess(1);
+}
+
+#[panic_handler]
+fn panic(_: &core::panic::PanicInfo) -> ! {
+ unsafe { ExitProcess(EXIT_CODE_PANIC) }
+}