mirror of
https://github.com/oven-sh/bun
synced 2026-02-27 03:57:23 +01:00
Compare commits
2 Commits
main
...
claude/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e222414173 | ||
|
|
8267c13a37 |
@@ -472,9 +472,12 @@ function getBuildCommand(target, options, label) {
|
||||
const { profile } = target;
|
||||
const buildProfile = profile || "release";
|
||||
|
||||
// Windows code signing is handled by a dedicated 'windows-sign' step after
|
||||
// all Windows builds complete — see getWindowsSignStep(). smctl is x64-only,
|
||||
// so signing on the build agent wouldn't work for ARM64 anyway.
|
||||
if (target.os === "windows" && label === "build-bun") {
|
||||
// Only sign release builds, not canary builds (DigiCert charges per signature)
|
||||
// Skip signing on ARM64 for now — smctl (x64-only) silently fails under emulation
|
||||
const enableSigning = !options.canary && target.arch !== "aarch64" ? " -DENABLE_WINDOWS_CODESIGNING=ON" : "";
|
||||
return `bun run build:${buildProfile}${enableSigning}`;
|
||||
}
|
||||
|
||||
return `bun run build:${buildProfile}`;
|
||||
}
|
||||
@@ -779,74 +782,24 @@ function getBuildImageStep(platform, options) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-signs all Windows artifacts on an x64 agent. DigiCert smctl is x64-only
|
||||
* and silently fails under ARM64 emulation, so signing must happen here instead
|
||||
* of inline during each build. Re-uploads signed zips with the same names so
|
||||
* the release step picks them up transparently.
|
||||
* @param {Platform[]} windowsPlatforms
|
||||
* @param {PipelineOptions} options
|
||||
* @returns {Step}
|
||||
*/
|
||||
function getWindowsSignStep(windowsPlatforms, options) {
|
||||
// Each build-bun step produces two zips: <triplet>-profile.zip and <triplet>.zip
|
||||
const artifacts = [];
|
||||
const buildSteps = [];
|
||||
for (const platform of windowsPlatforms) {
|
||||
const triplet = getTargetTriplet(platform);
|
||||
const stepKey = `${getTargetKey(platform)}-build-bun`;
|
||||
artifacts.push(`${triplet}-profile.zip`, `${triplet}.zip`);
|
||||
buildSteps.push(stepKey, stepKey);
|
||||
}
|
||||
|
||||
// Run on an x64 build agent — smctl doesn't work on ARM64
|
||||
const signPlatform = windowsPlatforms.find(p => p.arch === "x64" && !p.baseline) ?? windowsPlatforms[0];
|
||||
|
||||
return {
|
||||
key: "windows-sign",
|
||||
label: `${getBuildkiteEmoji("windows")} sign`,
|
||||
depends_on: windowsPlatforms.map(p => `${getTargetKey(p)}-build-bun`),
|
||||
agents: getEc2Agent(signPlatform, options, {
|
||||
instanceType: getAzureVmSize("windows", "x64", "test"),
|
||||
}),
|
||||
retry: getRetry(),
|
||||
cancel_on_build_failing: isMergeQueue(),
|
||||
command: [
|
||||
`powershell -NoProfile -ExecutionPolicy Bypass -File .buildkite/scripts/sign-windows-artifacts.ps1 ` +
|
||||
`-Artifacts ${artifacts.join(",")} ` +
|
||||
`-BuildSteps ${buildSteps.join(",")}`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Platform[]} buildPlatforms
|
||||
* @param {PipelineOptions} options
|
||||
* @param {{ signed: boolean }} [extra]
|
||||
* @returns {Step}
|
||||
*/
|
||||
function getReleaseStep(buildPlatforms, options, { signed = false } = {}) {
|
||||
function getReleaseStep(buildPlatforms, options) {
|
||||
const { canary } = options;
|
||||
const revision = typeof canary === "number" ? canary : 1;
|
||||
|
||||
// When signing ran, depend on windows-sign instead of the raw Windows builds
|
||||
// so we wait for signed artifacts before releasing.
|
||||
const depends_on = signed
|
||||
? [...buildPlatforms.filter(p => p.os !== "windows").map(p => `${getTargetKey(p)}-build-bun`), "windows-sign"]
|
||||
: buildPlatforms.map(platform => `${getTargetKey(platform)}-build-bun`);
|
||||
|
||||
return {
|
||||
key: "release",
|
||||
label: getBuildkiteEmoji("rocket"),
|
||||
agents: {
|
||||
queue: "test-darwin",
|
||||
},
|
||||
depends_on,
|
||||
depends_on: buildPlatforms.map(platform => `${getTargetKey(platform)}-build-bun`),
|
||||
env: {
|
||||
CANARY: revision,
|
||||
// Tells upload-release.sh to fetch Windows zips from the sign step
|
||||
// (same filenames, but the signed re-uploads are the ones we want).
|
||||
WINDOWS_ARTIFACT_STEP: signed ? "windows-sign" : "",
|
||||
},
|
||||
command: ".buildkite/scripts/upload-release.sh",
|
||||
};
|
||||
@@ -952,7 +905,6 @@ function getBenchmarkStep() {
|
||||
* @property {string | boolean} [forceBuilds]
|
||||
* @property {string | boolean} [forceTests]
|
||||
* @property {string | boolean} [buildImages]
|
||||
* @property {string | boolean} [signWindows]
|
||||
* @property {string | boolean} [publishImages]
|
||||
* @property {number} [canary]
|
||||
* @property {Platform[]} [buildPlatforms]
|
||||
@@ -1228,7 +1180,6 @@ async function getPipelineOptions() {
|
||||
skipBuilds: parseOption(/\[(skip builds?|no builds?|only tests?)\]/i),
|
||||
forceBuilds: parseOption(/\[(force builds?)\]/i),
|
||||
skipTests: parseOption(/\[(skip tests?|no tests?|only builds?)\]/i),
|
||||
signWindows: parseOption(/\[(sign windows)\]/i),
|
||||
buildImages: parseOption(/\[(build (?:(?:windows|linux) )?images?)\]/i),
|
||||
dryRun: parseOption(/\[(dry run)\]/i),
|
||||
publishImages: parseOption(/\[(publish (?:(?:windows|linux) )?images?)\]/i),
|
||||
@@ -1343,19 +1294,8 @@ async function getPipeline(options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// Sign Windows builds on release (non-canary main) or when [sign windows]
|
||||
// is in the commit message (for testing the sign step on a branch).
|
||||
// DigiCert charges per signature, so canary builds are never signed.
|
||||
const shouldSignWindows = (isMainBranch() && !options.canary) || options.signWindows;
|
||||
if (shouldSignWindows) {
|
||||
const windowsPlatforms = buildPlatforms.filter(p => p.os === "windows");
|
||||
if (windowsPlatforms.length > 0) {
|
||||
steps.push(getWindowsSignStep(windowsPlatforms, options));
|
||||
}
|
||||
}
|
||||
|
||||
if (isMainBranch()) {
|
||||
steps.push(getReleaseStep(buildPlatforms, options, { signed: shouldSignWindows }));
|
||||
steps.push(getReleaseStep(buildPlatforms, options));
|
||||
}
|
||||
steps.push(getBenchmarkStep());
|
||||
|
||||
|
||||
@@ -1,281 +0,0 @@
|
||||
# Batch Windows code signing for all bun-windows-*.zip Buildkite artifacts.
|
||||
#
|
||||
# This runs as a dedicated pipeline step on a Windows x64 agent after all
|
||||
# Windows build-bun steps complete. Signing is done here instead of inline
|
||||
# during each build because DigiCert smctl is x64-only and silently fails
|
||||
# under ARM64 emulation.
|
||||
#
|
||||
# Each zip is downloaded, its exe signed in place, and the zip is re-packed
|
||||
# with the same name so downstream steps (release, tests) see signed binaries.
|
||||
|
||||
param(
|
||||
# Comma-separated list. powershell.exe -File passes everything as
|
||||
# literal strings, so [string[]] with "a,b,c" becomes a 1-element array.
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$Artifacts,
|
||||
|
||||
# Comma-separated, same length as Artifacts, mapping each zip to its source step.
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$BuildSteps
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
|
||||
$ArtifactList = $Artifacts -split ","
|
||||
$BuildStepList = $BuildSteps -split ","
|
||||
|
||||
# smctl shells out to signtool.exe which is only in PATH when the VS dev
|
||||
# environment is loaded. Dot-source the existing helper to set it up.
|
||||
. $PSScriptRoot\..\..\scripts\vs-shell.ps1
|
||||
|
||||
function Log-Info {
|
||||
param([string]$Message)
|
||||
Write-Host "[INFO] $Message" -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
function Log-Success {
|
||||
param([string]$Message)
|
||||
Write-Host "[SUCCESS] $Message" -ForegroundColor Green
|
||||
}
|
||||
|
||||
function Log-Error {
|
||||
param([string]$Message)
|
||||
Write-Host "[ERROR] $Message" -ForegroundColor Red
|
||||
}
|
||||
|
||||
function Log-Debug {
|
||||
param([string]$Message)
|
||||
if ($env:DEBUG -eq "true" -or $env:DEBUG -eq "1") {
|
||||
Write-Host "[DEBUG] $Message" -ForegroundColor Gray
|
||||
}
|
||||
}
|
||||
|
||||
function Get-BuildkiteSecret {
|
||||
param([string]$Name)
|
||||
$value = & buildkite-agent secret get $Name 2>&1
|
||||
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrEmpty($value)) {
|
||||
throw "Failed to fetch Buildkite secret: $Name"
|
||||
}
|
||||
return $value
|
||||
}
|
||||
|
||||
function Ensure-Secrets {
|
||||
Log-Info "Fetching signing secrets from Buildkite..."
|
||||
$env:SM_API_KEY = Get-BuildkiteSecret "SM_API_KEY"
|
||||
$env:SM_CLIENT_CERT_PASSWORD = Get-BuildkiteSecret "SM_CLIENT_CERT_PASSWORD"
|
||||
$env:SM_CLIENT_CERT_FILE = Get-BuildkiteSecret "SM_CLIENT_CERT_FILE"
|
||||
$env:SM_KEYPAIR_ALIAS = Get-BuildkiteSecret "SM_KEYPAIR_ALIAS"
|
||||
$env:SM_HOST = Get-BuildkiteSecret "SM_HOST"
|
||||
Log-Success "All signing secrets fetched"
|
||||
}
|
||||
|
||||
function Setup-Certificate {
|
||||
Log-Info "Decoding client certificate..."
|
||||
try {
|
||||
$tempCertPath = Join-Path $env:TEMP "digicert_cert_$(Get-Random).p12"
|
||||
$certBytes = [System.Convert]::FromBase64String($env:SM_CLIENT_CERT_FILE)
|
||||
[System.IO.File]::WriteAllBytes($tempCertPath, $certBytes)
|
||||
$fileSize = (Get-Item $tempCertPath).Length
|
||||
if ($fileSize -lt 100) {
|
||||
throw "Decoded certificate too small: $fileSize bytes"
|
||||
}
|
||||
$env:SM_CLIENT_CERT_FILE = $tempCertPath
|
||||
$script:TempCertPath = $tempCertPath
|
||||
Log-Success "Certificate decoded ($fileSize bytes)"
|
||||
} catch {
|
||||
if (Test-Path $env:SM_CLIENT_CERT_FILE) {
|
||||
Log-Info "Using certificate file path directly: $env:SM_CLIENT_CERT_FILE"
|
||||
} else {
|
||||
throw "SM_CLIENT_CERT_FILE is neither valid base64 nor an existing file"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Install-KeyLocker {
|
||||
Log-Info "Setting up DigiCert KeyLocker tools..."
|
||||
$installDir = "C:\BuildTools\DigiCert"
|
||||
$smctlPath = Join-Path $installDir "smctl.exe"
|
||||
|
||||
if (Test-Path $smctlPath) {
|
||||
Log-Success "smctl already installed at $smctlPath"
|
||||
$env:PATH = "$installDir;$env:PATH"
|
||||
return $smctlPath
|
||||
}
|
||||
|
||||
if (!(Test-Path $installDir)) {
|
||||
New-Item -ItemType Directory -Path $installDir -Force | Out-Null
|
||||
}
|
||||
|
||||
# smctl is x64-only; this script must run on an x64 agent
|
||||
$msiUrl = "https://bun-ci-assets.bun.sh/Keylockertools-windows-x64.msi"
|
||||
$msiPath = Join-Path $env:TEMP "Keylockertools-windows-x64.msi"
|
||||
|
||||
Log-Info "Downloading KeyLocker MSI from $msiUrl"
|
||||
if (Test-Path $msiPath) { Remove-Item $msiPath -Force }
|
||||
(New-Object System.Net.WebClient).DownloadFile($msiUrl, $msiPath)
|
||||
if (!(Test-Path $msiPath)) { throw "MSI download failed" }
|
||||
|
||||
Log-Info "Installing KeyLocker MSI..."
|
||||
$proc = Start-Process -FilePath "msiexec.exe" -Wait -PassThru -NoNewWindow -ArgumentList @(
|
||||
"/i", "`"$msiPath`"",
|
||||
"/quiet", "/norestart",
|
||||
"TARGETDIR=`"$installDir`"",
|
||||
"INSTALLDIR=`"$installDir`"",
|
||||
"ACCEPT_EULA=1",
|
||||
"ADDLOCAL=ALL"
|
||||
)
|
||||
if ($proc.ExitCode -ne 0) {
|
||||
throw "MSI install failed with exit code $($proc.ExitCode)"
|
||||
}
|
||||
|
||||
if (!(Test-Path $smctlPath)) {
|
||||
$found = Get-ChildItem -Path $installDir -Filter "smctl.exe" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($found) {
|
||||
$smctlPath = $found.FullName
|
||||
$installDir = $found.DirectoryName
|
||||
} else {
|
||||
throw "smctl.exe not found after install"
|
||||
}
|
||||
}
|
||||
|
||||
$env:PATH = "$installDir;$env:PATH"
|
||||
Log-Success "smctl installed at $smctlPath"
|
||||
return $smctlPath
|
||||
}
|
||||
|
||||
function Configure-KeyLocker {
|
||||
param([string]$Smctl)
|
||||
Log-Info "Configuring KeyLocker..."
|
||||
|
||||
$version = & $Smctl --version 2>&1
|
||||
Log-Debug "smctl version: $version"
|
||||
|
||||
$saveOut = & $Smctl credentials save $env:SM_API_KEY $env:SM_CLIENT_CERT_PASSWORD 2>&1 | Out-String
|
||||
Log-Debug "credentials save: $saveOut"
|
||||
|
||||
$healthOut = & $Smctl healthcheck 2>&1 | Out-String
|
||||
Log-Debug "healthcheck: $healthOut"
|
||||
if ($healthOut -notlike "*Healthy*" -and $healthOut -notlike "*SUCCESS*" -and $LASTEXITCODE -ne 0) {
|
||||
Log-Error "healthcheck output: $healthOut"
|
||||
# Don't throw — healthcheck is sometimes flaky but signing still works
|
||||
}
|
||||
|
||||
$syncOut = & $Smctl windows certsync 2>&1 | Out-String
|
||||
Log-Debug "certsync: $syncOut"
|
||||
|
||||
Log-Success "KeyLocker configured"
|
||||
}
|
||||
|
||||
function Download-Artifact {
|
||||
param([string]$Name, [string]$StepKey)
|
||||
|
||||
Log-Info "Downloading $Name from step $StepKey"
|
||||
& buildkite-agent artifact download $Name . --step $StepKey
|
||||
if ($LASTEXITCODE -ne 0 -or !(Test-Path $Name)) {
|
||||
throw "Failed to download artifact: $Name"
|
||||
}
|
||||
Log-Success "Downloaded $Name ($((Get-Item $Name).Length) bytes)"
|
||||
}
|
||||
|
||||
function Sign-Exe {
|
||||
param([string]$ExePath, [string]$Smctl)
|
||||
|
||||
$fileName = Split-Path $ExePath -Leaf
|
||||
Log-Info "Signing $fileName ($((Get-Item $ExePath).Length) bytes)..."
|
||||
|
||||
$existing = Get-AuthenticodeSignature $ExePath
|
||||
if ($existing.Status -eq "Valid") {
|
||||
Log-Info "$fileName already signed by $($existing.SignerCertificate.Subject), skipping"
|
||||
return
|
||||
}
|
||||
|
||||
$out = & $Smctl sign --keypair-alias $env:SM_KEYPAIR_ALIAS --input $ExePath --verbose 2>&1 | Out-String
|
||||
Log-Info "smctl output: $out"
|
||||
# smctl exits 0 even on failure — must also check output text
|
||||
if ($LASTEXITCODE -ne 0 -or $out -like "*FAILED*" -or $out -like "*error*") {
|
||||
throw "Signing failed for $fileName (exit $LASTEXITCODE): $out"
|
||||
}
|
||||
|
||||
$sig = Get-AuthenticodeSignature $ExePath
|
||||
if ($sig.Status -ne "Valid") {
|
||||
throw "$fileName signature verification failed: $($sig.Status) - $($sig.StatusMessage)"
|
||||
}
|
||||
Log-Success "$fileName signed by $($sig.SignerCertificate.Subject)"
|
||||
}
|
||||
|
||||
function Sign-Artifact {
|
||||
param([string]$ZipName, [string]$Smctl)
|
||||
|
||||
Write-Host "================================================" -ForegroundColor Cyan
|
||||
Write-Host " Signing $ZipName" -ForegroundColor Cyan
|
||||
Write-Host "================================================" -ForegroundColor Cyan
|
||||
|
||||
$extractDir = [System.IO.Path]::GetFileNameWithoutExtension($ZipName)
|
||||
|
||||
if (Test-Path $extractDir) { Remove-Item $extractDir -Recurse -Force }
|
||||
|
||||
Log-Info "Extracting $ZipName"
|
||||
Expand-Archive -Path $ZipName -DestinationPath . -Force
|
||||
if (!(Test-Path $extractDir)) {
|
||||
throw "Expected directory $extractDir not found after extraction"
|
||||
}
|
||||
|
||||
$exes = Get-ChildItem -Path $extractDir -Filter "*.exe"
|
||||
if ($exes.Count -eq 0) {
|
||||
throw "No .exe files found in $extractDir"
|
||||
}
|
||||
|
||||
foreach ($exe in $exes) {
|
||||
Sign-Exe -ExePath $exe.FullName -Smctl $Smctl
|
||||
}
|
||||
|
||||
Log-Info "Re-packing $ZipName"
|
||||
Remove-Item $ZipName -Force
|
||||
Compress-Archive -Path $extractDir -DestinationPath $ZipName -CompressionLevel Optimal
|
||||
Remove-Item $extractDir -Recurse -Force
|
||||
|
||||
Log-Info "Uploading signed $ZipName"
|
||||
& buildkite-agent artifact upload $ZipName
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Failed to upload $ZipName"
|
||||
}
|
||||
|
||||
Log-Success "$ZipName signed and uploaded"
|
||||
}
|
||||
|
||||
# Main
|
||||
try {
|
||||
Write-Host "================================================" -ForegroundColor Cyan
|
||||
Write-Host " Windows Artifact Code Signing" -ForegroundColor Cyan
|
||||
Write-Host "================================================" -ForegroundColor Cyan
|
||||
|
||||
if ($ArtifactList.Count -ne $BuildStepList.Count) {
|
||||
throw "Artifact count ($($ArtifactList.Count)) must match BuildStep count ($($BuildStepList.Count))"
|
||||
}
|
||||
Log-Info "Will sign $($ArtifactList.Count) artifacts: $($ArtifactList -join ', ')"
|
||||
|
||||
Ensure-Secrets
|
||||
Setup-Certificate
|
||||
$smctl = Install-KeyLocker
|
||||
Configure-KeyLocker -Smctl $smctl
|
||||
|
||||
for ($i = 0; $i -lt $ArtifactList.Count; $i++) {
|
||||
Download-Artifact -Name $ArtifactList[$i] -StepKey $BuildStepList[$i]
|
||||
Sign-Artifact -ZipName $ArtifactList[$i] -Smctl $smctl
|
||||
}
|
||||
|
||||
Write-Host "================================================" -ForegroundColor Green
|
||||
Write-Host " All artifacts signed successfully" -ForegroundColor Green
|
||||
Write-Host "================================================" -ForegroundColor Green
|
||||
exit 0
|
||||
|
||||
} catch {
|
||||
Log-Error "Signing failed: $_"
|
||||
exit 1
|
||||
|
||||
} finally {
|
||||
if ($script:TempCertPath -and (Test-Path $script:TempCertPath)) {
|
||||
Remove-Item $script:TempCertPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
470
.buildkite/scripts/sign-windows.ps1
Normal file
470
.buildkite/scripts/sign-windows.ps1
Normal file
@@ -0,0 +1,470 @@
|
||||
# Windows Code Signing Script for Bun
|
||||
# Uses DigiCert KeyLocker for Authenticode signing
|
||||
# Native PowerShell implementation - no path translation issues
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$BunProfileExe,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$BunExe
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
|
||||
# Logging functions
|
||||
function Log-Info {
|
||||
param([string]$Message)
|
||||
Write-Host "[INFO] $Message" -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
function Log-Success {
|
||||
param([string]$Message)
|
||||
Write-Host "[SUCCESS] $Message" -ForegroundColor Green
|
||||
}
|
||||
|
||||
function Log-Error {
|
||||
param([string]$Message)
|
||||
Write-Host "[ERROR] $Message" -ForegroundColor Red
|
||||
}
|
||||
|
||||
function Log-Debug {
|
||||
param([string]$Message)
|
||||
if ($env:DEBUG -eq "true" -or $env:DEBUG -eq "1") {
|
||||
Write-Host "[DEBUG] $Message" -ForegroundColor Gray
|
||||
}
|
||||
}
|
||||
|
||||
# Detect system architecture
|
||||
$script:IsARM64 = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq [System.Runtime.InteropServices.Architecture]::Arm64
|
||||
$script:VsArch = if ($script:IsARM64) { "arm64" } else { "amd64" }
|
||||
|
||||
# Load Visual Studio environment if not already loaded
|
||||
function Ensure-VSEnvironment {
|
||||
if ($null -eq $env:VSINSTALLDIR) {
|
||||
Log-Info "Loading Visual Studio environment for $script:VsArch..."
|
||||
|
||||
$vswhere = "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe"
|
||||
if (!(Test-Path $vswhere)) {
|
||||
throw "Command not found: vswhere (did you install Visual Studio?)"
|
||||
}
|
||||
|
||||
$vsDir = & $vswhere -prerelease -latest -property installationPath
|
||||
if ($null -eq $vsDir) {
|
||||
$vsDir = Get-ChildItem -Path "C:\Program Files\Microsoft Visual Studio\2022" -Directory -ErrorAction SilentlyContinue
|
||||
if ($null -eq $vsDir) {
|
||||
throw "Visual Studio directory not found."
|
||||
}
|
||||
$vsDir = $vsDir.FullName
|
||||
}
|
||||
|
||||
Push-Location $vsDir
|
||||
try {
|
||||
$vsShell = Join-Path -Path $vsDir -ChildPath "Common7\Tools\Launch-VsDevShell.ps1"
|
||||
. $vsShell -Arch $script:VsArch -HostArch $script:VsArch
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
Log-Success "Visual Studio environment loaded"
|
||||
}
|
||||
|
||||
if ($env:VSCMD_ARG_TGT_ARCH -eq "x86") {
|
||||
throw "Visual Studio environment is targeting 32 bit x86, but only 64-bit architectures (x64/arm64) are supported."
|
||||
}
|
||||
}
|
||||
|
||||
# Check for required environment variables
|
||||
function Check-Environment {
|
||||
Log-Info "Checking environment variables..."
|
||||
|
||||
$required = @{
|
||||
"SM_API_KEY" = $env:SM_API_KEY
|
||||
"SM_CLIENT_CERT_PASSWORD" = $env:SM_CLIENT_CERT_PASSWORD
|
||||
"SM_KEYPAIR_ALIAS" = $env:SM_KEYPAIR_ALIAS
|
||||
"SM_HOST" = $env:SM_HOST
|
||||
"SM_CLIENT_CERT_FILE" = $env:SM_CLIENT_CERT_FILE
|
||||
}
|
||||
|
||||
$missing = @()
|
||||
foreach ($key in $required.Keys) {
|
||||
if ([string]::IsNullOrEmpty($required[$key])) {
|
||||
$missing += $key
|
||||
} else {
|
||||
Log-Debug "$key is set (length: $($required[$key].Length))"
|
||||
}
|
||||
}
|
||||
|
||||
if ($missing.Count -gt 0) {
|
||||
throw "Missing required environment variables: $($missing -join ', ')"
|
||||
}
|
||||
|
||||
Log-Success "All required environment variables are present"
|
||||
}
|
||||
|
||||
# Setup certificate file
|
||||
function Setup-Certificate {
|
||||
Log-Info "Setting up certificate..."
|
||||
|
||||
# Always try to decode as base64 first
|
||||
# If it fails, then treat as file path
|
||||
try {
|
||||
Log-Info "Attempting to decode certificate as base64..."
|
||||
Log-Debug "Input string length: $($env:SM_CLIENT_CERT_FILE.Length) characters"
|
||||
|
||||
$tempCertPath = Join-Path $env:TEMP "digicert_cert_$(Get-Random).p12"
|
||||
|
||||
# Try to decode as base64
|
||||
$certBytes = [System.Convert]::FromBase64String($env:SM_CLIENT_CERT_FILE)
|
||||
[System.IO.File]::WriteAllBytes($tempCertPath, $certBytes)
|
||||
|
||||
# Validate the decoded certificate size
|
||||
$fileSize = (Get-Item $tempCertPath).Length
|
||||
if ($fileSize -lt 100) {
|
||||
throw "Decoded certificate too small: $fileSize bytes (expected >100 bytes)"
|
||||
}
|
||||
|
||||
# Update environment to point to file
|
||||
$env:SM_CLIENT_CERT_FILE = $tempCertPath
|
||||
|
||||
Log-Success "Certificate decoded and written to: $tempCertPath"
|
||||
Log-Debug "Decoded certificate file size: $fileSize bytes"
|
||||
|
||||
# Register cleanup
|
||||
$global:TEMP_CERT_PATH = $tempCertPath
|
||||
|
||||
} catch {
|
||||
# If base64 decode fails, check if it's a file path
|
||||
Log-Info "Base64 decode failed, checking if it's a file path..."
|
||||
Log-Debug "Decode error: $_"
|
||||
|
||||
if (Test-Path $env:SM_CLIENT_CERT_FILE) {
|
||||
$fileSize = (Get-Item $env:SM_CLIENT_CERT_FILE).Length
|
||||
|
||||
# Validate file size
|
||||
if ($fileSize -lt 100) {
|
||||
throw "Certificate file too small: $fileSize bytes at $env:SM_CLIENT_CERT_FILE (possibly corrupted)"
|
||||
}
|
||||
|
||||
Log-Info "Using certificate file: $env:SM_CLIENT_CERT_FILE"
|
||||
Log-Debug "Certificate file size: $fileSize bytes"
|
||||
} else {
|
||||
throw "SM_CLIENT_CERT_FILE is neither valid base64 nor an existing file: $env:SM_CLIENT_CERT_FILE"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Install DigiCert KeyLocker tools
|
||||
function Install-KeyLocker {
|
||||
Log-Info "Setting up DigiCert KeyLocker tools..."
|
||||
|
||||
# Define our controlled installation directory
|
||||
$installDir = "C:\BuildTools\DigiCert"
|
||||
$smctlPath = Join-Path $installDir "smctl.exe"
|
||||
|
||||
# Check if already installed in our controlled location
|
||||
if (Test-Path $smctlPath) {
|
||||
Log-Success "KeyLocker tools already installed at: $smctlPath"
|
||||
|
||||
# Add to PATH if not already there
|
||||
if ($env:PATH -notlike "*$installDir*") {
|
||||
$env:PATH = "$installDir;$env:PATH"
|
||||
Log-Info "Added to PATH: $installDir"
|
||||
}
|
||||
|
||||
return $smctlPath
|
||||
}
|
||||
|
||||
Log-Info "Installing KeyLocker tools to: $installDir"
|
||||
|
||||
# Create the installation directory if it doesn't exist
|
||||
if (!(Test-Path $installDir)) {
|
||||
Log-Info "Creating installation directory: $installDir"
|
||||
try {
|
||||
New-Item -ItemType Directory -Path $installDir -Force | Out-Null
|
||||
Log-Success "Created directory: $installDir"
|
||||
} catch {
|
||||
throw "Failed to create directory $installDir : $_"
|
||||
}
|
||||
}
|
||||
|
||||
# Download MSI installer
|
||||
# Note: KeyLocker tools currently only available for x64, but works on ARM64 via emulation
|
||||
$msiArch = "x64"
|
||||
$msiUrl = "https://bun-ci-assets.bun.sh/Keylockertools-windows-${msiArch}.msi"
|
||||
$msiPath = Join-Path $env:TEMP "Keylockertools-windows-${msiArch}.msi"
|
||||
|
||||
Log-Info "Downloading MSI from: $msiUrl"
|
||||
Log-Info "Downloading to: $msiPath"
|
||||
|
||||
try {
|
||||
# Remove existing MSI if present
|
||||
if (Test-Path $msiPath) {
|
||||
Remove-Item $msiPath -Force
|
||||
Log-Debug "Removed existing MSI file"
|
||||
}
|
||||
|
||||
# Download with progress tracking
|
||||
$webClient = New-Object System.Net.WebClient
|
||||
$webClient.DownloadFile($msiUrl, $msiPath)
|
||||
|
||||
if (!(Test-Path $msiPath)) {
|
||||
throw "MSI download failed - file not found"
|
||||
}
|
||||
|
||||
$fileSize = (Get-Item $msiPath).Length
|
||||
Log-Success "MSI downloaded successfully (size: $fileSize bytes)"
|
||||
|
||||
} catch {
|
||||
throw "Failed to download MSI: $_"
|
||||
}
|
||||
|
||||
# Install MSI
|
||||
Log-Info "Installing MSI..."
|
||||
Log-Debug "MSI path: $msiPath"
|
||||
Log-Debug "File exists: $(Test-Path $msiPath)"
|
||||
Log-Debug "File size: $((Get-Item $msiPath).Length) bytes"
|
||||
|
||||
# Check if running as administrator
|
||||
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
Log-Info "Running as administrator: $isAdmin"
|
||||
|
||||
# Install MSI silently to our controlled directory
|
||||
$arguments = @(
|
||||
"/i", "`"$msiPath`"",
|
||||
"/quiet",
|
||||
"/norestart",
|
||||
"TARGETDIR=`"$installDir`"",
|
||||
"INSTALLDIR=`"$installDir`"",
|
||||
"ACCEPT_EULA=1",
|
||||
"ADDLOCAL=ALL"
|
||||
)
|
||||
|
||||
Log-Debug "Running: msiexec.exe $($arguments -join ' ')"
|
||||
Log-Info "Installing to: $installDir"
|
||||
|
||||
$process = Start-Process -FilePath "msiexec.exe" -ArgumentList $arguments -Wait -PassThru -NoNewWindow
|
||||
|
||||
if ($process.ExitCode -ne 0) {
|
||||
Log-Error "MSI installation failed with exit code: $($process.ExitCode)"
|
||||
|
||||
# Try to get error details from event log
|
||||
try {
|
||||
$events = Get-WinEvent -LogName "Application" -MaxEvents 10 |
|
||||
Where-Object { $_.ProviderName -eq "MsiInstaller" -and $_.TimeCreated -gt (Get-Date).AddMinutes(-1) }
|
||||
|
||||
foreach ($event in $events) {
|
||||
Log-Debug "MSI Event: $($event.Message)"
|
||||
}
|
||||
} catch {
|
||||
Log-Debug "Could not retrieve MSI installation events"
|
||||
}
|
||||
|
||||
throw "MSI installation failed with exit code: $($process.ExitCode)"
|
||||
}
|
||||
|
||||
Log-Success "MSI installation completed"
|
||||
|
||||
# Wait for installation to complete
|
||||
Start-Sleep -Seconds 2
|
||||
|
||||
# Verify smctl.exe exists in our controlled location
|
||||
if (Test-Path $smctlPath) {
|
||||
Log-Success "KeyLocker tools installed successfully at: $smctlPath"
|
||||
|
||||
# Add to PATH
|
||||
$env:PATH = "$installDir;$env:PATH"
|
||||
Log-Info "Added to PATH: $installDir"
|
||||
|
||||
return $smctlPath
|
||||
}
|
||||
|
||||
# If not in our expected location, check if it installed somewhere in the directory
|
||||
$found = Get-ChildItem -Path $installDir -Filter "smctl.exe" -Recurse -ErrorAction SilentlyContinue |
|
||||
Select-Object -First 1
|
||||
|
||||
if ($found) {
|
||||
Log-Success "Found smctl.exe at: $($found.FullName)"
|
||||
$smctlDir = $found.DirectoryName
|
||||
$env:PATH = "$smctlDir;$env:PATH"
|
||||
return $found.FullName
|
||||
}
|
||||
|
||||
throw "KeyLocker tools installation succeeded but smctl.exe not found in $installDir"
|
||||
}
|
||||
|
||||
# Configure KeyLocker
|
||||
function Configure-KeyLocker {
|
||||
param([string]$SmctlPath)
|
||||
|
||||
Log-Info "Configuring KeyLocker..."
|
||||
|
||||
# Verify smctl is accessible
|
||||
try {
|
||||
$version = & $SmctlPath --version 2>&1
|
||||
Log-Debug "smctl version: $version"
|
||||
} catch {
|
||||
throw "Failed to run smctl: $_"
|
||||
}
|
||||
|
||||
# Configure KeyLocker credentials and environment
|
||||
Log-Info "Configuring KeyLocker credentials..."
|
||||
|
||||
try {
|
||||
# Save credentials (API key and password)
|
||||
Log-Info "Saving credentials to OS store..."
|
||||
$saveOutput = & $SmctlPath credentials save $env:SM_API_KEY $env:SM_CLIENT_CERT_PASSWORD 2>&1 | Out-String
|
||||
Log-Debug "Credentials save output: $saveOutput"
|
||||
|
||||
if ($saveOutput -like "*Credentials saved*") {
|
||||
Log-Success "Credentials saved successfully"
|
||||
}
|
||||
|
||||
# Set environment variables for smctl
|
||||
Log-Info "Setting KeyLocker environment variables..."
|
||||
$env:SM_HOST = $env:SM_HOST # Already set, but ensure it's available
|
||||
$env:SM_API_KEY = $env:SM_API_KEY # Already set
|
||||
$env:SM_CLIENT_CERT_FILE = $env:SM_CLIENT_CERT_FILE # Path to decoded cert file
|
||||
Log-Debug "SM_HOST: $env:SM_HOST"
|
||||
Log-Debug "SM_CLIENT_CERT_FILE: $env:SM_CLIENT_CERT_FILE"
|
||||
|
||||
# Run health check
|
||||
Log-Info "Running KeyLocker health check..."
|
||||
$healthOutput = & $SmctlPath healthcheck 2>&1 | Out-String
|
||||
Log-Debug "Health check output: $healthOutput"
|
||||
|
||||
if ($healthOutput -like "*Healthy*" -or $healthOutput -like "*SUCCESS*" -or $LASTEXITCODE -eq 0) {
|
||||
Log-Success "KeyLocker health check passed"
|
||||
} else {
|
||||
Log-Error "Health check failed: $healthOutput"
|
||||
# Don't throw here, sometimes healthcheck is flaky but signing still works
|
||||
}
|
||||
|
||||
# Sync certificates to Windows certificate store
|
||||
Log-Info "Syncing certificates to Windows store..."
|
||||
$syncOutput = & $SmctlPath windows certsync 2>&1 | Out-String
|
||||
Log-Debug "Certificate sync output: $syncOutput"
|
||||
|
||||
if ($syncOutput -like "*success*" -or $syncOutput -like "*synced*" -or $LASTEXITCODE -eq 0) {
|
||||
Log-Success "Certificates synced to Windows store"
|
||||
} else {
|
||||
Log-Info "Certificate sync output: $syncOutput"
|
||||
}
|
||||
|
||||
} catch {
|
||||
throw "Failed to configure KeyLocker: $_"
|
||||
}
|
||||
}
|
||||
|
||||
# Sign an executable
|
||||
function Sign-Executable {
|
||||
param(
|
||||
[string]$ExePath,
|
||||
[string]$SmctlPath
|
||||
)
|
||||
|
||||
if (!(Test-Path $ExePath)) {
|
||||
throw "Executable not found: $ExePath"
|
||||
}
|
||||
|
||||
$fileName = Split-Path $ExePath -Leaf
|
||||
Log-Info "Signing $fileName..."
|
||||
Log-Debug "Full path: $ExePath"
|
||||
Log-Debug "File size: $((Get-Item $ExePath).Length) bytes"
|
||||
|
||||
# Check if already signed
|
||||
$existingSig = Get-AuthenticodeSignature $ExePath
|
||||
if ($existingSig.Status -eq "Valid") {
|
||||
Log-Info "$fileName is already signed by: $($existingSig.SignerCertificate.Subject)"
|
||||
Log-Info "Skipping re-signing"
|
||||
return
|
||||
}
|
||||
|
||||
# Sign the executable using smctl
|
||||
try {
|
||||
# smctl sign command with keypair-alias
|
||||
$signArgs = @(
|
||||
"sign",
|
||||
"--keypair-alias", $env:SM_KEYPAIR_ALIAS,
|
||||
"--input", $ExePath,
|
||||
"--verbose"
|
||||
)
|
||||
|
||||
Log-Debug "Running: $SmctlPath $($signArgs -join ' ')"
|
||||
|
||||
$signOutput = & $SmctlPath $signArgs 2>&1 | Out-String
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Log-Error "Signing output: $signOutput"
|
||||
throw "Signing failed with exit code: $LASTEXITCODE"
|
||||
}
|
||||
|
||||
Log-Debug "Signing output: $signOutput"
|
||||
Log-Success "Signing command completed"
|
||||
|
||||
} catch {
|
||||
throw "Failed to sign $fileName : $_"
|
||||
}
|
||||
|
||||
# Verify signature
|
||||
$newSig = Get-AuthenticodeSignature $ExePath
|
||||
|
||||
if ($newSig.Status -eq "Valid") {
|
||||
Log-Success "$fileName signed successfully"
|
||||
Log-Info "Signed by: $($newSig.SignerCertificate.Subject)"
|
||||
Log-Info "Thumbprint: $($newSig.SignerCertificate.Thumbprint)"
|
||||
Log-Info "Valid from: $($newSig.SignerCertificate.NotBefore) to $($newSig.SignerCertificate.NotAfter)"
|
||||
} else {
|
||||
throw "$fileName signature verification failed: $($newSig.Status) - $($newSig.StatusMessage)"
|
||||
}
|
||||
}
|
||||
|
||||
# Cleanup function
|
||||
function Cleanup {
|
||||
if ($global:TEMP_CERT_PATH -and (Test-Path $global:TEMP_CERT_PATH)) {
|
||||
try {
|
||||
Remove-Item $global:TEMP_CERT_PATH -Force
|
||||
Log-Info "Cleaned up temporary certificate"
|
||||
} catch {
|
||||
Log-Error "Failed to cleanup temporary certificate: $_"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Main execution
|
||||
try {
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host " Windows Code Signing for Bun" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
|
||||
# Ensure we're in a VS environment
|
||||
Ensure-VSEnvironment
|
||||
|
||||
# Check environment variables
|
||||
Check-Environment
|
||||
|
||||
# Setup certificate
|
||||
Setup-Certificate
|
||||
|
||||
# Install and configure KeyLocker
|
||||
$smctlPath = Install-KeyLocker
|
||||
Configure-KeyLocker -SmctlPath $smctlPath
|
||||
|
||||
# Sign both executables
|
||||
Sign-Executable -ExePath $BunProfileExe -SmctlPath $smctlPath
|
||||
Sign-Executable -ExePath $BunExe -SmctlPath $smctlPath
|
||||
|
||||
Write-Host "========================================" -ForegroundColor Green
|
||||
Write-Host " Code signing completed successfully!" -ForegroundColor Green
|
||||
Write-Host "========================================" -ForegroundColor Green
|
||||
|
||||
exit 0
|
||||
|
||||
} catch {
|
||||
Log-Error "Code signing failed: $_"
|
||||
exit 1
|
||||
|
||||
} finally {
|
||||
Cleanup
|
||||
}
|
||||
@@ -121,14 +121,7 @@ function download_buildkite_artifact() {
|
||||
if [ -z "$dir" ]; then
|
||||
dir="."
|
||||
fi
|
||||
# When signing ran, Windows zips exist in two steps with the same name
|
||||
# (build-bun unsigned, windows-sign signed). Pin to the sign step to
|
||||
# guarantee we get the signed one.
|
||||
local step_args=()
|
||||
if [[ -n "$WINDOWS_ARTIFACT_STEP" && "$name" == bun-windows-* ]]; then
|
||||
step_args=(--step "$WINDOWS_ARTIFACT_STEP")
|
||||
fi
|
||||
run_command buildkite-agent artifact download "$name" "$dir" "${step_args[@]}"
|
||||
run_command buildkite-agent artifact download "$name" "$dir"
|
||||
if [ ! -f "$dir/$name" ]; then
|
||||
echo "error: Cannot find Buildkite artifact: $name"
|
||||
exit 1
|
||||
|
||||
@@ -58,6 +58,23 @@ else()
|
||||
message(FATAL_ERROR "Unsupported architecture: ${CMAKE_SYSTEM_PROCESSOR}")
|
||||
endif()
|
||||
|
||||
# Windows Code Signing Option
|
||||
if(WIN32)
|
||||
optionx(ENABLE_WINDOWS_CODESIGNING BOOL "Enable Windows code signing with DigiCert KeyLocker" DEFAULT OFF)
|
||||
|
||||
if(ENABLE_WINDOWS_CODESIGNING)
|
||||
message(STATUS "Windows code signing: ENABLED")
|
||||
|
||||
# Check for required environment variables
|
||||
if(NOT DEFINED ENV{SM_API_KEY})
|
||||
message(WARNING "SM_API_KEY not set - code signing may fail")
|
||||
endif()
|
||||
if(NOT DEFINED ENV{SM_CLIENT_CERT_FILE})
|
||||
message(WARNING "SM_CLIENT_CERT_FILE not set - code signing may fail")
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
|
||||
if(LINUX)
|
||||
if(EXISTS "/etc/alpine-release")
|
||||
set(DEFAULT_ABI "musl")
|
||||
|
||||
@@ -1409,8 +1409,47 @@ if(NOT BUN_CPP_ONLY)
|
||||
${BUILD_PATH}/${bunStripExe}
|
||||
)
|
||||
|
||||
# Windows code signing happens in a dedicated Buildkite step after all
|
||||
# Windows builds complete. See .buildkite/scripts/sign-windows-artifacts.ps1
|
||||
# Then sign both executables on Windows
|
||||
if(WIN32 AND ENABLE_WINDOWS_CODESIGNING)
|
||||
set(SIGN_SCRIPT "${CMAKE_SOURCE_DIR}/.buildkite/scripts/sign-windows.ps1")
|
||||
|
||||
# Verify signing script exists
|
||||
if(NOT EXISTS "${SIGN_SCRIPT}")
|
||||
message(FATAL_ERROR "Windows signing script not found: ${SIGN_SCRIPT}")
|
||||
endif()
|
||||
|
||||
# Use PowerShell for Windows code signing (native Windows, no path issues)
|
||||
find_program(POWERSHELL_EXECUTABLE
|
||||
NAMES pwsh.exe powershell.exe
|
||||
PATHS
|
||||
"C:/Program Files/PowerShell/7"
|
||||
"C:/Program Files (x86)/PowerShell/7"
|
||||
"C:/Windows/System32/WindowsPowerShell/v1.0"
|
||||
DOC "Path to PowerShell executable"
|
||||
)
|
||||
|
||||
if(NOT POWERSHELL_EXECUTABLE)
|
||||
set(POWERSHELL_EXECUTABLE "powershell.exe")
|
||||
endif()
|
||||
|
||||
message(STATUS "Using PowerShell executable: ${POWERSHELL_EXECUTABLE}")
|
||||
|
||||
# Sign both bun-profile.exe and bun.exe after stripping
|
||||
register_command(
|
||||
TARGET
|
||||
${bun}
|
||||
TARGET_PHASE
|
||||
POST_BUILD
|
||||
COMMENT
|
||||
"Code signing bun-profile.exe and bun.exe with DigiCert KeyLocker"
|
||||
COMMAND
|
||||
"${POWERSHELL_EXECUTABLE}" "-NoProfile" "-ExecutionPolicy" "Bypass" "-File" "${SIGN_SCRIPT}" "-BunProfileExe" "${BUILD_PATH}/${bunExe}" "-BunExe" "${BUILD_PATH}/${bunStripExe}"
|
||||
CWD
|
||||
${CMAKE_SOURCE_DIR}
|
||||
SOURCES
|
||||
${BUILD_PATH}/${bunStripExe}
|
||||
)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# somehow on some Linux systems we need to disable ASLR for ASAN-instrumented binaries to run
|
||||
|
||||
@@ -96,7 +96,7 @@ $2b$10$Lyj9kHYZtiyfxh2G60TEfeqs7xkkGiEFFDi3iJGc50ZG/XJ1sxIFi;
|
||||
The format is composed of:
|
||||
|
||||
- `bcrypt`: `$2b`
|
||||
- `rounds`: `$10` - rounds (log10 of the actual number of rounds)
|
||||
- `rounds`: `$10` - rounds (log2 of the actual number of rounds)
|
||||
- `salt`: `$Lyj9kHYZtiyfxh2G60TEfeqs7xkkGiEFFDi3iJGc50ZG/XJ1sxIFi`
|
||||
- `hash`: `$GzJ8PuBi+K+BVojzPfS5mjnC8OpLGtv8KJqF99eP6a4`
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { basename, join, relative, resolve } from "node:path";
|
||||
import {
|
||||
formatAnnotationToHtml,
|
||||
getEnv,
|
||||
getSecret,
|
||||
isCI,
|
||||
isWindows,
|
||||
parseAnnotations,
|
||||
@@ -164,6 +165,35 @@ async function spawn(command, args, options, label) {
|
||||
|
||||
const pipe = process.env.CI === "true";
|
||||
|
||||
if (isBuildkite()) {
|
||||
if (process.env.BUN_LINK_ONLY && isWindows) {
|
||||
env ||= options?.env || { ...process.env };
|
||||
|
||||
// Pass signing secrets directly to the build process
|
||||
// The PowerShell signing script will handle certificate decoding
|
||||
env.SM_CLIENT_CERT_PASSWORD = getSecret("SM_CLIENT_CERT_PASSWORD", {
|
||||
redact: true,
|
||||
required: true,
|
||||
});
|
||||
env.SM_CLIENT_CERT_FILE = getSecret("SM_CLIENT_CERT_FILE", {
|
||||
redact: true,
|
||||
required: true,
|
||||
});
|
||||
env.SM_API_KEY = getSecret("SM_API_KEY", {
|
||||
redact: true,
|
||||
required: true,
|
||||
});
|
||||
env.SM_KEYPAIR_ALIAS = getSecret("SM_KEYPAIR_ALIAS", {
|
||||
redact: true,
|
||||
required: true,
|
||||
});
|
||||
env.SM_HOST = getSecret("SM_HOST", {
|
||||
redact: true,
|
||||
required: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const subprocess = nodeSpawn(command, effectiveArgs, {
|
||||
stdio: pipe ? "pipe" : "inherit",
|
||||
...options,
|
||||
|
||||
@@ -767,14 +767,11 @@ pub fn NewWebSocketClient(comptime ssl: bool) type {
|
||||
) bool {
|
||||
// For tunnel mode, write through the tunnel instead of direct socket
|
||||
if (this.proxy_tunnel) |tunnel| {
|
||||
const wrote = tunnel.write(bytes) catch {
|
||||
// The tunnel handles TLS encryption and buffering
|
||||
_ = tunnel.write(bytes) catch {
|
||||
this.terminate(ErrorCode.failed_to_write);
|
||||
return false;
|
||||
};
|
||||
// Buffer any data the tunnel couldn't accept
|
||||
if (wrote < bytes.len) {
|
||||
_ = this.copyToSendBuffer(bytes[wrote..], false);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -859,11 +856,9 @@ pub fn NewWebSocketClient(comptime ssl: bool) type {
|
||||
|
||||
if (do_write) {
|
||||
if (comptime Environment.allow_assert) {
|
||||
if (this.proxy_tunnel == null) {
|
||||
bun.assert(!this.tcp.isShutdown());
|
||||
bun.assert(!this.tcp.isClosed());
|
||||
bun.assert(this.tcp.isEstablished());
|
||||
}
|
||||
bun.assert(!this.tcp.isShutdown());
|
||||
bun.assert(!this.tcp.isClosed());
|
||||
bun.assert(this.tcp.isEstablished());
|
||||
}
|
||||
return this.sendBuffer(this.send_buffer.readableSlice(0));
|
||||
}
|
||||
@@ -885,11 +880,9 @@ pub fn NewWebSocketClient(comptime ssl: bool) type {
|
||||
|
||||
if (do_write) {
|
||||
if (comptime Environment.allow_assert) {
|
||||
if (this.proxy_tunnel == null) {
|
||||
bun.assert(!this.tcp.isShutdown());
|
||||
bun.assert(!this.tcp.isClosed());
|
||||
bun.assert(this.tcp.isEstablished());
|
||||
}
|
||||
bun.assert(!this.tcp.isShutdown());
|
||||
bun.assert(!this.tcp.isClosed());
|
||||
bun.assert(this.tcp.isEstablished());
|
||||
}
|
||||
return this.sendBuffer(this.send_buffer.readableSlice(0));
|
||||
}
|
||||
@@ -902,29 +895,21 @@ pub fn NewWebSocketClient(comptime ssl: bool) type {
|
||||
out_buf: []const u8,
|
||||
) bool {
|
||||
bun.assert(out_buf.len > 0);
|
||||
// Do not use MSG_MORE, see https://github.com/oven-sh/bun/issues/4010
|
||||
const wrote: usize = if (this.proxy_tunnel) |tunnel|
|
||||
// In tunnel mode, route through the tunnel's TLS layer
|
||||
// instead of the detached raw socket.
|
||||
tunnel.write(out_buf) catch {
|
||||
this.terminate(ErrorCode.failed_to_write);
|
||||
return false;
|
||||
}
|
||||
else blk: {
|
||||
if (this.tcp.isClosed()) {
|
||||
return false;
|
||||
}
|
||||
const w = this.tcp.write(out_buf);
|
||||
if (w < 0) {
|
||||
this.terminate(ErrorCode.failed_to_write);
|
||||
return false;
|
||||
}
|
||||
break :blk @intCast(w);
|
||||
};
|
||||
// Do not set MSG_MORE, see https://github.com/oven-sh/bun/issues/4010
|
||||
if (this.tcp.isClosed()) {
|
||||
return false;
|
||||
}
|
||||
const wrote = this.tcp.write(out_buf);
|
||||
if (wrote < 0) {
|
||||
this.terminate(ErrorCode.failed_to_write);
|
||||
return false;
|
||||
}
|
||||
const expected = @as(usize, @intCast(wrote));
|
||||
const readable = this.send_buffer.readableSlice(0);
|
||||
if (readable.ptr == out_buf.ptr) {
|
||||
this.send_buffer.discard(wrote);
|
||||
this.send_buffer.discard(expected);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1038,9 +1023,7 @@ pub fn NewWebSocketClient(comptime ssl: bool) type {
|
||||
}
|
||||
|
||||
pub fn hasBackpressure(this: *const WebSocket) bool {
|
||||
if (this.send_buffer.count > 0) return true;
|
||||
if (this.proxy_tunnel) |tunnel| return tunnel.hasBackpressure();
|
||||
return false;
|
||||
return this.send_buffer.count > 0;
|
||||
}
|
||||
|
||||
pub fn writeBinaryData(
|
||||
@@ -1372,15 +1355,6 @@ pub fn NewWebSocketClient(comptime ssl: bool) type {
|
||||
this.handleData(this.tcp, data);
|
||||
}
|
||||
|
||||
/// Called by the WebSocketProxyTunnel when the underlying socket drains.
|
||||
/// Flushes any buffered plaintext data through the tunnel.
|
||||
pub fn handleTunnelWritable(this: *WebSocket) void {
|
||||
if (this.close_received) return;
|
||||
const send_buf = this.send_buffer.readableSlice(0);
|
||||
if (send_buf.len == 0) return;
|
||||
_ = this.sendBuffer(send_buf);
|
||||
}
|
||||
|
||||
pub fn finalize(this: *WebSocket) callconv(.c) void {
|
||||
log("finalize", .{});
|
||||
this.clearData();
|
||||
|
||||
@@ -297,22 +297,16 @@ pub fn onWritable(this: *WebSocketProxyTunnel) void {
|
||||
|
||||
// Send buffered encrypted data
|
||||
const to_send = this.#write_buffer.slice();
|
||||
if (to_send.len > 0) {
|
||||
const written = this.#socket.write(to_send);
|
||||
if (written < 0) return;
|
||||
if (to_send.len == 0) return;
|
||||
|
||||
const written_usize: usize = @intCast(written);
|
||||
if (written_usize == to_send.len) {
|
||||
this.#write_buffer.reset();
|
||||
} else {
|
||||
this.#write_buffer.cursor += written_usize;
|
||||
return; // still have backpressure
|
||||
}
|
||||
}
|
||||
const written = this.#socket.write(to_send);
|
||||
if (written < 0) return;
|
||||
|
||||
// Tunnel drained - let the connected WebSocket flush its send_buffer
|
||||
if (this.#connected_websocket) |ws| {
|
||||
ws.handleTunnelWritable();
|
||||
const written_usize: usize = @intCast(written);
|
||||
if (written_usize == to_send.len) {
|
||||
this.#write_buffer.reset();
|
||||
} else {
|
||||
this.#write_buffer.cursor += written_usize;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -698,22 +698,19 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type {
|
||||
return;
|
||||
};
|
||||
|
||||
// Take the WebSocket upgrade request from proxy state (transfers ownership).
|
||||
// Store it in input_body_buf so handleWritable can retry on drain.
|
||||
this.input_body_buf = p.takeWebsocketRequestBuf();
|
||||
if (this.input_body_buf.len == 0) {
|
||||
// Take the WebSocket upgrade request from proxy state (transfers ownership)
|
||||
const upgrade_request = p.takeWebsocketRequestBuf();
|
||||
if (upgrade_request.len == 0) {
|
||||
this.terminate(ErrorCode.failed_to_write);
|
||||
return;
|
||||
}
|
||||
|
||||
// Send through the tunnel (will be encrypted). Buffer any unwritten
|
||||
// portion in to_send so handleWritable retries when the socket drains.
|
||||
// Send through the tunnel (will be encrypted)
|
||||
if (p.getTunnel()) |tunnel| {
|
||||
const wrote = tunnel.write(this.input_body_buf) catch {
|
||||
_ = tunnel.write(upgrade_request) catch {
|
||||
this.terminate(ErrorCode.failed_to_write);
|
||||
return;
|
||||
};
|
||||
this.to_send = this.input_body_buf[wrote..];
|
||||
} else {
|
||||
this.terminate(ErrorCode.proxy_tunnel_failed);
|
||||
}
|
||||
@@ -1020,17 +1017,6 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type {
|
||||
tunnel.onWritable();
|
||||
// In .done state (after WebSocket upgrade), just handle tunnel writes
|
||||
if (this.state == .done) return;
|
||||
|
||||
// Flush any unwritten upgrade request bytes through the tunnel
|
||||
if (this.to_send.len == 0) return;
|
||||
this.ref();
|
||||
defer this.deref();
|
||||
const wrote = tunnel.write(this.to_send) catch {
|
||||
this.terminate(ErrorCode.failed_to_write);
|
||||
return;
|
||||
};
|
||||
this.to_send = this.to_send[@min(wrote, this.to_send.len)..];
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { tls as tlsCerts } from "harness";
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
|
||||
// Regression test: sendBuffer() was writing directly to this.tcp (which is
|
||||
// detached in proxy tunnel mode) instead of routing through the tunnel's TLS
|
||||
// layer. Under bidirectional traffic, backpressure pushes writes through the
|
||||
// sendBuffer slow path, corrupting the TLS stream and killing the connection
|
||||
// (close code 1006) within seconds.
|
||||
test("bidirectional ping/pong through TLS proxy", async () => {
|
||||
const intervals: ReturnType<typeof setInterval>[] = [];
|
||||
const clearIntervals = () => {
|
||||
for (const i of intervals) clearInterval(i);
|
||||
intervals.length = 0;
|
||||
};
|
||||
|
||||
using server = Bun.serve({
|
||||
port: 0,
|
||||
tls: { key: tlsCerts.key, cert: tlsCerts.cert },
|
||||
fetch(req, server) {
|
||||
if (server.upgrade(req)) return;
|
||||
return new Response("Expected WebSocket", { status: 400 });
|
||||
},
|
||||
websocket: {
|
||||
message(ws, msg) {
|
||||
ws.send("echo:" + msg);
|
||||
},
|
||||
open(ws) {
|
||||
// Server pings periodically (like session-ingress's 54s interval, sped up)
|
||||
intervals.push(
|
||||
setInterval(() => {
|
||||
if (ws.readyState === 1) ws.ping();
|
||||
}, 500),
|
||||
);
|
||||
// Server pushes data continuously
|
||||
intervals.push(
|
||||
setInterval(() => {
|
||||
if (ws.readyState === 1) ws.send("push:" + Date.now());
|
||||
}, 100),
|
||||
);
|
||||
},
|
||||
close() {
|
||||
clearIntervals();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// HTTP CONNECT proxy
|
||||
const proxy = http.createServer((req, res) => {
|
||||
res.writeHead(400);
|
||||
res.end();
|
||||
});
|
||||
proxy.on("connect", (req, clientSocket, head) => {
|
||||
const [host, port] = req.url!.split(":");
|
||||
const serverSocket = net.createConnection({ host: host!, port: parseInt(port!) }, () => {
|
||||
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
||||
serverSocket.pipe(clientSocket);
|
||||
clientSocket.pipe(serverSocket);
|
||||
if (head.length > 0) serverSocket.write(head);
|
||||
});
|
||||
serverSocket.on("error", () => clientSocket.destroy());
|
||||
clientSocket.on("error", () => serverSocket.destroy());
|
||||
});
|
||||
|
||||
const { promise: proxyReady, resolve: proxyReadyResolve } = Promise.withResolvers<void>();
|
||||
proxy.listen(0, "127.0.0.1", () => proxyReadyResolve());
|
||||
await proxyReady;
|
||||
const proxyPort = (proxy.address() as net.AddressInfo).port;
|
||||
|
||||
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
||||
|
||||
const ws = new WebSocket(`wss://localhost:${server.port}`, {
|
||||
proxy: `http://127.0.0.1:${proxyPort}`,
|
||||
tls: { rejectUnauthorized: false },
|
||||
} as any);
|
||||
|
||||
const REQUIRED_PONGS = 5;
|
||||
let pongReceived = true;
|
||||
let closeCode: number | undefined;
|
||||
|
||||
ws.addEventListener("open", () => {
|
||||
// Client sends pings (like Claude Code's 10s interval, sped up)
|
||||
intervals.push(
|
||||
setInterval(() => {
|
||||
if (!pongReceived) {
|
||||
reject(new Error("Pong timeout - connection dead"));
|
||||
return;
|
||||
}
|
||||
pongReceived = false;
|
||||
(ws as any).ping?.();
|
||||
}, 400),
|
||||
);
|
||||
// Client writes data continuously (bidirectional traffic triggers the bug)
|
||||
intervals.push(
|
||||
setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send("data:" + Date.now());
|
||||
}, 50),
|
||||
);
|
||||
});
|
||||
|
||||
// Resolve as soon as enough pongs arrive (condition-based, not timer-gated)
|
||||
let pongCount = 0;
|
||||
ws.addEventListener("pong", () => {
|
||||
pongCount++;
|
||||
pongReceived = true;
|
||||
if (pongCount >= REQUIRED_PONGS) resolve();
|
||||
});
|
||||
|
||||
ws.addEventListener("close", e => {
|
||||
closeCode = (e as CloseEvent).code;
|
||||
clearIntervals();
|
||||
if (pongCount < REQUIRED_PONGS) {
|
||||
reject(new Error(`Connection closed (${closeCode}) after only ${pongCount}/${REQUIRED_PONGS} pongs`));
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await promise;
|
||||
expect(pongCount).toBeGreaterThanOrEqual(REQUIRED_PONGS);
|
||||
ws.close();
|
||||
} finally {
|
||||
clearIntervals();
|
||||
proxy.close();
|
||||
}
|
||||
}, 10000);
|
||||
Reference in New Issue
Block a user