From bdab91e53100e45301dedce9aad969e96f38fbc6 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:25:30 -0400 Subject: [PATCH] fix(windows): link the CLI launcher's C runtime statically so orca.exe runs without the VC++ Redistributable (#24484) --- config/scripts/build-windows-cli-launcher.mjs | 14 +++- .../build-windows-cli-launcher.test.mjs | 4 ++ config/scripts/windows-pe-imports.mjs | 54 +++++++++++++++ config/scripts/windows-pe-imports.test.mjs | 66 +++++++++++++++++++ .../windows-cli-launcher/.cargo/config.toml | 4 ++ 5 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 config/scripts/windows-pe-imports.mjs create mode 100644 config/scripts/windows-pe-imports.test.mjs create mode 100644 native/windows-cli-launcher/.cargo/config.toml diff --git a/config/scripts/build-windows-cli-launcher.mjs b/config/scripts/build-windows-cli-launcher.mjs index b93005600e9..3d9d93aaed2 100644 --- a/config/scripts/build-windows-cli-launcher.mjs +++ b/config/scripts/build-windows-cli-launcher.mjs @@ -5,6 +5,7 @@ import { createHash } from 'node:crypto' import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' +import { findDynamicVcRuntimeImports, readPeImportedDllNames } from './windows-pe-imports.mjs' export function windowsCliLauncherFingerprint(inputPaths, version) { const hash = createHash('sha256').update(version) @@ -66,6 +67,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) join(crateRoot, 'build.rs'), manifestPath, join(crateRoot, 'app.manifest'), + join(crateRoot, '.cargo', 'config.toml'), iconPath, join(repoRoot, 'config/scripts/build-windows-cli-launcher.mjs') ], @@ -117,6 +119,16 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) process.exit(result.status ?? 1) } - copyFileSync(join(targetDirectory, 'release', 'orca.exe'), outputPath) + const builtPath = join(targetDirectory, 'release', 'orca.exe') + const vcRuntimeImports = findDynamicVcRuntimeImports( + readPeImportedDllNames(readFileSync(builtPath)) + ) + if (vcRuntimeImports.length > 0) { + // Why fatal: those DLLs ship with the Visual C++ Redistributable, so the CLI would fail to start on a clean Windows install. + throw new Error( + `orca.exe imports ${vcRuntimeImports.join(', ')}; the C runtime must be linked statically (native/windows-cli-launcher/.cargo/config.toml).` + ) + } + copyFileSync(builtPath, outputPath) writeFileSync(`${outputPath}.sha256`, fingerprint) } diff --git a/config/scripts/build-windows-cli-launcher.test.mjs b/config/scripts/build-windows-cli-launcher.test.mjs index e83b57f65e7..0e06cafd22e 100644 --- a/config/scripts/build-windows-cli-launcher.test.mjs +++ b/config/scripts/build-windows-cli-launcher.test.mjs @@ -17,6 +17,7 @@ import { windowsCliLauncherFileVersion, windowsCliLauncherFingerprint } from './build-windows-cli-launcher.mjs' +import { findDynamicVcRuntimeImports, readPeImportedDllNames } from './windows-pe-imports.mjs' const itCrossHost = process.platform === 'win32' ? it.skip : it const projectRoot = resolve(import.meta.dirname, '../..') @@ -131,6 +132,9 @@ describe('Windows CLI launcher', () => { expect(info.ProductVersion).toBe(version) const binary = readFileSync(launcherPath) expect(binary.includes(Buffer.from('requestedExecutionLevel level="asInvoker"'))).toBe(true) + const imports = readPeImportedDllNames(binary) + expect(imports.map((name) => name.toLowerCase())).toContain('kernel32.dll') + expect(findDynamicVcRuntimeImports(imports)).toEqual([]) const icon = readFileSync(join(projectRoot, 'resources', 'build', 'icon.ico')) const imageSize = icon.readUInt32LE(14) const imageOffset = icon.readUInt32LE(18) diff --git a/config/scripts/windows-pe-imports.mjs b/config/scripts/windows-pe-imports.mjs new file mode 100644 index 00000000000..340bc08a2ae --- /dev/null +++ b/config/scripts/windows-pe-imports.mjs @@ -0,0 +1,54 @@ +// Why a hand-rolled reader: the release guard only needs the import table's DLL +// names, and a parser dependency would be a new supply-chain input for one check. +const IMPORT_DIRECTORY_INDEX = 1 + +export function readPeImportedDllNames(buffer) { + const peOffset = buffer.readUInt32LE(0x3c) + if (buffer.toString('latin1', peOffset, peOffset + 4) !== 'PE\0\0') { + throw new Error('Not a PE image') + } + const sectionCount = buffer.readUInt16LE(peOffset + 6) + const optionalHeaderSize = buffer.readUInt16LE(peOffset + 20) + const optionalHeader = peOffset + 24 + const isPe32Plus = buffer.readUInt16LE(optionalHeader) === 0x20b + const dataDirectories = optionalHeader + (isPe32Plus ? 112 : 96) + const importRva = buffer.readUInt32LE(dataDirectories + IMPORT_DIRECTORY_INDEX * 8) + if (importRva === 0) { + return [] + } + const sections = [] + for (let index = 0; index < sectionCount; index += 1) { + const header = optionalHeader + optionalHeaderSize + index * 40 + sections.push({ + virtualAddress: buffer.readUInt32LE(header + 12), + size: Math.max(buffer.readUInt32LE(header + 8), buffer.readUInt32LE(header + 16)), + rawOffset: buffer.readUInt32LE(header + 20) + }) + } + const fileOffset = (rva) => { + const section = sections.find( + (candidate) => + rva >= candidate.virtualAddress && rva < candidate.virtualAddress + candidate.size + ) + if (!section) { + throw new Error(`RVA 0x${rva.toString(16)} is outside every section`) + } + return rva - section.virtualAddress + section.rawOffset + } + const names = [] + for (let descriptor = fileOffset(importRva); ; descriptor += 20) { + const nameRva = buffer.readUInt32LE(descriptor + 12) + if (nameRva === 0) { + return names + } + const start = fileOffset(nameRva) + names.push(buffer.toString('latin1', start, buffer.indexOf(0, start))) + } +} + +// The Visual C++ runtime DLLs ship with the Redistributable, not with Windows. +const DYNAMIC_VC_RUNTIME_RE = /^(?:vcruntime|msvcp|msvcr)\d+(?:_\d+)?\.dll$/i + +export function findDynamicVcRuntimeImports(dllNames) { + return dllNames.filter((name) => DYNAMIC_VC_RUNTIME_RE.test(name)) +} diff --git a/config/scripts/windows-pe-imports.test.mjs b/config/scripts/windows-pe-imports.test.mjs new file mode 100644 index 00000000000..11cfbbfc78e --- /dev/null +++ b/config/scripts/windows-pe-imports.test.mjs @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { findDynamicVcRuntimeImports, readPeImportedDllNames } from './windows-pe-imports.mjs' + +// Smallest PE32+ image with one section holding an import directory for `dllNames`. +function peWithImports(dllNames) { + const peOffset = 0x40 + const optionalHeaderSize = 240 + const sectionHeader = peOffset + 24 + optionalHeaderSize + const rawOffset = 0x200 + const virtualAddress = 0x1000 + const descriptorsSize = (dllNames.length + 1) * 20 + const strings = dllNames.map((name) => Buffer.from(`${name}\0`, 'latin1')) + const sectionSize = descriptorsSize + strings.reduce((sum, item) => sum + item.length, 0) + const buffer = Buffer.alloc(rawOffset + sectionSize) + buffer.write('MZ', 0, 'latin1') + buffer.writeUInt32LE(peOffset, 0x3c) + buffer.write('PE\0\0', peOffset, 'latin1') + buffer.writeUInt16LE(0x8664, peOffset + 4) + buffer.writeUInt16LE(1, peOffset + 6) + buffer.writeUInt16LE(optionalHeaderSize, peOffset + 20) + buffer.writeUInt16LE(0x20b, peOffset + 24) + buffer.writeUInt32LE(virtualAddress, peOffset + 24 + 112 + 8) + buffer.writeUInt32LE(sectionSize, sectionHeader + 8) + buffer.writeUInt32LE(virtualAddress, sectionHeader + 12) + buffer.writeUInt32LE(sectionSize, sectionHeader + 16) + buffer.writeUInt32LE(rawOffset, sectionHeader + 20) + let stringRva = virtualAddress + descriptorsSize + strings.forEach((item, index) => { + buffer.writeUInt32LE(stringRva, rawOffset + index * 20 + 12) + item.copy(buffer, rawOffset + (stringRva - virtualAddress)) + stringRva += item.length + }) + return buffer +} + +describe('windows PE imports', () => { + it('reads every imported DLL name in order', () => { + const names = ['KERNEL32.dll', 'VCRUNTIME140.dll', 'api-ms-win-crt-runtime-l1-1-0.dll'] + expect(readPeImportedDllNames(peWithImports(names))).toEqual(names) + }) + + it('reads an image with no imports', () => { + expect(readPeImportedDllNames(peWithImports([]))).toEqual([]) + }) + + it('rejects a file that is not a PE image', () => { + const buffer = Buffer.alloc(0x80) + buffer.writeUInt32LE(0x40, 0x3c) + expect(() => readPeImportedDllNames(buffer)).toThrow('Not a PE image') + }) + + it('flags only the Visual C++ Redistributable DLLs', () => { + expect( + findDynamicVcRuntimeImports([ + 'KERNEL32.dll', + 'ntdll.dll', + 'VCRUNTIME140.dll', + 'vcruntime140_1.dll', + 'MSVCP140.dll', + 'msvcr120.dll', + 'api-ms-win-crt-heap-l1-1-0.dll', + 'ucrtbase.dll' + ]) + ).toEqual(['VCRUNTIME140.dll', 'vcruntime140_1.dll', 'MSVCP140.dll', 'msvcr120.dll']) + }) +}) diff --git a/native/windows-cli-launcher/.cargo/config.toml b/native/windows-cli-launcher/.cargo/config.toml new file mode 100644 index 00000000000..7a86328d513 --- /dev/null +++ b/native/windows-cli-launcher/.cargo/config.toml @@ -0,0 +1,4 @@ +# Why: link the C runtime statically so orca.exe runs on Windows hosts without the +# Visual C++ Redistributable (VCRUNTIME140.dll is not part of Windows). +[target.'cfg(all(windows, target_env = "msvc"))'] +rustflags = ["-C", "target-feature=+crt-static"]