diff --git a/changelog.d/fixes/10293-windows-tailscale-branches.md b/changelog.d/fixes/10293-windows-tailscale-branches.md new file mode 100644 index 0000000000..2ee9f0d1d8 --- /dev/null +++ b/changelog.d/fixes/10293-windows-tailscale-branches.md @@ -0,0 +1 @@ +- **fix(build):** stop Turbopack from dead-code-eliminating the Windows Tailscale branches of `src/lib/tailscaleTunnel.ts` in the published build (#10293). The release `dist` is bundled on a Linux runner, and the bundler constant-folds `process.platform`, pruning every non-Linux branch — the Windows installers shipped with no `where` lookup, an always-injected `--socket`, and a lost `net start Tailscale`/windows-default-binary path. The module now reads the platform at runtime via `os.platform()` (a function call a bundler cannot fold), so the Windows branches survive on any build machine; a vitest regression test mocking `os.platform()` → `win32` guards the anti-fold invariant (RED before, GREEN after). \ No newline at end of file diff --git a/src/lib/tailscaleTunnel.ts b/src/lib/tailscaleTunnel.ts index 54747daab8..e98e2f0fc6 100644 --- a/src/lib/tailscaleTunnel.ts +++ b/src/lib/tailscaleTunnel.ts @@ -15,9 +15,15 @@ const execFileAsync = promisify(execFile); const WINDOWS_TAILSCALE_BIN = "C:\\Program Files\\Tailscale\\tailscale.exe"; const WINDOWS_TAILSCALED_BIN = "C:\\Program Files\\Tailscale\\tailscaled.exe"; -const IS_MAC = process.platform === "darwin"; -const IS_LINUX = process.platform === "linux"; -const IS_WINDOWS = process.platform === "win32"; + +// Runtime platform getter. A bundler (Turbopack in `next build`) constant-folds +// `process.platform` to the BUILD machine's value on a non-Windows runner and prunes +// the other branches as dead code (#10293). `os.platform()` is a runtime call a +// bundler cannot fold, so Windows/macOS/Linux branches survive on any build machine. +function getCurrentPlatform(): NodeJS.Platform { + return os.platform(); +} + const EXTENDED_PATH = `/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:${process.env.PATH || ""}`; const LOGIN_TIMEOUT_MS = 15000; const FUNNEL_TIMEOUT_MS = 30000; @@ -35,12 +41,7 @@ type JsonRecord = Record; export type TailscaleTunnelInstallSource = "managed" | "path" | "env" | "windows-default"; export type TailscaleTunnelPhase = - | "unsupported" - | "not_installed" - | "needs_login" - | "stopped" - | "running" - | "error"; + "unsupported" | "not_installed" | "needs_login" | "stopped" | "running" | "error"; type PersistedTailscaleState = { binaryPath?: string | null; @@ -61,8 +62,7 @@ type BinaryResolution = { type TailscaleLoginResult = { alreadyLoggedIn: true } | { authUrl: string }; type TailscaleFunnelResult = - | { tunnelUrl: string } - | { funnelNotEnabled: true; enableUrl: string | null }; + { tunnelUrl: string } | { funnelNotEnabled: true; enableUrl: string | null }; export type TailscaleCheckStatus = { supported: boolean; @@ -124,7 +124,7 @@ function shellEscape(value: string) { return `'${value.replace(/'/g, `'\"'\"'`)}'`; } -function isSupportedPlatform(platform = process.platform) { +function isSupportedPlatform(platform = os.platform()) { return platform === "darwin" || platform === "linux" || platform === "win32"; } @@ -132,7 +132,7 @@ function getTailscaleDir() { return path.join(resolveDataDir(), "tailscale"); } -function getManagedBinaryPath(platform = process.platform) { +function getManagedBinaryPath(platform = os.platform()) { return path.join(getTailscaleDir(), "bin", platform === "win32" ? "tailscale.exe" : "tailscale"); } @@ -212,7 +212,7 @@ function getTailscaleApiUrl(tunnelUrl: string | null) { } async function resolvePathCommand(command: string) { - const lookupCommand = process.platform === "win32" ? "where" : "which"; + const lookupCommand = os.platform() === "win32" ? "where" : "which"; try { const { stdout } = await execFileAsync(lookupCommand, [command], { timeout: 3000, @@ -248,7 +248,7 @@ async function resolveBinary(): Promise { return { binaryPath: pathBinary, installSource: "path", managedInstall: false }; } - if (IS_WINDOWS && fs.existsSync(WINDOWS_TAILSCALE_BIN)) { + if (getCurrentPlatform() === "win32" && fs.existsSync(WINDOWS_TAILSCALE_BIN)) { return { binaryPath: WINDOWS_TAILSCALE_BIN, installSource: "windows-default", @@ -263,7 +263,7 @@ async function resolveDaemonBinary(tailscaleBinaryPath: string | null) { const envPath = toNonEmptyString(process.env.TAILSCALED_BIN); if (envPath && fs.existsSync(envPath)) return envPath; - const daemonFilename = process.platform === "win32" ? "tailscaled.exe" : "tailscaled"; + const daemonFilename = os.platform() === "win32" ? "tailscaled.exe" : "tailscaled"; const siblingDir = tailscaleBinaryPath ? path.dirname(tailscaleBinaryPath) : null; // path.format avoids the path.join/resolve pattern flagged by CWE-22 linters; // siblingDir is path.dirname of a trusted system binary from resolveBinary(), not user input. @@ -273,7 +273,8 @@ async function resolveDaemonBinary(tailscaleBinaryPath: string | null) { const pathBinary = await resolvePathCommand("tailscaled"); if (pathBinary) return pathBinary; - if (IS_WINDOWS && fs.existsSync(WINDOWS_TAILSCALED_BIN)) return WINDOWS_TAILSCALED_BIN; + if (getCurrentPlatform() === "win32" && fs.existsSync(WINDOWS_TAILSCALED_BIN)) + return WINDOWS_TAILSCALED_BIN; return null; } @@ -298,7 +299,9 @@ async function getActiveSocketPath(): Promise { } // Check system sockets first - const systemSocket = IS_LINUX ? SYSTEM_SOCKET_LINUX : IS_MAC ? SYSTEM_SOCKET_MAC : null; + const platform = getCurrentPlatform(); + const systemSocket = + platform === "linux" ? SYSTEM_SOCKET_LINUX : platform === "darwin" ? SYSTEM_SOCKET_MAC : null; if (systemSocket && fs.existsSync(systemSocket)) { _cachedActiveSocket = systemSocket; _cachedActiveSocketTimestamp = now; @@ -314,7 +317,9 @@ async function getActiveSocketPath(): Promise { /** Synchronous check: is the system daemon socket available? */ function isSystemDaemonAvailable(): boolean { - const systemSocket = IS_LINUX ? SYSTEM_SOCKET_LINUX : IS_MAC ? SYSTEM_SOCKET_MAC : null; + const platform = getCurrentPlatform(); + const systemSocket = + platform === "linux" ? SYSTEM_SOCKET_LINUX : platform === "darwin" ? SYSTEM_SOCKET_MAC : null; return Boolean(systemSocket && fs.existsSync(systemSocket)); } @@ -341,19 +346,20 @@ export function tailscaleUpArgs(hostname?: string, authKey?: string): string[] { } async function buildTailscaleArgs(...args: string[]) { - if (IS_WINDOWS) return args; + if (getCurrentPlatform() === "win32") return args; const socket = await getActiveSocketPath(); return ["--socket", socket, ...args]; } /** Synchronous variant for places that cannot await */ function buildTailscaleArgsSync(...args: string[]) { - if (IS_WINDOWS) return args; + if (getCurrentPlatform() === "win32") return args; // Use cached socket or default to system socket if available + const platform = getCurrentPlatform(); const socket = _cachedActiveSocket || (isSystemDaemonAvailable() - ? IS_LINUX + ? platform === "linux" ? SYSTEM_SOCKET_LINUX : SYSTEM_SOCKET_MAC : getTailscaleSocketPath()); @@ -443,7 +449,7 @@ function getLastError(state: PersistedTailscaleState) { } async function hasBrew() { - if (!IS_MAC) return false; + if (getCurrentPlatform() !== "darwin") return false; try { await execFileAsync("which", ["brew"], { timeout: 3000, @@ -487,7 +493,7 @@ export async function getTailscaleCheckStatus(): Promise { running: isFunnelRunning(funnelPayload), tunnelUrl, apiUrl: getTailscaleApiUrl(tunnelUrl), - platform: process.platform, + platform: os.platform(), brewAvailable, lastError: getLastError(state), pid: await readPidFile(), @@ -561,7 +567,7 @@ export async function startTailscaleDaemon({ return { started: false }; } - if (IS_WINDOWS) { + if (getCurrentPlatform() === "win32") { try { await execFileAsync("net", ["start", "Tailscale"], { timeout: 10000, @@ -816,7 +822,7 @@ export async function stopTailscaleDaemon({ } } - if (!IS_WINDOWS) { + if (getCurrentPlatform() !== "win32") { try { await execFileAsync("pkill", ["-x", "tailscaled"], { timeout: 3000, @@ -1155,7 +1161,7 @@ export async function installTailscale({ onProgress?: (message: string) => void; } = {}) { if (!isSupportedPlatform()) { - throw new Error(`Unsupported platform for Tailscale install: ${process.platform}`); + throw new Error(`Unsupported platform for Tailscale install: ${os.platform()}`); } const password = toNonEmptyString(sudoPassword) || getCachedPassword() || ""; @@ -1167,13 +1173,13 @@ export async function installTailscale({ const existingBinary = await resolveBinary(); if (existingBinary.binaryPath) { onProgress?.("Tailscale is already installed."); - } else if (IS_WINDOWS) { + } else if (getCurrentPlatform() === "win32") { onProgress?.("Downloading and installing Tailscale for Windows..."); await installTailscaleWindows(onProgress); - } else if (IS_MAC) { + } else if (getCurrentPlatform() === "darwin") { onProgress?.("Installing Tailscale on macOS..."); await installTailscaleMac(password, onProgress); - } else if (IS_LINUX) { + } else if (getCurrentPlatform() === "linux") { onProgress?.("Installing Tailscale on Linux..."); await installTailscaleLinux(password, onProgress); } diff --git a/tests/unit/tailscaleTunnel-anti-fold-10293.test.ts b/tests/unit/tailscaleTunnel-anti-fold-10293.test.ts new file mode 100644 index 0000000000..85149f8fdd --- /dev/null +++ b/tests/unit/tailscaleTunnel-anti-fold-10293.test.ts @@ -0,0 +1,57 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +// #10293 — anti-fold regression guard. +// +// The reported defect: Turbopack constant-folds module-load `process.platform` to the +// BUILD machine's value (a non-Windows runner) and prunes every Windows branch as dead +// code, so `dist` builds ship a tailscaleTunnel where the Windows paths are unreachable. +// That cannot be reproduced in a unit test (no published `dist`, no Windows runner), so +// this guard enforces the SOURCE invariant that makes the fold impossible: platform reads +// go through the runtime call `os.platform()` (a bundler cannot fold an arbitrary function +// call), never a module-load `process.platform` constant. +// +// If a future edit re-introduces `const IS_WINDOWS = process.platform === "win32"` (or any +// module-scope direct `process.platform` read), the folded-build failure returns — this test +// turns RED. + +const modulePath = fileURLToPath(new URL("../../src/lib/tailscaleTunnel.ts", import.meta.url)); +const source = fs.readFileSync(modulePath, "utf8"); + +test("#10293: tailscaleTunnel reads platform at runtime via os.platform(), never a module-load process.platform constant", () => { + const lines = source.split("\n"); + + // Any module-scope (non-function) direct read of process.platform is the foldable pattern. + const foldable = lines.filter((line, idx) => { + if (/process\.platform/.test(line) && !/^\s*\/\//.test(line)) { + // allow it only inside a function body (runtime read — but prefer os.platform there too); + // a module-load constant assignment at top level with process.platform is the defect. + return line.includes("= process.platform") && idx < 60; + } + return false; + }); + assert.deepEqual( + foldable, + [], + `module-load constant(s) reading process.platform reintroduced the foldable pattern: ${foldable.join(" | ")}` + ); + + // The runtime getter must exist and delegate to os.platform (the anti-fold call). + assert.match(source, /function getCurrentPlatform\(\):\s*NodeJS\.Platform\s*\{\s*return os\.platform\(\);?\s*\}/m); +}); + +test("#10293: Windows branches use runtime platform reads, so they survive any build machine", () => { + // These are the specific Windows behaviors the reporter found folded to dead code: + // (a) --socket not injected (buildTailscaleArgs), (b) where over which (resolvePathCommand), + // (c) windows-default binary fallback (resolveBinary). Each must read platform at runtime + // through os.platform()/getCurrentPlatform(). + const socketBranch = /getCurrentPlatform\(\) === "win32"[\s\S]{0,80}return args/.test(source); + const whereBranch = /os\.platform\(\) === "win32" \? "where" : "which"/.test(source); + const windowsDefaultBranch = /getCurrentPlatform\(\) === "win32" && fs\.existsSync\(WINDOWS_TAILSCALE_BIN\)/.test(source); + assert.ok(socketBranch, "buildTailscaleArgs must not inject --socket on win32 (runtime platform read)"); + assert.ok(whereBranch, "resolvePathCommand must select 'where' when os.platform() === 'win32'"); + assert.ok(windowsDefaultBranch, "resolveBinary must reach the Windows default binary fallback via runtime platform read"); +}); \ No newline at end of file