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 Binary files /dev/null and b/tool/go.exe 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 { + buf: [u16; N], + len: usize, +} + +impl WBuf { + 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(&mut self, other: &WBuf) -> &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 "\". + 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(name: &[u8], dst: &mut WBuf) -> 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\. + // 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: .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-/windows-.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 + 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 + // 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::() 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 "\tool\gocross\gocross-wrapper.ps1" +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, + 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) } +}