mirror of
https://github.com/oven-sh/bun
synced 2026-02-27 12:07:20 +01:00
Compare commits
11 Commits
claude/har
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e317c861f | ||
|
|
5c9172cf34 | ||
|
|
c099ad3fff | ||
|
|
6450e91aa4 | ||
|
|
57a36224f3 | ||
|
|
49937251ba | ||
|
|
06858cbef0 | ||
|
|
492e0f533c | ||
|
|
1524632cbb | ||
|
|
5b8b72522c | ||
|
|
9e3330a9ad |
@@ -472,12 +472,9 @@ function getBuildCommand(target, options, label) {
|
||||
const { profile } = target;
|
||||
const buildProfile = profile || "release";
|
||||
|
||||
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}`;
|
||||
}
|
||||
// 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.
|
||||
|
||||
return `bun run build:${buildProfile}`;
|
||||
}
|
||||
@@ -783,23 +780,73 @@ function getBuildImageStep(platform, options) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Platform[]} buildPlatforms
|
||||
* 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 getReleaseStep(buildPlatforms, options) {
|
||||
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 } = {}) {
|
||||
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: buildPlatforms.map(platform => `${getTargetKey(platform)}-build-bun`),
|
||||
depends_on,
|
||||
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",
|
||||
};
|
||||
@@ -905,6 +952,7 @@ 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]
|
||||
@@ -1180,6 +1228,7 @@ 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),
|
||||
@@ -1294,8 +1343,19 @@ 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));
|
||||
steps.push(getReleaseStep(buildPlatforms, options, { signed: shouldSignWindows }));
|
||||
}
|
||||
steps.push(getBenchmarkStep());
|
||||
|
||||
|
||||
281
.buildkite/scripts/sign-windows-artifacts.ps1
Normal file
281
.buildkite/scripts/sign-windows-artifacts.ps1
Normal file
@@ -0,0 +1,281 @@
|
||||
# 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
|
||||
}
|
||||
}
|
||||
@@ -1,470 +0,0 @@
|
||||
# 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,7 +121,14 @@ function download_buildkite_artifact() {
|
||||
if [ -z "$dir" ]; then
|
||||
dir="."
|
||||
fi
|
||||
run_command buildkite-agent artifact download "$name" "$dir"
|
||||
# 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[@]}"
|
||||
if [ ! -f "$dir/$name" ]; then
|
||||
echo "error: Cannot find Buildkite artifact: $name"
|
||||
exit 1
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"@swc/core": "^1.2.133",
|
||||
"benchmark": "^2.1.4",
|
||||
"braces": "^3.0.2",
|
||||
"cli-truncate": "^5.1.1",
|
||||
"color": "^4.2.3",
|
||||
"esbuild": "^0.14.12",
|
||||
"eventemitter3": "^5.0.0",
|
||||
@@ -25,6 +26,7 @@
|
||||
"react-markdown": "^9.0.3",
|
||||
"remark": "^15.0.1",
|
||||
"remark-html": "^16.0.1",
|
||||
"slice-ansi": "^8.0.0",
|
||||
"string-width": "7.1.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"tar": "^7.4.3",
|
||||
@@ -222,6 +224,8 @@
|
||||
|
||||
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
||||
|
||||
"cli-truncate": ["cli-truncate@5.1.1", "", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A=="],
|
||||
|
||||
"color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
|
||||
|
||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
@@ -394,6 +398,8 @@
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
|
||||
@@ -598,6 +604,8 @@
|
||||
|
||||
"slash": ["slash@4.0.0", "", {}, "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew=="],
|
||||
|
||||
"slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="],
|
||||
|
||||
"sonic-boom": ["sonic-boom@4.2.0", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww=="],
|
||||
|
||||
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
|
||||
@@ -684,10 +692,16 @@
|
||||
|
||||
"avvio/fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="],
|
||||
|
||||
"cli-truncate/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
|
||||
|
||||
"cli-truncate/string-width": ["string-width@8.2.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="],
|
||||
|
||||
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"fastify/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
|
||||
"is-fullwidth-code-point/get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="],
|
||||
|
||||
"light-my-request/process-warning": ["process-warning@4.0.1", "", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="],
|
||||
|
||||
"lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
@@ -698,8 +712,14 @@
|
||||
|
||||
"@babel/highlight/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
|
||||
|
||||
"cli-truncate/string-width/get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="],
|
||||
|
||||
"cli-truncate/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
|
||||
|
||||
"@babel/highlight/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
|
||||
|
||||
"cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
"@babel/highlight/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"@swc/core": "^1.2.133",
|
||||
"benchmark": "^2.1.4",
|
||||
"braces": "^3.0.2",
|
||||
"cli-truncate": "^5.1.1",
|
||||
"color": "^4.2.3",
|
||||
"esbuild": "^0.14.12",
|
||||
"eventemitter3": "^5.0.0",
|
||||
@@ -21,6 +22,7 @@
|
||||
"react-markdown": "^9.0.3",
|
||||
"remark": "^15.0.1",
|
||||
"remark-html": "^16.0.1",
|
||||
"slice-ansi": "^8.0.0",
|
||||
"string-width": "7.1.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"tar": "^7.4.3",
|
||||
|
||||
227
bench/snippets/slice-ansi.mjs
Normal file
227
bench/snippets/slice-ansi.mjs
Normal file
@@ -0,0 +1,227 @@
|
||||
// Compares Bun.sliceAnsi against npm slice-ansi and cli-truncate.
|
||||
// Bun.sliceAnsi replaces both packages with one function:
|
||||
// slice-ansi → Bun.sliceAnsi(s, start, end)
|
||||
// cli-truncate → Bun.sliceAnsi(s, 0, max, ellipsis) / Bun.sliceAnsi(s, -max, undefined, ellipsis)
|
||||
|
||||
import npmCliTruncate from "cli-truncate";
|
||||
import npmSliceAnsi from "slice-ansi";
|
||||
import { bench, run, summary } from "../runner.mjs";
|
||||
|
||||
// Under Node (or any runtime without Bun.sliceAnsi), we only run the npm side
|
||||
// of each pair — no point benching npm against itself. Under Bun with
|
||||
// FORCE_NPM=1, we still run both to measure the npm impl cost under JSC.
|
||||
const hasBunSliceAnsi = typeof Bun !== "undefined" && typeof Bun.sliceAnsi === "function";
|
||||
const useBun = hasBunSliceAnsi && !process.env.FORCE_NPM;
|
||||
|
||||
// `maybeBench` registers the Bun-side bench only when useBun is true, so under
|
||||
// Node each summary() collapses to a single npm entry with no false "1.0x" noise.
|
||||
const maybeBench = useBun ? bench : () => {};
|
||||
|
||||
if (hasBunSliceAnsi) {
|
||||
console.log(`[slice-ansi bench] ${useBun ? "Bun.sliceAnsi vs npm" : "npm-only (FORCE_NPM=1)"}\n`);
|
||||
} else {
|
||||
console.log(`[slice-ansi bench] Bun.sliceAnsi unavailable — running npm-only\n`);
|
||||
}
|
||||
|
||||
// Wrappers so the call site stays monomorphic:
|
||||
const bunSlice = useBun ? Bun.sliceAnsi : () => {};
|
||||
const bunTruncEnd = useBun ? (s, n, e) => Bun.sliceAnsi(s, 0, n, e) : () => {};
|
||||
const bunTruncStart = useBun ? (s, n, e) => Bun.sliceAnsi(s, -n, undefined, e) : () => {};
|
||||
|
||||
// ============================================================================
|
||||
// Fixtures — cover the tiers of Bun.sliceAnsi's dispatch:
|
||||
// 1. Pure ASCII → SIMD fast path (direct substring)
|
||||
// 2. ASCII + ANSI → single-pass streaming emit with bulk-ASCII runs
|
||||
// 3. CJK / emoji → per-char width, inline grapheme tracking
|
||||
// 4. ZWJ emoji / combining marks → clustering path
|
||||
// ============================================================================
|
||||
|
||||
const red = s => `\x1b[31m${s}\x1b[39m`;
|
||||
const green = s => `\x1b[32m${s}\x1b[39m`;
|
||||
const bold = s => `\x1b[1m${s}\x1b[22m`;
|
||||
const truecolor = (r, g, b, s) => `\x1b[38;2;${r};${g};${b}m${s}\x1b[39m`;
|
||||
const link = (url, s) => `\x1b]8;;${url}\x07${s}\x1b]8;;\x07`;
|
||||
|
||||
// Tier 1: pure ASCII (SIMD fast path)
|
||||
const asciiShort = "The quick brown fox jumps over the lazy dog.";
|
||||
const asciiLong = "The quick brown fox jumps over the lazy dog. ".repeat(100);
|
||||
|
||||
// Tier 2: ASCII + ANSI codes (streaming + bulk-ASCII emit)
|
||||
const ansiShort = `The ${red("quick")} ${green("brown")} fox ${bold("jumps")} over the lazy dog.`;
|
||||
const ansiMedium =
|
||||
`The ${red("quick brown fox")} jumps ${green("over the lazy dog")} and ${bold("runs away")}. `.repeat(10);
|
||||
const ansiLong = `The ${red("quick brown fox")} jumps ${green("over the lazy dog")} and ${bold("runs away")}. `.repeat(
|
||||
100,
|
||||
);
|
||||
// Dense ANSI: SGR between every few chars (stresses pending buffer)
|
||||
const ansiDense = `${red("ab")}${green("cd")}${bold("ef")}${truecolor(255, 128, 64, "gh")}`.repeat(50);
|
||||
|
||||
// Tier 3: CJK (width 2, no clustering)
|
||||
const cjk = "日本語のテキストをスライスするテストです。全角文字は幅2としてカウントされます。".repeat(10);
|
||||
const cjkAnsi = red("日本語のテキストを") + green("スライスするテスト") + "です。".repeat(10);
|
||||
|
||||
// Tier 4: grapheme clustering
|
||||
const emoji = "Hello 👋 World 🌍! Test 🧪 emoji 😀 slicing 📦!".repeat(10);
|
||||
// ZWJ family emoji — worst case for clustering (4 codepoints + 3 ZWJ per cluster)
|
||||
const zwj = "Family: 👨👩👧👦 and 👩💻 technologist! ".repeat(20);
|
||||
// Skin tone modifiers
|
||||
const skinTone = "Wave 👋🏽 handshake 🤝🏻 thumbs 👍🏿 ok 👌🏼!".repeat(20);
|
||||
// Combining marks (café → c-a-f-e + ́)
|
||||
const combining = "cafe\u0301 re\u0301sume\u0301 na\u0131\u0308ve pi\u00f1ata ".repeat(30);
|
||||
|
||||
// Hyperlinks (OSC 8)
|
||||
const hyperlinks = link("https://bun.sh", "Check out Bun, it's fast! ").repeat(20);
|
||||
|
||||
// ============================================================================
|
||||
// Slice benchmarks (vs slice-ansi)
|
||||
// ============================================================================
|
||||
|
||||
// Tier 1: pure ASCII — Bun's SIMD fast path should be near-memcpy.
|
||||
summary(() => {
|
||||
bench("ascii-short [0,20) — npm slice-ansi", () => npmSliceAnsi(asciiShort, 0, 20));
|
||||
maybeBench("ascii-short [0,20) — Bun.sliceAnsi ", () => bunSlice(asciiShort, 0, 20));
|
||||
});
|
||||
|
||||
summary(() => {
|
||||
bench("ascii-long [0,1000) — npm slice-ansi", () => npmSliceAnsi(asciiLong, 0, 1000));
|
||||
maybeBench("ascii-long [0,1000) — Bun.sliceAnsi ", () => bunSlice(asciiLong, 0, 1000));
|
||||
});
|
||||
|
||||
// Zero-copy case: slice covers whole string. Bun returns the input JSString.
|
||||
summary(() => {
|
||||
bench("ascii-long no-op (whole string) — npm slice-ansi", () => npmSliceAnsi(asciiLong, 0));
|
||||
maybeBench("ascii-long no-op (whole string) — Bun.sliceAnsi ", () => bunSlice(asciiLong, 0));
|
||||
});
|
||||
|
||||
// Tier 2: ANSI — Bun's bulk-ASCII-run emit vs npm's per-token walk.
|
||||
summary(() => {
|
||||
bench("ansi-short [0,30) — npm slice-ansi", () => npmSliceAnsi(ansiShort, 0, 30));
|
||||
maybeBench("ansi-short [0,30) — Bun.sliceAnsi ", () => bunSlice(ansiShort, 0, 30));
|
||||
});
|
||||
|
||||
summary(() => {
|
||||
bench("ansi-medium [10,200) — npm slice-ansi", () => npmSliceAnsi(ansiMedium, 10, 200));
|
||||
maybeBench("ansi-medium [10,200) — Bun.sliceAnsi ", () => bunSlice(ansiMedium, 10, 200));
|
||||
});
|
||||
|
||||
summary(() => {
|
||||
bench("ansi-long [0,2000) — npm slice-ansi", () => npmSliceAnsi(ansiLong, 0, 2000));
|
||||
maybeBench("ansi-long [0,2000) — Bun.sliceAnsi ", () => bunSlice(ansiLong, 0, 2000));
|
||||
});
|
||||
|
||||
summary(() => {
|
||||
bench("ansi-dense (SGR every 2 chars) — npm slice-ansi", () => npmSliceAnsi(ansiDense, 0, 100));
|
||||
maybeBench("ansi-dense (SGR every 2 chars) — Bun.sliceAnsi ", () => bunSlice(ansiDense, 0, 100));
|
||||
});
|
||||
|
||||
// Tier 3: CJK (width 2, no clustering)
|
||||
summary(() => {
|
||||
bench("cjk [0,100) — npm slice-ansi", () => npmSliceAnsi(cjk, 0, 100));
|
||||
maybeBench("cjk [0,100) — Bun.sliceAnsi ", () => bunSlice(cjk, 0, 100));
|
||||
});
|
||||
|
||||
summary(() => {
|
||||
bench("cjk+ansi [0,100) — npm slice-ansi", () => npmSliceAnsi(cjkAnsi, 0, 100));
|
||||
maybeBench("cjk+ansi [0,100) — Bun.sliceAnsi ", () => bunSlice(cjkAnsi, 0, 100));
|
||||
});
|
||||
|
||||
// Tier 4: grapheme clustering
|
||||
summary(() => {
|
||||
bench("emoji [0,100) — npm slice-ansi", () => npmSliceAnsi(emoji, 0, 100));
|
||||
maybeBench("emoji [0,100) — Bun.sliceAnsi ", () => bunSlice(emoji, 0, 100));
|
||||
});
|
||||
|
||||
summary(() => {
|
||||
bench("zwj-family [0,100) — npm slice-ansi", () => npmSliceAnsi(zwj, 0, 100));
|
||||
maybeBench("zwj-family [0,100) — Bun.sliceAnsi ", () => bunSlice(zwj, 0, 100));
|
||||
});
|
||||
|
||||
summary(() => {
|
||||
bench("skin-tone [0,100) — npm slice-ansi", () => npmSliceAnsi(skinTone, 0, 100));
|
||||
maybeBench("skin-tone [0,100) — Bun.sliceAnsi ", () => bunSlice(skinTone, 0, 100));
|
||||
});
|
||||
|
||||
summary(() => {
|
||||
bench("combining-marks [0,100) — npm slice-ansi", () => npmSliceAnsi(combining, 0, 100));
|
||||
maybeBench("combining-marks [0,100) — Bun.sliceAnsi ", () => bunSlice(combining, 0, 100));
|
||||
});
|
||||
|
||||
// OSC 8 hyperlinks
|
||||
summary(() => {
|
||||
bench("hyperlinks [0,100) — npm slice-ansi", () => npmSliceAnsi(hyperlinks, 0, 100));
|
||||
maybeBench("hyperlinks [0,100) — Bun.sliceAnsi ", () => bunSlice(hyperlinks, 0, 100));
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Truncate benchmarks (vs cli-truncate)
|
||||
// ============================================================================
|
||||
|
||||
// cli-truncate internally calls slice-ansi, so Bun should win by a similar
|
||||
// margin. The interesting comparison is the lazy-cutEnd speculative zone vs
|
||||
// cli-truncate's eager stringWidth pre-pass.
|
||||
|
||||
summary(() => {
|
||||
bench("truncate-end ascii-short — npm cli-truncate", () => npmCliTruncate(asciiShort, 20));
|
||||
maybeBench("truncate-end ascii-short — Bun.sliceAnsi ", () => bunTruncEnd(asciiShort, 20, "…"));
|
||||
});
|
||||
|
||||
summary(() => {
|
||||
bench("truncate-end ansi-long — npm cli-truncate", () => npmCliTruncate(ansiLong, 200));
|
||||
maybeBench("truncate-end ansi-long — Bun.sliceAnsi ", () => bunTruncEnd(ansiLong, 200, "…"));
|
||||
});
|
||||
|
||||
summary(() => {
|
||||
bench("truncate-start ansi-long — npm cli-truncate", () => npmCliTruncate(ansiLong, 200, { position: "start" }));
|
||||
// Negative index → Bun's 2-pass path (computeTotalWidth pre-pass).
|
||||
maybeBench("truncate-start ansi-long — Bun.sliceAnsi ", () => bunTruncStart(ansiLong, 200, "…"));
|
||||
});
|
||||
|
||||
summary(() => {
|
||||
bench("truncate-end emoji — npm cli-truncate", () => npmCliTruncate(emoji, 50));
|
||||
maybeBench("truncate-end emoji — Bun.sliceAnsi ", () => bunTruncEnd(emoji, 50, "…"));
|
||||
});
|
||||
|
||||
// No-cut case: string already fits. cli-truncate calls stringWidth + early returns.
|
||||
// Bun's lazy cutEnd detection means it walks once but detects no cut at EOF.
|
||||
summary(() => {
|
||||
bench("truncate no-cut (fits) — npm cli-truncate", () => npmCliTruncate(asciiShort, 100));
|
||||
maybeBench("truncate no-cut (fits) — Bun.sliceAnsi ", () => bunTruncEnd(asciiShort, 100, "…"));
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Real-world: ink-style viewport clipping (hot path for terminal UI rendering)
|
||||
// ============================================================================
|
||||
|
||||
// Simulates ink's output.ts sliceAnsi(line, from, to) call in the render loop.
|
||||
// Each line is colored and gets clipped to the viewport width.
|
||||
const logLine = `${bold("[2024-01-15 12:34:56]")} ${red("ERROR")} Connection to ${link("https://api.example.com", "api.example.com")} timed out after 30s (attempt 3/5)`;
|
||||
|
||||
summary(() => {
|
||||
bench("ink-clip (80-col viewport) — npm slice-ansi", () => npmSliceAnsi(logLine, 0, 80));
|
||||
maybeBench("ink-clip (80-col viewport) — Bun.sliceAnsi ", () => bunSlice(logLine, 0, 80));
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Correctness spot-check (fail fast if results diverge on simple cases)
|
||||
// ============================================================================
|
||||
|
||||
if (useBun) {
|
||||
const checks = [
|
||||
[asciiShort, 0, 20],
|
||||
[ansiShort, 5, 30],
|
||||
[cjk, 0, 50],
|
||||
];
|
||||
for (const [s, a, b] of checks) {
|
||||
// slice-ansi and Bun.sliceAnsi may differ in exact ANSI byte ordering for
|
||||
// close codes, but stripped visible content should match.
|
||||
const npm = npmSliceAnsi(s, a, b).replace(/\x1b\[[\d;]*m/g, "");
|
||||
const bun = bunSlice(s, a, b).replace(/\x1b\[[\d;]*m/g, "");
|
||||
if (npm !== bun) {
|
||||
throw new Error(
|
||||
`Correctness check failed for [${a},${b}): npm=${JSON.stringify(npm)} bun=${JSON.stringify(bun)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await run();
|
||||
@@ -58,23 +58,6 @@ 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,47 +1409,8 @@ if(NOT BUN_CPP_ONLY)
|
||||
${BUILD_PATH}/${bunStripExe}
|
||||
)
|
||||
|
||||
# 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()
|
||||
# Windows code signing happens in a dedicated Buildkite step after all
|
||||
# Windows builds complete. See .buildkite/scripts/sign-windows-artifacts.ps1
|
||||
endif()
|
||||
|
||||
# somehow on some Linux systems we need to disable ASLR for ASAN-instrumented binaries to run
|
||||
|
||||
@@ -228,16 +228,16 @@ To build for macOS x64:
|
||||
|
||||
The order of the `--target` flag does not matter, as long as they're delimited by a `-`.
|
||||
|
||||
| --target | Operating System | Architecture | Modern | Baseline | Libc |
|
||||
| --------------------- | ---------------- | ------------ | ------ | -------- | ----- |
|
||||
| bun-linux-x64 | Linux | x64 | ✅ | ✅ | glibc |
|
||||
| bun-linux-arm64 | Linux | arm64 | ✅ | N/A | glibc |
|
||||
| bun-windows-x64 | Windows | x64 | ✅ | ✅ | - |
|
||||
| bun-windows-arm64 | Windows | arm64 | ✅ | N/A | - |
|
||||
| bun-darwin-x64 | macOS | x64 | ✅ | ✅ | - |
|
||||
| bun-darwin-arm64 | macOS | arm64 | ✅ | N/A | - |
|
||||
| bun-linux-x64-musl | Linux | x64 | ✅ | ✅ | musl |
|
||||
| bun-linux-arm64-musl | Linux | arm64 | ✅ | N/A | musl |
|
||||
| --target | Operating System | Architecture | Modern | Baseline | Libc |
|
||||
| -------------------- | ---------------- | ------------ | ------ | -------- | ----- |
|
||||
| bun-linux-x64 | Linux | x64 | ✅ | ✅ | glibc |
|
||||
| bun-linux-arm64 | Linux | arm64 | ✅ | N/A | glibc |
|
||||
| bun-windows-x64 | Windows | x64 | ✅ | ✅ | - |
|
||||
| bun-windows-arm64 | Windows | arm64 | ✅ | N/A | - |
|
||||
| bun-darwin-x64 | macOS | x64 | ✅ | ✅ | - |
|
||||
| bun-darwin-arm64 | macOS | arm64 | ✅ | N/A | - |
|
||||
| bun-linux-x64-musl | Linux | x64 | ✅ | ✅ | musl |
|
||||
| bun-linux-arm64-musl | Linux | arm64 | ✅ | N/A | musl |
|
||||
|
||||
<Warning>
|
||||
On x64 platforms, Bun uses SIMD optimizations which require a modern CPU supporting AVX2 instructions. The `-baseline`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": true,
|
||||
"name": "bun",
|
||||
"version": "1.3.10",
|
||||
"version": "1.3.11",
|
||||
"workspaces": [
|
||||
"./packages/bun-types",
|
||||
"./packages/@types/bun"
|
||||
|
||||
77
packages/bun-types/bun.d.ts
vendored
77
packages/bun-types/bun.d.ts
vendored
@@ -610,6 +610,83 @@ declare module "bun" {
|
||||
*/
|
||||
function stripANSI(input: string): string;
|
||||
|
||||
interface SliceAnsiOptions {
|
||||
/**
|
||||
* If set, and content was cut at either edge of the requested range,
|
||||
* insert this string at the cut edge(s). The ellipsis is counted against
|
||||
* the visible-width budget and is emitted *inside* any active SGR styles
|
||||
* (color, bold, etc.) so it inherits them, but *outside* any active OSC 8
|
||||
* hyperlink.
|
||||
*
|
||||
* This turns `sliceAnsi` into a drop-in `cli-truncate` replacement:
|
||||
* - truncate-end: `sliceAnsi(str, 0, max, { ellipsis: "\u2026" })`
|
||||
* - truncate-start: `sliceAnsi(str, -max, undefined, { ellipsis: "\u2026" })`
|
||||
*/
|
||||
ellipsis?: string;
|
||||
|
||||
/**
|
||||
* Count characters with East Asian Width "Ambiguous" as 1 column (narrow)
|
||||
* instead of 2 (wide). Affects Greek, Cyrillic, some symbols, etc. that
|
||||
* render wide in CJK-encoded terminals but narrow in Western ones.
|
||||
*
|
||||
* Matches the option of the same name in {@link stringWidth} and
|
||||
* {@link wrapAnsi}.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
ambiguousIsNarrow?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slice a string by visible column width, preserving ANSI escape codes.
|
||||
*
|
||||
* Like `String.prototype.slice`, but indices are terminal column widths
|
||||
* (accounting for wide CJK characters, emoji grapheme clusters, and
|
||||
* zero-width joiners), and ANSI escape sequences (SGR colors, OSC 8
|
||||
* hyperlinks, etc.) are preserved and correctly re-opened/closed at the
|
||||
* slice boundaries.
|
||||
*
|
||||
* @category Utilities
|
||||
*
|
||||
* @param input The string to slice
|
||||
* @param start Starting column (default 0). Negative counts from end.
|
||||
* @param end Ending column, exclusive (default end of string). Negative counts from end.
|
||||
* @param options Optional behavior flags (e.g. `ellipsis` for truncation)
|
||||
* @returns The sliced string with ANSI codes intact
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { sliceAnsi } from "bun";
|
||||
*
|
||||
* // Plain slice (replaces the `slice-ansi` npm package)
|
||||
* sliceAnsi("hello", 1, 4); // "ell"
|
||||
* sliceAnsi("\u001b[31mhello\u001b[39m", 1, 4); // "\u001b[31mell\u001b[39m"
|
||||
* sliceAnsi("\u5b89\u5b81\u54c8", 0, 4); // "\u5b89\u5b81" (CJK: width 2 each)
|
||||
*
|
||||
* // Truncation (replaces the `cli-truncate` npm package)
|
||||
* sliceAnsi("unicorn", 0, 4, "\u2026"); // "uni\u2026"
|
||||
* sliceAnsi("unicorn", -4, undefined, "\u2026"); // "\u2026orn"
|
||||
* ```
|
||||
*/
|
||||
function sliceAnsi(
|
||||
input: string,
|
||||
start?: number,
|
||||
end?: number,
|
||||
/**
|
||||
* Shorthand for common options (avoids `{}` allocation):
|
||||
* - `string` → ellipsis (equivalent to `{ ellipsis: string }`)
|
||||
* - `boolean` → ambiguousIsNarrow (equivalent to `{ ambiguousIsNarrow: boolean }`)
|
||||
* - `SliceAnsiOptions` → full options object
|
||||
*/
|
||||
options?: string | boolean | SliceAnsiOptions,
|
||||
/**
|
||||
* ambiguousIsNarrow as a positional arg, usable when the 4th arg is an
|
||||
* ellipsis string (or `undefined`). Lets you pass both options without
|
||||
* an object: `sliceAnsi(s, 0, n, "\u2026", false)`.
|
||||
*/
|
||||
ambiguousIsNarrow?: boolean,
|
||||
): string;
|
||||
|
||||
interface WrapAnsiOptions {
|
||||
/**
|
||||
* If `true`, break words in the middle if they don't fit on a line.
|
||||
|
||||
@@ -54,15 +54,19 @@ namespace uWS {
|
||||
while (data.length() && data[0] > 32 && data[0] != ';') {
|
||||
|
||||
unsigned char digit = (unsigned char)data[0];
|
||||
if (digit >= 'a') {
|
||||
digit = (unsigned char) (digit - ('a' - ':'));
|
||||
} else if (digit >= 'A') {
|
||||
digit = (unsigned char) (digit - ('A' - ':'));
|
||||
unsigned int number;
|
||||
if (digit >= '0' && digit <= '9') {
|
||||
number = digit - '0';
|
||||
} else if (digit >= 'a' && digit <= 'f') {
|
||||
number = digit - 'a' + 10;
|
||||
} else if (digit >= 'A' && digit <= 'F') {
|
||||
number = digit - 'A' + 10;
|
||||
} else {
|
||||
state = STATE_IS_ERROR;
|
||||
return;
|
||||
}
|
||||
|
||||
unsigned int number = ((unsigned int) digit - (unsigned int) '0');
|
||||
|
||||
if (number > 16 || (chunkSize(state) & STATE_SIZE_OVERFLOW)) {
|
||||
if ((chunkSize(state) & STATE_SIZE_OVERFLOW)) {
|
||||
state = STATE_IS_ERROR;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -721,7 +721,8 @@ namespace uWS
|
||||
|
||||
/* Check for empty headers (no headers, just \r\n) */
|
||||
if (postPaddedBuffer[0] == '\r' && postPaddedBuffer[1] == '\n') {
|
||||
/* Valid request with no headers */
|
||||
/* Valid request with no headers - write null terminator like the normal path */
|
||||
headers[1].key = std::string_view(nullptr, 0);
|
||||
return HttpParserResult::success((unsigned int) ((postPaddedBuffer + 2) - start));
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { basename, join, relative, resolve } from "node:path";
|
||||
import {
|
||||
formatAnnotationToHtml,
|
||||
getEnv,
|
||||
getSecret,
|
||||
isCI,
|
||||
isWindows,
|
||||
parseAnnotations,
|
||||
@@ -165,35 +164,6 @@ 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,
|
||||
|
||||
@@ -17,14 +17,15 @@ Conventions:
|
||||
|
||||
| Instead of | Use |
|
||||
| ------------------------------------------------------------ | ------------------------------------ |
|
||||
| `std.fs.File` | `bun.sys.File` |
|
||||
| `std.base64` | `bun.base64` |
|
||||
| `std.crypto.sha{...}` | `bun.sha.Hashers.{...}` |
|
||||
| `std.fs.cwd()` | `bun.FD.cwd()` |
|
||||
| `std.posix.open/read/write/stat/mkdir/unlink/rename/symlink` | `bun.sys.*` equivalents |
|
||||
| `std.fs.File` | `bun.sys.File` |
|
||||
| `std.fs.path.join/dirname/basename` | `bun.path.join/dirname/basename` |
|
||||
| `std.mem.eql/indexOf/startsWith` (for strings) | `bun.strings.eql/indexOf/startsWith` |
|
||||
| `std.posix.O` / `std.posix.mode_t` / `std.posix.fd_t` | `bun.O` / `bun.Mode` / `bun.FD` |
|
||||
| `std.posix.open/read/write/stat/mkdir/unlink/rename/symlink` | `bun.sys.*` equivalents |
|
||||
| `std.process.Child` | `bun.spawnSync` |
|
||||
| `catch bun.outOfMemory()` | `bun.handleOom(...)` |
|
||||
|
||||
## `bun.sys` — System Calls (`src/sys.zig`)
|
||||
|
||||
|
||||
@@ -1684,11 +1684,20 @@ fn _resolve(
|
||||
const buster_name = name: {
|
||||
if (std.fs.path.isAbsolute(normalized_specifier)) {
|
||||
if (std.fs.path.dirname(normalized_specifier)) |dir| {
|
||||
if (dir.len > specifier_cache_resolver_buf.len) {
|
||||
return error.ModuleNotFound;
|
||||
}
|
||||
// Normalized without trailing slash
|
||||
break :name bun.strings.normalizeSlashesOnly(&specifier_cache_resolver_buf, dir, std.fs.path.sep);
|
||||
}
|
||||
}
|
||||
|
||||
// If the specifier is too long to join, it can't name a real
|
||||
// directory — skip the cache bust and fail.
|
||||
if (source_to_use.len + normalized_specifier.len + 4 >= specifier_cache_resolver_buf.len) {
|
||||
return error.ModuleNotFound;
|
||||
}
|
||||
|
||||
var parts = [_]string{
|
||||
source_to_use,
|
||||
normalized_specifier,
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
#include "root.h"
|
||||
#include <wtf/SIMDHelpers.h>
|
||||
#include <span>
|
||||
#include <unicode/utf16.h>
|
||||
|
||||
namespace Bun {
|
||||
namespace ANSI {
|
||||
@@ -26,14 +28,16 @@ static inline bool isEscapeCharacter(Char c)
|
||||
|
||||
// SIMD comparison against exact escape character values. Used to refine
|
||||
// the broad range match (0x10-0x1F / 0x90-0x9F) to only actual escape
|
||||
// introducers: 0x1B, 0x90, 0x98, 0x9B, 0x9D, 0x9E, 0x9F.
|
||||
// introducers: 0x1B, 0x90, 0x98, 0x9B, 0x9D, 0x9E, 0x9F. Also includes 0x9C
|
||||
// (C1 ST — a terminator, not an introducer) so callers tokenizing ANSI by
|
||||
// skipping to the next interesting byte will stop at standalone ST too.
|
||||
template<typename SIMDType>
|
||||
static auto exactEscapeMatch(std::conditional_t<sizeof(SIMDType) == 1, simde_uint8x16_t, simde_uint16x8_t> chunk)
|
||||
{
|
||||
if constexpr (sizeof(SIMDType) == 1)
|
||||
return SIMD::equal<0x1b, 0x90, 0x98, 0x9b, 0x9d, 0x9e, 0x9f>(chunk);
|
||||
return SIMD::equal<0x1b, 0x90, 0x98, 0x9b, 0x9c, 0x9d, 0x9e, 0x9f>(chunk);
|
||||
else
|
||||
return SIMD::equal<u'\x1b', u'\x90', u'\x98', u'\x9b', u'\x9d', u'\x9e', u'\x9f'>(chunk);
|
||||
return SIMD::equal<u'\x1b', u'\x90', u'\x98', u'\x9b', u'\x9c', u'\x9d', u'\x9e', u'\x9f'>(chunk);
|
||||
}
|
||||
|
||||
// Find the first escape character in a string using SIMD
|
||||
@@ -64,9 +68,9 @@ static const Char* findEscapeCharacter(const Char* start, const Char* end)
|
||||
}
|
||||
}
|
||||
|
||||
// Check remaining characters
|
||||
// Check remaining characters (include 0x9c to match SIMD behavior)
|
||||
for (; it != end; ++it) {
|
||||
if (isEscapeCharacter(*it))
|
||||
if (isEscapeCharacter(*it) || *it == 0x9c)
|
||||
return it;
|
||||
}
|
||||
return nullptr;
|
||||
@@ -203,5 +207,145 @@ static const Char* consumeANSI(const Char* start, const Char* end)
|
||||
return end;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// UTF-16 surrogate pair decoding — thin wrapper over ICU's U16_NEXT
|
||||
// ============================================================================
|
||||
static inline char32_t decodeUTF16(const UChar* p, size_t available, size_t& outLen)
|
||||
{
|
||||
size_t i = 0;
|
||||
char32_t cp;
|
||||
U16_NEXT(p, i, available, cp);
|
||||
outLen = i;
|
||||
return cp;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SIMD: index of first code unit NOT in [0x20, 0x7E] (or span.size() if none)
|
||||
// ============================================================================
|
||||
// Range check via wrapping subtract + unsigned compare:
|
||||
// c in [0x20, 0x7E] <=> (c - 0x20) <= 0x5E unsigned
|
||||
// Any lane with (c - 0x20) > 0x5E is out of range.
|
||||
//
|
||||
// Returns an index rather than a bool so callers can:
|
||||
// 1. Take a fast path if the whole string qualifies (index == size)
|
||||
// 2. Take a fast path if the requested operation lies inside the prefix
|
||||
// 3. Fast-forward past the proven-ASCII prefix without re-checking each byte
|
||||
//
|
||||
// Lane = uint8_t for Latin-1, uint16_t for UTF-16.
|
||||
template<typename Lane>
|
||||
static size_t firstNonAsciiPrintable(std::span<const Lane> input)
|
||||
{
|
||||
static_assert(sizeof(Lane) == 1 || sizeof(Lane) == 2);
|
||||
constexpr size_t stride = SIMD::stride<Lane>;
|
||||
const auto v20 = SIMD::splat<Lane>(static_cast<Lane>(0x20));
|
||||
const auto v5E = SIMD::splat<Lane>(static_cast<Lane>(0x5E));
|
||||
const Lane* const data = input.data();
|
||||
const Lane* const end = data + input.size();
|
||||
const Lane* it = data;
|
||||
for (; static_cast<size_t>(end - it) >= stride; it += stride) {
|
||||
auto chunk = SIMD::load(it);
|
||||
auto shifted = SIMD::sub(chunk, v20);
|
||||
auto oob = SIMD::greaterThan(shifted, v5E);
|
||||
if (auto idx = SIMD::findFirstNonZeroIndex(oob))
|
||||
return static_cast<size_t>(it - data) + *idx;
|
||||
}
|
||||
for (; it != end; ++it) {
|
||||
Lane c = *it;
|
||||
if (static_cast<Lane>(c - 0x20) > 0x5E)
|
||||
return static_cast<size_t>(it - data);
|
||||
}
|
||||
return input.size();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SGR (Select Graphic Rendition) open → close code mapping
|
||||
// ============================================================================
|
||||
// Shared by sliceAnsi and wrapAnsi for ANSI style tracking across boundaries.
|
||||
// Returns the SGR reset code for a given open code, or 0 if unknown.
|
||||
static inline uint32_t sgrCloseCode(uint32_t openCode)
|
||||
{
|
||||
// Densely-packed case ranges — LLVM lowers this to a jump table.
|
||||
switch (openCode) {
|
||||
case 1:
|
||||
case 2:
|
||||
return 22; // bold, dim
|
||||
case 3:
|
||||
return 23; // italic
|
||||
case 4:
|
||||
return 24; // underline
|
||||
case 5:
|
||||
case 6:
|
||||
return 25; // blink
|
||||
case 7:
|
||||
return 27; // inverse
|
||||
case 8:
|
||||
return 28; // hidden
|
||||
case 9:
|
||||
return 29; // strikethrough
|
||||
// Foreground colors (basic + extended + bright)
|
||||
case 30:
|
||||
case 31:
|
||||
case 32:
|
||||
case 33:
|
||||
case 34:
|
||||
case 35:
|
||||
case 36:
|
||||
case 37:
|
||||
case 38: // 256/truecolor foreground introducer
|
||||
case 90:
|
||||
case 91:
|
||||
case 92:
|
||||
case 93:
|
||||
case 94:
|
||||
case 95:
|
||||
case 96:
|
||||
case 97:
|
||||
return 39;
|
||||
// Background colors (basic + extended + bright)
|
||||
case 40:
|
||||
case 41:
|
||||
case 42:
|
||||
case 43:
|
||||
case 44:
|
||||
case 45:
|
||||
case 46:
|
||||
case 47:
|
||||
case 48: // 256/truecolor background introducer
|
||||
case 100:
|
||||
case 101:
|
||||
case 102:
|
||||
case 103:
|
||||
case 104:
|
||||
case 105:
|
||||
case 106:
|
||||
case 107:
|
||||
return 49;
|
||||
case 53:
|
||||
return 55; // overline
|
||||
default:
|
||||
return 0; // Unknown → caller uses full reset
|
||||
}
|
||||
}
|
||||
|
||||
static inline bool isSgrEndCode(uint32_t code)
|
||||
{
|
||||
switch (code) {
|
||||
case 0:
|
||||
case 22:
|
||||
case 23:
|
||||
case 24:
|
||||
case 25:
|
||||
case 27:
|
||||
case 28:
|
||||
case 29:
|
||||
case 39:
|
||||
case 49:
|
||||
case 55:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace ANSI
|
||||
} // namespace Bun
|
||||
|
||||
@@ -77,6 +77,8 @@ BUN_DECLARE_HOST_FUNCTION(Bun__fetchPreconnect);
|
||||
BUN_DECLARE_HOST_FUNCTION(Bun__randomUUIDv7);
|
||||
BUN_DECLARE_HOST_FUNCTION(Bun__randomUUIDv5);
|
||||
|
||||
#include "sliceAnsi.h"
|
||||
|
||||
namespace Bun {
|
||||
JSC_DECLARE_HOST_FUNCTION(jsFunctionBunStripANSI);
|
||||
JSC_DECLARE_HOST_FUNCTION(jsFunctionBunWrapAnsi);
|
||||
@@ -1014,6 +1016,7 @@ JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObj
|
||||
serve BunObject_callback_serve DontDelete|Function 1
|
||||
sha BunObject_callback_sha DontDelete|Function 1
|
||||
shrink BunObject_callback_shrink DontDelete|Function 1
|
||||
sliceAnsi jsFunctionBunSliceAnsi DontDelete|Function 5
|
||||
sleep functionBunSleep DontDelete|Function 1
|
||||
sleepSync BunObject_callback_sleepSync DontDelete|Function 1
|
||||
spawn BunObject_callback_spawn DontDelete|Function 1
|
||||
|
||||
@@ -1092,21 +1092,23 @@ static JSC::EncodedJSValue jsBufferPrototypeFunction_compareBody(JSC::JSGlobalOb
|
||||
break;
|
||||
}
|
||||
|
||||
if (targetStart > targetEndInit && targetStart <= targetEnd) {
|
||||
return Bun::ERR::OUT_OF_RANGE(throwScope, lexicalGlobalObject, "targetStart"_s, 0, targetEndInit, targetStartValue);
|
||||
}
|
||||
if (targetEnd > targetEndInit && targetEnd >= targetStart) {
|
||||
// Validate end values against their respective buffer lengths to prevent OOB access.
|
||||
// This matches Node.js behavior where targetEnd is validated against target.length
|
||||
// and sourceEnd is validated against source.length.
|
||||
if (targetEnd > targetEndInit) {
|
||||
return Bun::ERR::OUT_OF_RANGE(throwScope, lexicalGlobalObject, "targetEnd"_s, 0, targetEndInit, targetEndValue);
|
||||
}
|
||||
if (sourceStart > sourceEndInit && sourceStart <= sourceEnd) {
|
||||
return Bun::ERR::OUT_OF_RANGE(throwScope, lexicalGlobalObject, "sourceStart"_s, 0, sourceEndInit, sourceStartValue);
|
||||
}
|
||||
if (sourceEnd > sourceEndInit && sourceEnd >= sourceStart) {
|
||||
if (sourceEnd > sourceEndInit) {
|
||||
return Bun::ERR::OUT_OF_RANGE(throwScope, lexicalGlobalObject, "sourceEnd"_s, 0, sourceEndInit, sourceEndValue);
|
||||
}
|
||||
|
||||
targetStart = std::min(targetStart, std::min(targetEnd, targetEndInit));
|
||||
sourceStart = std::min(sourceStart, std::min(sourceEnd, sourceEndInit));
|
||||
// When start >= end for either side, return early per Node.js semantics.
|
||||
// This must be checked before validating start against buffer length, because
|
||||
// Node.js allows start > buffer.length when it forms a zero-length range.
|
||||
if (sourceStart >= sourceEnd)
|
||||
RELEASE_AND_RETURN(throwScope, JSC::JSValue::encode(JSC::jsNumber(targetStart >= targetEnd ? 0 : -1)));
|
||||
if (targetStart >= targetEnd)
|
||||
RELEASE_AND_RETURN(throwScope, JSC::JSValue::encode(JSC::jsNumber(1)));
|
||||
|
||||
auto sourceLength = sourceEnd - sourceStart;
|
||||
auto targetLength = targetEnd - targetStart;
|
||||
|
||||
1434
src/bun.js/bindings/sliceAnsi.cpp
Normal file
1434
src/bun.js/bindings/sliceAnsi.cpp
Normal file
File diff suppressed because it is too large
Load Diff
9
src/bun.js/bindings/sliceAnsi.h
Normal file
9
src/bun.js/bindings/sliceAnsi.h
Normal file
@@ -0,0 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
#include "root.h"
|
||||
|
||||
namespace Bun {
|
||||
|
||||
JSC_DECLARE_HOST_FUNCTION(jsFunctionBunSliceAnsi);
|
||||
|
||||
}
|
||||
@@ -15,25 +15,12 @@ extern "C" uint8_t Bun__codepointWidth(uint32_t cp, bool ambiguous_as_wide);
|
||||
namespace Bun {
|
||||
using namespace WTF;
|
||||
|
||||
// ============================================================================
|
||||
// UTF-16 Decoding Utilities (needed for hard wrap with surrogate pairs)
|
||||
// ============================================================================
|
||||
|
||||
static char32_t decodeUTF16(const UChar* ptr, size_t available, size_t& outLen)
|
||||
// UTF-16 decoding and codepoint width are in ANSIHelpers.h (shared with
|
||||
// sliceAnsi.cpp). The local wrapper here just delegates to keep existing
|
||||
// call sites unchanged.
|
||||
static inline char32_t decodeUTF16(const UChar* ptr, size_t available, size_t& outLen)
|
||||
{
|
||||
UChar c = ptr[0];
|
||||
|
||||
// Check for surrogate pair
|
||||
if (c >= 0xD800 && c <= 0xDBFF && available >= 2) {
|
||||
UChar c2 = ptr[1];
|
||||
if (c2 >= 0xDC00 && c2 <= 0xDFFF) {
|
||||
outLen = 2;
|
||||
return 0x10000 + (((c - 0xD800) << 10) | (c2 - 0xDC00));
|
||||
}
|
||||
}
|
||||
|
||||
outLen = 1;
|
||||
return static_cast<char32_t>(c);
|
||||
return ANSI::decodeUTF16(ptr, available, outLen);
|
||||
}
|
||||
|
||||
static inline uint8_t getVisibleWidth(char32_t cp, bool ambiguousIsWide)
|
||||
|
||||
@@ -496,6 +496,13 @@ pub const FileSystem = struct {
|
||||
return path_handler.joinAbsStringBuf(f.top_level_dir, buf, parts, .loose);
|
||||
}
|
||||
|
||||
/// Like `absBuf`, but returns null when the joined path (after `..`/`.`
|
||||
/// normalization) would overflow `buf`. Use when `parts` may contain
|
||||
/// user-controlled input of arbitrary length.
|
||||
pub fn absBufChecked(f: *@This(), parts: []const string, buf: []u8) ?string {
|
||||
return path_handler.joinAbsStringBufChecked(f.top_level_dir, buf, parts, .loose);
|
||||
}
|
||||
|
||||
pub fn absBufZ(f: *@This(), parts: anytype, buf: []u8) stringZ {
|
||||
return path_handler.joinAbsStringBufZ(f.top_level_dir, buf, parts, .loose);
|
||||
}
|
||||
|
||||
50
src/http.zig
50
src/http.zig
@@ -22,7 +22,8 @@ var print_every_i: usize = 0;
|
||||
|
||||
// we always rewrite the entire HTTP request when write() returns EAGAIN
|
||||
// so we can reuse this buffer
|
||||
var shared_request_headers_buf: [256]picohttp.Header = undefined;
|
||||
const max_request_headers = 256;
|
||||
var shared_request_headers_buf: [max_request_headers]picohttp.Header = undefined;
|
||||
|
||||
// this doesn't need to be stack memory because it is immediately cloned after use
|
||||
var shared_response_headers_buf: [256]picohttp.Header = undefined;
|
||||
@@ -633,26 +634,40 @@ pub fn buildRequest(this: *HTTPClient, body_len: usize) picohttp.Request {
|
||||
var add_transfer_encoding = true;
|
||||
var original_content_length: ?string = null;
|
||||
|
||||
// Reserve slots for default headers that may be appended after user headers
|
||||
// (Connection, User-Agent, Accept, Host, Accept-Encoding, Content-Length/Transfer-Encoding).
|
||||
const max_default_headers = 6;
|
||||
const max_user_headers = max_request_headers - max_default_headers;
|
||||
|
||||
for (header_names, 0..) |head, i| {
|
||||
const name = this.headerStr(head);
|
||||
// Hash it as lowercase
|
||||
const hash = hashHeaderName(name);
|
||||
|
||||
// Whether this header will actually be written to the buffer.
|
||||
// Override flags must only be set when the header is kept, otherwise
|
||||
// the default header is suppressed but the user header is dropped,
|
||||
// leaving the header entirely absent from the request.
|
||||
const will_append = header_count < max_user_headers;
|
||||
|
||||
// Skip host and connection header
|
||||
// we manage those
|
||||
switch (hash) {
|
||||
hashHeaderConst("Content-Length"),
|
||||
=> {
|
||||
// Content-Length is always consumed (never written to the buffer).
|
||||
original_content_length = this.headerStr(header_values[i]);
|
||||
continue;
|
||||
},
|
||||
hashHeaderConst("Connection") => {
|
||||
override_connection_header = true;
|
||||
const connection_value = this.headerStr(header_values[i]);
|
||||
if (std.ascii.eqlIgnoreCase(connection_value, "close")) {
|
||||
this.flags.disable_keepalive = true;
|
||||
} else if (std.ascii.eqlIgnoreCase(connection_value, "keep-alive")) {
|
||||
this.flags.disable_keepalive = false;
|
||||
if (will_append) {
|
||||
override_connection_header = true;
|
||||
const connection_value = this.headerStr(header_values[i]);
|
||||
if (std.ascii.eqlIgnoreCase(connection_value, "close")) {
|
||||
this.flags.disable_keepalive = true;
|
||||
} else if (std.ascii.eqlIgnoreCase(connection_value, "keep-alive")) {
|
||||
this.flags.disable_keepalive = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
hashHeaderConst("if-modified-since") => {
|
||||
@@ -661,30 +676,35 @@ pub fn buildRequest(this: *HTTPClient, body_len: usize) picohttp.Request {
|
||||
}
|
||||
},
|
||||
hashHeaderConst(host_header_name) => {
|
||||
override_host_header = true;
|
||||
if (will_append) override_host_header = true;
|
||||
},
|
||||
hashHeaderConst("Accept") => {
|
||||
override_accept_header = true;
|
||||
if (will_append) override_accept_header = true;
|
||||
},
|
||||
hashHeaderConst("User-Agent") => {
|
||||
override_user_agent = true;
|
||||
if (will_append) override_user_agent = true;
|
||||
},
|
||||
hashHeaderConst("Accept-Encoding") => {
|
||||
override_accept_encoding = true;
|
||||
if (will_append) override_accept_encoding = true;
|
||||
},
|
||||
hashHeaderConst("Upgrade") => {
|
||||
const value = this.headerStr(header_values[i]);
|
||||
if (!std.ascii.eqlIgnoreCase(value, "h2") and !std.ascii.eqlIgnoreCase(value, "h2c")) {
|
||||
this.flags.upgrade_state = .pending;
|
||||
if (will_append) {
|
||||
const value = this.headerStr(header_values[i]);
|
||||
if (!std.ascii.eqlIgnoreCase(value, "h2") and !std.ascii.eqlIgnoreCase(value, "h2c")) {
|
||||
this.flags.upgrade_state = .pending;
|
||||
}
|
||||
}
|
||||
},
|
||||
hashHeaderConst(chunked_encoded_header.name) => {
|
||||
// We don't want to override chunked encoding header if it was set by the user
|
||||
add_transfer_encoding = false;
|
||||
if (will_append) add_transfer_encoding = false;
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
|
||||
// Silently drop excess headers to stay within the fixed-size request header buffer.
|
||||
if (!will_append) continue;
|
||||
|
||||
request_headers_buf[header_count] = .{
|
||||
.name = name,
|
||||
.value = this.headerStr(header_values[i]),
|
||||
|
||||
@@ -767,11 +767,14 @@ pub fn NewWebSocketClient(comptime ssl: bool) type {
|
||||
) bool {
|
||||
// For tunnel mode, write through the tunnel instead of direct socket
|
||||
if (this.proxy_tunnel) |tunnel| {
|
||||
// The tunnel handles TLS encryption and buffering
|
||||
_ = tunnel.write(bytes) catch {
|
||||
const wrote = 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;
|
||||
}
|
||||
|
||||
@@ -856,9 +859,11 @@ pub fn NewWebSocketClient(comptime ssl: bool) type {
|
||||
|
||||
if (do_write) {
|
||||
if (comptime Environment.allow_assert) {
|
||||
bun.assert(!this.tcp.isShutdown());
|
||||
bun.assert(!this.tcp.isClosed());
|
||||
bun.assert(this.tcp.isEstablished());
|
||||
if (this.proxy_tunnel == null) {
|
||||
bun.assert(!this.tcp.isShutdown());
|
||||
bun.assert(!this.tcp.isClosed());
|
||||
bun.assert(this.tcp.isEstablished());
|
||||
}
|
||||
}
|
||||
return this.sendBuffer(this.send_buffer.readableSlice(0));
|
||||
}
|
||||
@@ -880,9 +885,11 @@ pub fn NewWebSocketClient(comptime ssl: bool) type {
|
||||
|
||||
if (do_write) {
|
||||
if (comptime Environment.allow_assert) {
|
||||
bun.assert(!this.tcp.isShutdown());
|
||||
bun.assert(!this.tcp.isClosed());
|
||||
bun.assert(this.tcp.isEstablished());
|
||||
if (this.proxy_tunnel == null) {
|
||||
bun.assert(!this.tcp.isShutdown());
|
||||
bun.assert(!this.tcp.isClosed());
|
||||
bun.assert(this.tcp.isEstablished());
|
||||
}
|
||||
}
|
||||
return this.sendBuffer(this.send_buffer.readableSlice(0));
|
||||
}
|
||||
@@ -895,21 +902,29 @@ pub fn NewWebSocketClient(comptime ssl: bool) type {
|
||||
out_buf: []const u8,
|
||||
) bool {
|
||||
bun.assert(out_buf.len > 0);
|
||||
// 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));
|
||||
// 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);
|
||||
};
|
||||
const readable = this.send_buffer.readableSlice(0);
|
||||
if (readable.ptr == out_buf.ptr) {
|
||||
this.send_buffer.discard(expected);
|
||||
this.send_buffer.discard(wrote);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1023,7 +1038,9 @@ pub fn NewWebSocketClient(comptime ssl: bool) type {
|
||||
}
|
||||
|
||||
pub fn hasBackpressure(this: *const WebSocket) bool {
|
||||
return this.send_buffer.count > 0;
|
||||
if (this.send_buffer.count > 0) return true;
|
||||
if (this.proxy_tunnel) |tunnel| return tunnel.hasBackpressure();
|
||||
return false;
|
||||
}
|
||||
|
||||
pub fn writeBinaryData(
|
||||
@@ -1355,6 +1372,15 @@ 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,16 +297,22 @@ pub fn onWritable(this: *WebSocketProxyTunnel) void {
|
||||
|
||||
// Send buffered encrypted data
|
||||
const to_send = this.#write_buffer.slice();
|
||||
if (to_send.len == 0) return;
|
||||
if (to_send.len > 0) {
|
||||
const written = this.#socket.write(to_send);
|
||||
if (written < 0) return;
|
||||
|
||||
const written = this.#socket.write(to_send);
|
||||
if (written < 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_usize: usize = @intCast(written);
|
||||
if (written_usize == to_send.len) {
|
||||
this.#write_buffer.reset();
|
||||
} else {
|
||||
this.#write_buffer.cursor += written_usize;
|
||||
// Tunnel drained - let the connected WebSocket flush its send_buffer
|
||||
if (this.#connected_websocket) |ws| {
|
||||
ws.handleTunnelWritable();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -698,19 +698,22 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type {
|
||||
return;
|
||||
};
|
||||
|
||||
// Take the WebSocket upgrade request from proxy state (transfers ownership)
|
||||
const upgrade_request = p.takeWebsocketRequestBuf();
|
||||
if (upgrade_request.len == 0) {
|
||||
// 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) {
|
||||
this.terminate(ErrorCode.failed_to_write);
|
||||
return;
|
||||
}
|
||||
|
||||
// Send through the tunnel (will be encrypted)
|
||||
// Send through the tunnel (will be encrypted). Buffer any unwritten
|
||||
// portion in to_send so handleWritable retries when the socket drains.
|
||||
if (p.getTunnel()) |tunnel| {
|
||||
_ = tunnel.write(upgrade_request) catch {
|
||||
const wrote = tunnel.write(this.input_body_buf) catch {
|
||||
this.terminate(ErrorCode.failed_to_write);
|
||||
return;
|
||||
};
|
||||
this.to_send = this.input_body_buf[wrote..];
|
||||
} else {
|
||||
this.terminate(ErrorCode.proxy_tunnel_failed);
|
||||
}
|
||||
@@ -1017,6 +1020,17 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1476,6 +1476,10 @@ pub const ESModule = struct {
|
||||
}
|
||||
|
||||
pub fn parseSubpath(subpath: *[]const u8, specifier: string, subpath_buf: []u8) void {
|
||||
if (specifier.len + 1 > subpath_buf.len) {
|
||||
subpath.* = "";
|
||||
return;
|
||||
}
|
||||
subpath_buf[0] = '.';
|
||||
bun.copy(u8, subpath_buf[1..], specifier);
|
||||
subpath.* = subpath_buf[0 .. specifier.len + 1];
|
||||
|
||||
@@ -1302,10 +1302,59 @@ pub fn joinStringBufT(comptime T: type, buf: []T, parts: anytype, comptime platf
|
||||
return normalizeStringNodeT(T, temp_buf[0..written], buf, platform);
|
||||
}
|
||||
|
||||
/// Inline `MAX_PATH_BYTES * 2` stack buffer that heap-allocates when the
|
||||
/// requested size exceeds it. Keeps `_joinAbsStringBuf`'s scratch buffer safe
|
||||
/// for arbitrarily long inputs while preserving zero-alloc behaviour for the
|
||||
/// common case.
|
||||
const JoinScratch = struct {
|
||||
sfa: std.heap.StackFallbackAllocator(bun.MAX_PATH_BYTES * 2),
|
||||
alloc: std.mem.Allocator,
|
||||
buf: []u8,
|
||||
|
||||
pub fn init(self: *JoinScratch, base: usize, parts: []const []const u8) []u8 {
|
||||
self.sfa = std.heap.stackFallback(bun.MAX_PATH_BYTES * 2, bun.default_allocator);
|
||||
self.alloc = self.sfa.get();
|
||||
var total = base + 2;
|
||||
for (parts) |p| total += p.len + 1;
|
||||
self.buf = bun.handleOom(self.alloc.alloc(u8, total));
|
||||
return self.buf;
|
||||
}
|
||||
|
||||
pub fn deinit(self: *JoinScratch) void {
|
||||
self.alloc.free(self.buf);
|
||||
}
|
||||
};
|
||||
|
||||
pub fn joinAbsStringBuf(cwd: []const u8, buf: []u8, _parts: anytype, comptime platform: Platform) []const u8 {
|
||||
return _joinAbsStringBuf(false, []const u8, cwd, buf, _parts, platform);
|
||||
}
|
||||
|
||||
/// Like `joinAbsStringBuf`, but returns null when the *normalized* result is
|
||||
/// too large for `buf`. Use this when `parts` may contain user-controlled
|
||||
/// input of arbitrary length. `..` segments are handled correctly: a path
|
||||
/// whose unnormalized length exceeds `buf.len` but normalizes down will still
|
||||
/// succeed.
|
||||
pub fn joinAbsStringBufChecked(cwd: []const u8, buf: []u8, parts: []const []const u8, comptime platform: Platform) ?[]const u8 {
|
||||
comptime if (platform == .nt) @compileError("joinAbsStringBufChecked does not support .nt (the \\\\?\\ prefix is not accounted for in scratch sizing)");
|
||||
// Fast path: size check only — don't allocate a JoinScratch here since the
|
||||
// inner joinAbsStringBuf already has its own (avoids doubling stack usage).
|
||||
var total: usize = cwd.len + 2;
|
||||
for (parts) |p| total += p.len + 1;
|
||||
if (total < buf.len) return joinAbsStringBuf(cwd, buf, parts, platform);
|
||||
|
||||
// Slow path: allocate a large scratch for the result. The inner
|
||||
// joinAbsStringBuf will heap-allocate its own temp buffer for the concat
|
||||
// since `total > MAX_PATH_BYTES * 2 > sfa inline size` is likely here.
|
||||
var sfa = std.heap.stackFallback(bun.MAX_PATH_BYTES, bun.default_allocator);
|
||||
const alloc = sfa.get();
|
||||
const scratch = bun.handleOom(alloc.alloc(u8, total));
|
||||
defer alloc.free(scratch);
|
||||
const joined = joinAbsStringBuf(cwd, scratch, parts, platform);
|
||||
if (joined.len > buf.len) return null;
|
||||
bun.copy(u8, buf, joined);
|
||||
return buf[0..joined.len];
|
||||
}
|
||||
|
||||
pub fn joinAbsStringBufZ(cwd: []const u8, buf: []u8, _parts: anytype, comptime platform: Platform) [:0]const u8 {
|
||||
return _joinAbsStringBuf(true, [:0]const u8, cwd, buf, _parts, platform);
|
||||
}
|
||||
@@ -1347,7 +1396,6 @@ fn _joinAbsStringBuf(comptime is_sentinel: bool, comptime ReturnType: type, _cwd
|
||||
}
|
||||
|
||||
var parts: []const []const u8 = _parts;
|
||||
var temp_buf: [bun.MAX_PATH_BYTES * 2]u8 = undefined;
|
||||
if (parts.len == 0) {
|
||||
if (comptime is_sentinel) {
|
||||
unreachable;
|
||||
@@ -1386,7 +1434,11 @@ fn _joinAbsStringBuf(comptime is_sentinel: bool, comptime ReturnType: type, _cwd
|
||||
}
|
||||
}
|
||||
|
||||
bun.copy(u8, &temp_buf, cwd);
|
||||
var scratch: JoinScratch = undefined;
|
||||
const temp_buf = scratch.init(cwd.len, parts);
|
||||
defer scratch.deinit();
|
||||
|
||||
bun.copy(u8, temp_buf, cwd);
|
||||
out = cwd.len;
|
||||
|
||||
for (parts) |_part| {
|
||||
@@ -1502,7 +1554,9 @@ fn _joinAbsStringBufWindows(
|
||||
if (set_cwd.len > 0)
|
||||
assert(isSepAny(set_cwd[0]));
|
||||
|
||||
var temp_buf: [bun.MAX_PATH_BYTES * 2]u8 = undefined;
|
||||
var scratch: JoinScratch = undefined;
|
||||
const temp_buf = scratch.init(root.len + set_cwd.len, parts[n_start..]);
|
||||
defer scratch.deinit();
|
||||
|
||||
@memcpy(temp_buf[0..root.len], root);
|
||||
@memcpy(temp_buf[root.len .. root.len + set_cwd.len], set_cwd);
|
||||
|
||||
@@ -1111,7 +1111,7 @@ pub const Resolver = struct {
|
||||
import_path[import_path.len - 2] == '.' and
|
||||
import_path[import_path.len - 1] == '.');
|
||||
const buf = bufs(.relative_abs_path);
|
||||
import_path = r.fs.absBuf(&.{import_path}, buf);
|
||||
import_path = r.fs.absBufChecked(&.{import_path}, buf) orelse return .{ .not_found = {} };
|
||||
if (ends_with_dir) {
|
||||
buf[import_path.len] = platform.separator();
|
||||
import_path.len += 1;
|
||||
@@ -1309,8 +1309,7 @@ pub const Resolver = struct {
|
||||
}
|
||||
|
||||
pub fn checkRelativePath(r: *ThisResolver, source_dir: string, import_path: string, kind: ast.ImportKind, global_cache: GlobalCache) Result.Union {
|
||||
const parts = [_]string{ source_dir, import_path };
|
||||
const abs_path = r.fs.absBuf(&parts, bufs(.relative_abs_path));
|
||||
const abs_path = r.fs.absBufChecked(&.{ source_dir, import_path }, bufs(.relative_abs_path)) orelse return .{ .not_found = {} };
|
||||
|
||||
if (r.opts.external.abs_paths.count() > 0 and r.opts.external.abs_paths.contains(abs_path)) {
|
||||
// If the string literal in the source text is an absolute path and has
|
||||
@@ -1725,13 +1724,11 @@ pub const Resolver = struct {
|
||||
// Try looking up the path relative to the base URL
|
||||
if (tsconfig.hasBaseURL()) {
|
||||
const base = tsconfig.base_url;
|
||||
const paths = [_]string{ base, import_path };
|
||||
const abs = r.fs.absBuf(&paths, bufs(.load_as_file_or_directory_via_tsconfig_base_path));
|
||||
|
||||
if (r.loadAsFileOrDirectory(abs, kind)) |res| {
|
||||
return .{ .success = res };
|
||||
if (r.fs.absBufChecked(&.{ base, import_path }, bufs(.load_as_file_or_directory_via_tsconfig_base_path))) |abs| {
|
||||
if (r.loadAsFileOrDirectory(abs, kind)) |res| {
|
||||
return .{ .success = res };
|
||||
}
|
||||
}
|
||||
// r.allocator.free(abs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1774,14 +1771,12 @@ pub const Resolver = struct {
|
||||
while (use_node_module_resolver) {
|
||||
// Skip directories that are themselves called "node_modules", since we
|
||||
// don't ever want to search for "node_modules/node_modules"
|
||||
if (dir_info.hasNodeModules() or is_self_reference) {
|
||||
if (dir_info.hasNodeModules() or is_self_reference) node_modules: {
|
||||
any_node_modules_folder = true;
|
||||
const abs_path = if (is_self_reference)
|
||||
dir_info.abs_path
|
||||
else brk: {
|
||||
var _parts = [_]string{ dir_info.abs_path, "node_modules", import_path };
|
||||
break :brk r.fs.absBuf(&_parts, bufs(.node_modules_check));
|
||||
};
|
||||
else
|
||||
r.fs.absBufChecked(&.{ dir_info.abs_path, "node_modules", import_path }, bufs(.node_modules_check)) orelse break :node_modules;
|
||||
if (r.debug_logs) |*debug| {
|
||||
debug.addNoteFmt("Checking for a package in the directory \"{s}\"", .{abs_path});
|
||||
}
|
||||
@@ -1896,7 +1891,7 @@ pub const Resolver = struct {
|
||||
if (node_path.len > 0) {
|
||||
var it = std.mem.tokenizeScalar(u8, node_path, if (Environment.isWindows) ';' else ':');
|
||||
while (it.next()) |path| {
|
||||
const abs_path = r.fs.absBuf(&[_]string{ path, import_path }, bufs(.node_modules_check));
|
||||
const abs_path = r.fs.absBufChecked(&.{ path, import_path }, bufs(.node_modules_check)) orelse continue;
|
||||
if (r.debug_logs) |*debug| {
|
||||
debug.addNoteFmt("Checking for a package in the NODE_PATH directory \"{s}\"", .{abs_path});
|
||||
}
|
||||
@@ -2160,8 +2155,7 @@ pub const Resolver = struct {
|
||||
}
|
||||
}
|
||||
|
||||
var _paths = [_]string{ pkg_dir_info.abs_path, esm.subpath };
|
||||
const abs_path = r.fs.absBuf(&_paths, bufs(.node_modules_check));
|
||||
const abs_path = r.fs.absBufChecked(&.{ pkg_dir_info.abs_path, esm.subpath }, bufs(.node_modules_check)) orelse return .{ .not_found = {} };
|
||||
if (r.debug_logs) |*debug| {
|
||||
debug.addNoteFmt("Checking for a package in the directory \"{s}\"", .{abs_path});
|
||||
}
|
||||
@@ -2398,12 +2392,12 @@ pub const Resolver = struct {
|
||||
esm_resolution.path.len > 0 and esm_resolution.path[0] == std.fs.path.sep))
|
||||
return null;
|
||||
|
||||
const abs_esm_path: string = brk: {
|
||||
var parts = [_]string{
|
||||
abs_package_path,
|
||||
strings.withoutLeadingPathSeparator(esm_resolution.path),
|
||||
};
|
||||
break :brk r.fs.absBuf(&parts, bufs(.esm_absolute_package_path_joined));
|
||||
const abs_esm_path: string = r.fs.absBufChecked(
|
||||
&.{ abs_package_path, strings.withoutLeadingPathSeparator(esm_resolution.path) },
|
||||
bufs(.esm_absolute_package_path_joined),
|
||||
) orelse {
|
||||
esm_resolution.status = .ModuleNotFound;
|
||||
return null;
|
||||
};
|
||||
|
||||
var missing_suffix: string = "";
|
||||
@@ -2528,8 +2522,7 @@ pub const Resolver = struct {
|
||||
if (isPackagePath(import_path)) {
|
||||
return r.loadNodeModules(import_path, kind, source_dir_info, global_cache, false);
|
||||
} else {
|
||||
const paths = [_]string{ source_dir_info.abs_path, import_path };
|
||||
const resolved = r.fs.absBuf(&paths, bufs(.resolve_without_remapping));
|
||||
const resolved = r.fs.absBufChecked(&.{ source_dir_info.abs_path, import_path }, bufs(.resolve_without_remapping)) orelse return .{ .not_found = {} };
|
||||
if (r.loadAsFileOrDirectory(resolved, kind)) |result| {
|
||||
return .{ .success = result };
|
||||
}
|
||||
@@ -2648,6 +2641,11 @@ pub const Resolver = struct {
|
||||
input_path = r.fs.top_level_dir;
|
||||
}
|
||||
|
||||
// A path longer than MAX_PATH_BYTES cannot name a real directory.
|
||||
// Bailing here also prevents overflowing `dir_info_uncached_path`
|
||||
// below when called with user-controlled absolute import paths.
|
||||
if (input_path.len > bun.MAX_PATH_BYTES) return null;
|
||||
|
||||
if (comptime Environment.isWindows) {
|
||||
input_path = r.fs.normalizeBuf(&win32_normalized_dir_info_cache_buf, input_path);
|
||||
// kind of a patch on the fact normalizeBuf isn't 100% perfect what we want
|
||||
@@ -2713,6 +2711,10 @@ pub const Resolver = struct {
|
||||
top_parent = result;
|
||||
break;
|
||||
}
|
||||
// Path has more uncached components than our fixed queue can hold.
|
||||
// This only happens for user-controlled absolute import paths with
|
||||
// hundreds of short components — no real directory is this deep.
|
||||
if (@as(usize, @intCast(i)) >= bufs(.dir_entry_paths_to_resolve).len) return null;
|
||||
bufs(.dir_entry_paths_to_resolve)[@as(usize, @intCast(i))] = DirEntryResolveQueueItem{
|
||||
.unsafe_path = top,
|
||||
.result = result,
|
||||
@@ -3079,12 +3081,13 @@ pub const Resolver = struct {
|
||||
if (total_length != null) {
|
||||
const suffix = std.mem.trimLeft(u8, original_path[total_length orelse original_path.len ..], "*");
|
||||
matched_text_with_suffix_len = matched_text.len + suffix.len;
|
||||
if (matched_text_with_suffix_len > matched_text_with_suffix.len) continue;
|
||||
bun.concat(u8, matched_text_with_suffix, &.{ matched_text, suffix });
|
||||
}
|
||||
|
||||
// 1. Normalize the base path
|
||||
// so that "/Users/foo/project/", "../components/*" => "/Users/foo/components/""
|
||||
const prefix = r.fs.absBuf(&prefix_parts, bufs(.tsconfig_match_full_buf2));
|
||||
const prefix = r.fs.absBufChecked(&prefix_parts, bufs(.tsconfig_match_full_buf2)) orelse continue;
|
||||
|
||||
// 2. Join the new base path with the matched result
|
||||
// so that "/Users/foo/components/", "/foo/bar" => /Users/foo/components/foo/bar
|
||||
@@ -3093,10 +3096,10 @@ pub const Resolver = struct {
|
||||
if (matched_text_with_suffix_len > 0) std.mem.trimLeft(u8, matched_text_with_suffix[0..matched_text_with_suffix_len], "/") else "",
|
||||
std.mem.trimLeft(u8, longest_match.suffix, "/"),
|
||||
};
|
||||
const absolute_original_path = r.fs.absBuf(
|
||||
const absolute_original_path = r.fs.absBufChecked(
|
||||
&parts,
|
||||
bufs(.tsconfig_match_full_buf),
|
||||
);
|
||||
) orelse continue;
|
||||
|
||||
if (r.loadAsFileOrDirectory(absolute_original_path, kind)) |res| {
|
||||
return res;
|
||||
|
||||
@@ -928,7 +928,15 @@ pub const visible = struct {
|
||||
fn visibleUTF16WidthFn(input_: []const u16, exclude_ansi_colors: bool, ambiguousAsWide: bool) usize {
|
||||
var input = input_;
|
||||
var len: usize = 0;
|
||||
// `prev` tracks the literal previous codepoint (including ANSI bytes) —
|
||||
// needed for the OSC ST terminator check (ESC \ = prev==0x1b, cp=='\\').
|
||||
// `prev_visible` tracks the last VISIBLE codepoint — used by graphemeBreak.
|
||||
// Using `prev` for graphemeBreak was a bug: CSI bytes like 'm' would
|
||||
// wrongly join to a following combining mark (e.g. "\x1b[1m\uFE0F?" →
|
||||
// graphemeBreak('m', FE0F) = false → add() on uninitialized state →
|
||||
// width 2 instead of 1).
|
||||
var prev: ?u32 = null;
|
||||
var prev_visible: ?u32 = null;
|
||||
var break_state: grapheme.BreakState = .default;
|
||||
var grapheme_state = GraphemeState{};
|
||||
var saw_1b = false;
|
||||
@@ -966,6 +974,7 @@ pub const visible = struct {
|
||||
const last_cp: u32 = input[bulk_end - 1];
|
||||
grapheme_state.reset(last_cp, ambiguousAsWide);
|
||||
prev = last_cp;
|
||||
prev_visible = last_cp;
|
||||
break_state = .default;
|
||||
|
||||
// If we consumed everything, advance and continue
|
||||
@@ -1037,7 +1046,7 @@ pub const visible = struct {
|
||||
continue;
|
||||
}
|
||||
if (!exclude_ansi_colors or cp != 0x1b) {
|
||||
if (prev) |prev_| {
|
||||
if (prev_visible) |prev_| {
|
||||
const should_break = grapheme.graphemeBreak(@truncate(prev_), @truncate(cp), &break_state);
|
||||
if (should_break) {
|
||||
len += grapheme_state.width();
|
||||
@@ -1048,6 +1057,7 @@ pub const visible = struct {
|
||||
} else {
|
||||
grapheme_state.reset(@truncate(cp), ambiguousAsWide);
|
||||
}
|
||||
prev_visible = cp;
|
||||
continue;
|
||||
}
|
||||
saw_1b = true;
|
||||
@@ -1095,7 +1105,7 @@ pub const visible = struct {
|
||||
// Don't count this char as part of escape, treat normally below
|
||||
}
|
||||
|
||||
if (prev) |prev_| {
|
||||
if (prev_visible) |prev_| {
|
||||
const should_break = grapheme.graphemeBreak(@truncate(prev_), @truncate(cp), &break_state);
|
||||
if (should_break) {
|
||||
len += grapheme_state.width();
|
||||
@@ -1106,6 +1116,7 @@ pub const visible = struct {
|
||||
} else {
|
||||
grapheme_state.reset(cp, ambiguousAsWide);
|
||||
}
|
||||
prev_visible = cp;
|
||||
}
|
||||
// Add width of final grapheme
|
||||
len += grapheme_state.width();
|
||||
@@ -1174,6 +1185,25 @@ export fn Bun__codepointWidth(cp: u32, ambiguous_as_wide: bool) u8 {
|
||||
return @intCast(visibleCodepointWidth(cp, ambiguous_as_wide));
|
||||
}
|
||||
|
||||
/// Grapheme break detection for C++ callers.
|
||||
/// Returns true if there should be a grapheme break between cp1 and cp2.
|
||||
/// `state` is an opaque u8 that must be initialized to 0 and passed between calls.
|
||||
export fn Bun__graphemeBreak(cp1: u32, cp2: u32, state_ptr: *u8) bool {
|
||||
var state: grapheme.BreakState = @enumFromInt(state_ptr.*);
|
||||
const result = grapheme.graphemeBreak(@truncate(cp1), @truncate(cp2), &state);
|
||||
state_ptr.* = @intFromEnum(state);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Check if a codepoint has the Emoji property (using ICU).
|
||||
export fn Bun__isEmojiPresentation(cp: u32) bool {
|
||||
if (cp < 0x203C) return false;
|
||||
if (cp >= 0x2C00 and cp < 0x1F000) return false;
|
||||
if (cp == 0xFE0E or cp == 0xFE0F or cp == 0x200D) return false;
|
||||
// UCHAR_EMOJI = 57
|
||||
return icu_hasBinaryProperty(cp, 57);
|
||||
}
|
||||
|
||||
const bun = @import("bun");
|
||||
const std = @import("std");
|
||||
|
||||
|
||||
107
test/js/bun/http/fetch-header-count-limit.test.ts
Normal file
107
test/js/bun/http/fetch-header-count-limit.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { once } from "node:events";
|
||||
import { createServer } from "node:net";
|
||||
|
||||
// Use a raw TCP server to avoid header count limits in HTTP servers.
|
||||
// The server reads the raw request, extracts header info, and sends a JSON response.
|
||||
function makeRawHttpServer() {
|
||||
const server = createServer(socket => {
|
||||
let data = "";
|
||||
socket.on("data", chunk => {
|
||||
data += chunk.toString();
|
||||
// Wait for the end of the HTTP headers (double CRLF).
|
||||
if (data.includes("\r\n\r\n")) {
|
||||
const headerSection = data.split("\r\n\r\n")[0];
|
||||
const lines = headerSection.split("\r\n");
|
||||
// First line is the request line, rest are headers.
|
||||
let customCount = 0;
|
||||
const headerNames: string[] = [];
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const lower = lines[i].toLowerCase();
|
||||
const colonIdx = lines[i].indexOf(":");
|
||||
if (colonIdx > 0) {
|
||||
headerNames.push(lines[i].substring(0, colonIdx).toLowerCase());
|
||||
}
|
||||
if (lower.startsWith("x-h-")) {
|
||||
customCount++;
|
||||
}
|
||||
}
|
||||
const body = JSON.stringify({ customCount, headerNames });
|
||||
socket.write(
|
||||
`HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: ${body.length}\r\nConnection: close\r\n\r\n${body}`,
|
||||
);
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
return server;
|
||||
}
|
||||
|
||||
test("fetch with many headers does not crash", async () => {
|
||||
await using server = makeRawHttpServer().listen(0);
|
||||
await once(server, "listening");
|
||||
const port = (server.address() as any).port;
|
||||
|
||||
// Build a request with more headers than the internal fixed-size buffer (256).
|
||||
const headers = new Headers();
|
||||
for (let i = 0; i < 300; i++) {
|
||||
headers.set(`x-h-${i}`, `v${i}`);
|
||||
}
|
||||
|
||||
const res = await fetch(`http://127.0.0.1:${port}/test`, { headers });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const { customCount } = await res.json();
|
||||
// Excess headers beyond the internal cap (250 user headers) are silently dropped.
|
||||
expect(customCount).toBe(250);
|
||||
});
|
||||
|
||||
test("fetch with exactly 250 custom headers sends all of them", async () => {
|
||||
await using server = makeRawHttpServer().listen(0);
|
||||
await once(server, "listening");
|
||||
const port = (server.address() as any).port;
|
||||
|
||||
const headers = new Headers();
|
||||
for (let i = 0; i < 250; i++) {
|
||||
headers.set(`x-h-${i}`, `v${i}`);
|
||||
}
|
||||
|
||||
const res = await fetch(`http://127.0.0.1:${port}/test`, { headers });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const { customCount } = await res.json();
|
||||
expect(customCount).toBe(250);
|
||||
});
|
||||
|
||||
test("default headers preserved when user headers overflow the buffer", async () => {
|
||||
await using server = makeRawHttpServer().listen(0);
|
||||
await once(server, "listening");
|
||||
const port = (server.address() as any).port;
|
||||
|
||||
// Use "a-" prefixed headers which sort alphabetically before "accept",
|
||||
// "host", "user-agent", etc. This ensures the filler headers consume all
|
||||
// 250 user-header slots first, pushing the special headers into overflow.
|
||||
// Without the fix, the override flags for Host/Accept/User-Agent would
|
||||
// still be set (suppressing defaults), but the headers themselves would be
|
||||
// dropped — resulting in missing mandatory headers like Host.
|
||||
const headers = new Headers();
|
||||
for (let i = 0; i < 250; i++) {
|
||||
headers.set(`a-${String(i).padStart(4, "0")}`, `v${i}`);
|
||||
}
|
||||
// These special headers sort after "a-*" and will overflow.
|
||||
headers.set("Host", "custom-host.example.com");
|
||||
headers.set("User-Agent", "custom-agent");
|
||||
headers.set("Accept", "text/html");
|
||||
|
||||
const res = await fetch(`http://127.0.0.1:${port}/test`, { headers });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const { headerNames } = await res.json();
|
||||
|
||||
// Even though the user-supplied Host, User-Agent, and Accept were dropped
|
||||
// due to overflow, the DEFAULT versions of these headers must still be
|
||||
// present (the override flags should not have been set for dropped headers).
|
||||
expect(headerNames).toContain("host");
|
||||
expect(headerNames).toContain("user-agent");
|
||||
expect(headerNames).toContain("accept");
|
||||
});
|
||||
@@ -560,3 +560,335 @@ describe("SPILL.TERM - invalid chunk terminators", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Tests for strict RFC 7230 HEXDIG validation in chunk size parsing.
|
||||
// Chunk sizes must only contain characters from the set [0-9a-fA-F].
|
||||
// Non-HEXDIG characters must be rejected to ensure consistent parsing
|
||||
// across all HTTP implementations in a proxy chain.
|
||||
describe("chunk size strict hex digit validation", () => {
|
||||
// Helper to send a raw HTTP request and get the response
|
||||
async function sendRawChunkedRequest(port: number, chunkSizeLine: string, chunkData: string): Promise<string> {
|
||||
const client = net.connect(port, "127.0.0.1");
|
||||
|
||||
const request =
|
||||
"POST / HTTP/1.1\r\n" +
|
||||
"Host: localhost\r\n" +
|
||||
"Transfer-Encoding: chunked\r\n" +
|
||||
"\r\n" +
|
||||
chunkSizeLine +
|
||||
"\r\n" +
|
||||
chunkData +
|
||||
"\r\n" +
|
||||
"0\r\n" +
|
||||
"\r\n";
|
||||
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
let responseData = "";
|
||||
client.on("error", reject);
|
||||
client.on("data", data => {
|
||||
responseData += data.toString();
|
||||
});
|
||||
client.on("close", () => {
|
||||
resolve(responseData);
|
||||
});
|
||||
client.write(request, () => {
|
||||
// Give the server time to process before half-closing
|
||||
setTimeout(() => client.end(), 100);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("accepts valid hex digits 0-9 in chunk size", async () => {
|
||||
let receivedBody = "";
|
||||
await using server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(req) {
|
||||
receivedBody = await req.text();
|
||||
return new Response("OK");
|
||||
},
|
||||
});
|
||||
|
||||
// "9" = 9 bytes
|
||||
const response = await sendRawChunkedRequest(server.port, "9", "123456789");
|
||||
expect(response).toContain("HTTP/1.1 200");
|
||||
expect(receivedBody).toBe("123456789");
|
||||
});
|
||||
|
||||
test("accepts valid hex digits a-f in chunk size", async () => {
|
||||
let receivedBody = "";
|
||||
await using server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(req) {
|
||||
receivedBody = await req.text();
|
||||
return new Response("OK");
|
||||
},
|
||||
});
|
||||
|
||||
// "a" = 10 bytes
|
||||
const response = await sendRawChunkedRequest(server.port, "a", "1234567890");
|
||||
expect(response).toContain("HTTP/1.1 200");
|
||||
expect(receivedBody).toBe("1234567890");
|
||||
});
|
||||
|
||||
test("accepts valid hex digits A-F in chunk size", async () => {
|
||||
let receivedBody = "";
|
||||
await using server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(req) {
|
||||
receivedBody = await req.text();
|
||||
return new Response("OK");
|
||||
},
|
||||
});
|
||||
|
||||
// "B" = 11 bytes
|
||||
const response = await sendRawChunkedRequest(server.port, "B", "12345678901");
|
||||
expect(response).toContain("HTTP/1.1 200");
|
||||
expect(receivedBody).toBe("12345678901");
|
||||
});
|
||||
|
||||
test("accepts multi-digit hex chunk size", async () => {
|
||||
let receivedBody = "";
|
||||
await using server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(req) {
|
||||
receivedBody = await req.text();
|
||||
return new Response("OK");
|
||||
},
|
||||
});
|
||||
|
||||
// "1a" = 26 bytes
|
||||
const response = await sendRawChunkedRequest(server.port, "1a", "abcdefghijklmnopqrstuvwxyz");
|
||||
expect(response).toContain("HTTP/1.1 200");
|
||||
expect(receivedBody).toBe("abcdefghijklmnopqrstuvwxyz");
|
||||
});
|
||||
|
||||
// Characters in ASCII 71+ (G-Z, g-z) are not valid hex digits
|
||||
for (const ch of ["G", "g", "Z", "z", "x", "X"]) {
|
||||
test(`rejects '${ch}' in chunk size (not a hex digit)`, async () => {
|
||||
await using server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(req) {
|
||||
return new Response("OK");
|
||||
},
|
||||
});
|
||||
|
||||
const response = await sendRawChunkedRequest(server.port, `1${ch}`, "A".repeat(32));
|
||||
expect(response).toContain("HTTP/1.1 400");
|
||||
});
|
||||
}
|
||||
|
||||
// Characters in ASCII 58-64 (:, <, =, >, ?, @) lie between '9' and 'A'
|
||||
// and must not be accepted as hex digits
|
||||
for (const ch of [":", "<", "=", ">", "?", "@"]) {
|
||||
test(`rejects '${ch}' (ASCII ${ch.charCodeAt(0)}) in chunk size`, async () => {
|
||||
await using server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(req) {
|
||||
return new Response("OK");
|
||||
},
|
||||
});
|
||||
|
||||
const response = await sendRawChunkedRequest(server.port, `1${ch}`, "A".repeat(32));
|
||||
expect(response).toContain("HTTP/1.1 400");
|
||||
});
|
||||
}
|
||||
|
||||
// Other non-hex characters
|
||||
for (const ch of ["!", "#", "$", "%", "^", "&", "*", "(", ")", "_", "+", "~", "`", "|"]) {
|
||||
test(`rejects '${ch}' in chunk size`, async () => {
|
||||
await using server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(req) {
|
||||
return new Response("OK");
|
||||
},
|
||||
});
|
||||
|
||||
const response = await sendRawChunkedRequest(server.port, `1${ch}`, "A".repeat(32));
|
||||
expect(response).toContain("HTTP/1.1 400");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("pipelined request header isolation", () => {
|
||||
test("pipelined request with no headers does not inherit previous request's headers", async () => {
|
||||
// When pipelining requests, headers from a previous request must not
|
||||
// carry over to subsequent requests. A request with no headers must
|
||||
// be treated as having no Content-Length and no Transfer-Encoding.
|
||||
const requestBodies: string[] = [];
|
||||
const requestUrls: string[] = [];
|
||||
|
||||
await using server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
requestUrls.push(url.pathname);
|
||||
const body = await req.text();
|
||||
requestBodies.push(body);
|
||||
return new Response("OK " + url.pathname);
|
||||
},
|
||||
});
|
||||
|
||||
const client = net.connect(server.port, "127.0.0.1");
|
||||
|
||||
// First request: has Content-Length header with a body
|
||||
// Second request: has NO headers at all (just request line + \r\n\r\n)
|
||||
// The second request must NOT inherit Content-Length from the first.
|
||||
const body = "A".repeat(50);
|
||||
const pipelinedRequests =
|
||||
"POST /first HTTP/1.1\r\n" +
|
||||
"Host: localhost\r\n" +
|
||||
`Content-Length: ${body.length}\r\n` +
|
||||
"\r\n" +
|
||||
body +
|
||||
"GET /second HTTP/1.1\r\n" +
|
||||
"Host: localhost\r\n" +
|
||||
"\r\n";
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let responseData = "";
|
||||
let responseCount = 0;
|
||||
client.on("error", reject);
|
||||
client.on("data", data => {
|
||||
responseData += data.toString();
|
||||
// Count HTTP responses
|
||||
const matches = responseData.match(/HTTP\/1\.1/g);
|
||||
responseCount = matches ? matches.length : 0;
|
||||
if (responseCount >= 2) {
|
||||
client.end();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
client.write(pipelinedRequests);
|
||||
});
|
||||
|
||||
// Both requests should have been handled
|
||||
expect(requestUrls).toContain("/first");
|
||||
expect(requestUrls).toContain("/second");
|
||||
// The second request (GET with no body) must have an empty body
|
||||
const secondIdx = requestUrls.indexOf("/second");
|
||||
expect(requestBodies[secondIdx]).toBe("");
|
||||
});
|
||||
|
||||
test("pipelined headerless request does not consume next client's data as body", async () => {
|
||||
// Simulates the scenario where a headerless pipelined request could
|
||||
// incorrectly read stale Content-Length and consume subsequent data as body.
|
||||
const requestBodies: string[] = [];
|
||||
const requestUrls: string[] = [];
|
||||
|
||||
await using server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
requestUrls.push(url.pathname);
|
||||
const body = await req.text();
|
||||
requestBodies.push(body);
|
||||
return new Response("OK " + url.pathname);
|
||||
},
|
||||
});
|
||||
|
||||
const client = net.connect(server.port, "127.0.0.1");
|
||||
|
||||
const body = "X".repeat(30);
|
||||
// Request 1: POST with Content-Length
|
||||
// Request 2: GET with no headers at all (empty headers)
|
||||
// Request 3: GET with normal headers
|
||||
// If stale headers leak, request 2 would try to read request 3's bytes as body
|
||||
const pipelinedRequests =
|
||||
"POST /req1 HTTP/1.1\r\n" +
|
||||
"Host: localhost\r\n" +
|
||||
`Content-Length: ${body.length}\r\n` +
|
||||
"\r\n" +
|
||||
body +
|
||||
"GET /req2 HTTP/1.1\r\n" +
|
||||
"Host: localhost\r\n" +
|
||||
"\r\n" +
|
||||
"GET /req3 HTTP/1.1\r\n" +
|
||||
"Host: localhost\r\n" +
|
||||
"\r\n";
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let responseData = "";
|
||||
let responseCount = 0;
|
||||
client.on("error", reject);
|
||||
client.on("data", data => {
|
||||
responseData += data.toString();
|
||||
const matches = responseData.match(/HTTP\/1\.1/g);
|
||||
responseCount = matches ? matches.length : 0;
|
||||
if (responseCount >= 3) {
|
||||
client.end();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
client.write(pipelinedRequests);
|
||||
});
|
||||
|
||||
// All three requests should have been processed independently
|
||||
expect(requestUrls).toContain("/req1");
|
||||
expect(requestUrls).toContain("/req2");
|
||||
expect(requestUrls).toContain("/req3");
|
||||
// req2 and req3 (both GETs) should have empty bodies
|
||||
const req2Idx = requestUrls.indexOf("/req2");
|
||||
const req3Idx = requestUrls.indexOf("/req3");
|
||||
expect(requestBodies[req2Idx]).toBe("");
|
||||
expect(requestBodies[req3Idx]).toBe("");
|
||||
});
|
||||
|
||||
test("pipelined headerless request is rejected and does not inherit stale content-length", async () => {
|
||||
// A pipelined request with truly NO headers (not even Host) must be
|
||||
// properly rejected. It must NOT inherit a Content-Length or
|
||||
// Transfer-Encoding from the previous request on the same connection.
|
||||
let secondRequestReached = false;
|
||||
|
||||
await using server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname === "/second") {
|
||||
secondRequestReached = true;
|
||||
}
|
||||
return new Response("OK " + url.pathname);
|
||||
},
|
||||
});
|
||||
|
||||
const client = net.connect(server.port, "127.0.0.1");
|
||||
|
||||
const body = "B".repeat(50);
|
||||
// Request 1: POST with Content-Length: 50
|
||||
// Request 2: completely headerless (no Host, no nothing)
|
||||
// Without the fix, headers[1] would still contain stale headers from
|
||||
// request 1, and the parser would incorrectly read Content-Length: 50
|
||||
// from the stale data, consuming the next 50 bytes as body.
|
||||
const pipelinedRequests =
|
||||
"POST /first HTTP/1.1\r\n" +
|
||||
"Host: localhost\r\n" +
|
||||
`Content-Length: ${body.length}\r\n` +
|
||||
"\r\n" +
|
||||
body +
|
||||
"GET /second HTTP/1.1\r\n" +
|
||||
"\r\n";
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let responseData = "";
|
||||
client.on("error", reject);
|
||||
client.on("data", data => {
|
||||
responseData += data.toString();
|
||||
// We expect: 200 for request 1, then 400 for request 2 (missing Host)
|
||||
const responses = responseData.match(/HTTP\/1\.1 \d+/g);
|
||||
if (responses && responses.length >= 2) {
|
||||
client.end();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
// Also resolve on close in case the server closes the connection
|
||||
client.on("close", () => {
|
||||
resolve();
|
||||
});
|
||||
client.write(pipelinedRequests);
|
||||
});
|
||||
|
||||
// The headerless second request must NOT have reached the handler
|
||||
// (it should be rejected for missing Host header, not processed
|
||||
// with stale headers from the first request)
|
||||
expect(secondRequestReached).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { bunEnv, bunExe, tempDir } from "harness";
|
||||
|
||||
describe("ResolveMessage", () => {
|
||||
it("position object does not segfault", async () => {
|
||||
@@ -60,3 +61,75 @@ describe("ResolveMessage", () => {
|
||||
}).toThrow("Cannot find module");
|
||||
});
|
||||
});
|
||||
|
||||
// These tests reproduce panics where the module resolver wrote past fixed-size
|
||||
// PathBuffers when given very long import specifiers. The bug triggers when
|
||||
// `import_path < PATH_MAX` but `baseUrl + import_path > PATH_MAX` (otherwise a
|
||||
// syscall returns ENAMETOOLONG first). PATH_MAX is 1024 on macOS, 4096 on
|
||||
// Linux/Windows, so pick a length just under it per platform.
|
||||
// Any length > 512 also exercises the `esm_subpath` buffer.
|
||||
describe.concurrent("long import path overflow", () => {
|
||||
const len = process.platform === "darwin" ? 1020 : 4090;
|
||||
// "a".repeat is slow in debug builds; use Buffer.alloc instead.
|
||||
const long = Buffer.alloc(len, "a").toString();
|
||||
|
||||
function makeDir() {
|
||||
// package.json + node_modules/ prevent the resolver from attempting
|
||||
// auto-install (which has an unrelated pre-existing bug).
|
||||
return tempDir("resolve-long-path", {
|
||||
"package.json": `{"name": "test", "version": "0.0.0"}`,
|
||||
"node_modules/.keep": "",
|
||||
"tsconfig.json": `{"compilerOptions": {"baseUrl": ".", "paths": {"@x/*": ["./src/*"]}}}`,
|
||||
});
|
||||
}
|
||||
|
||||
async function run(dir: string, importExpr: string) {
|
||||
await using proc = Bun.spawn({
|
||||
cmd: [bunExe(), "-e", `try { await import(${importExpr}); } catch {} console.log("ok");`],
|
||||
env: bunEnv,
|
||||
cwd: dir,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
||||
expect(stderr).toBe("");
|
||||
expect(stdout.trim()).toBe("ok");
|
||||
expect(exitCode).toBe(0);
|
||||
}
|
||||
|
||||
it("bare package specifier (tsconfig baseUrl + import_path join)", async () => {
|
||||
using dir = makeDir();
|
||||
// normalizeStringGenericTZ: `@memcpy(buf[buf_i..][0..count], ...)` past PathBuffer
|
||||
await run(String(dir), `\`@nonexistent/pkg/build/${long}.js\``);
|
||||
});
|
||||
|
||||
it("tsconfig paths wildcard (matched text captured from import path)", async () => {
|
||||
using dir = makeDir();
|
||||
// matchTSConfigPaths: bun.concat into fixed tsconfig_match_full_buf3
|
||||
await run(String(dir), `\`@x/${long}\``);
|
||||
});
|
||||
|
||||
it("relative path (source_dir + import_path join)", async () => {
|
||||
using dir = makeDir();
|
||||
// checkRelativePath / resolveWithoutRemapping absBuf
|
||||
await run(String(dir), `\`./${long}.js\``);
|
||||
});
|
||||
|
||||
it("relative path full of `..` segments (exercises normalization fallback)", async () => {
|
||||
using dir = makeDir();
|
||||
// Concat length >> PATH_MAX but normalizes down; JoinScratch heap fallback
|
||||
await run(String(dir), `\`./\${"x/../".repeat(${len})}${long}.js\``);
|
||||
});
|
||||
|
||||
it("absolute path longer than PATH_MAX (dirInfoCached buffer)", async () => {
|
||||
using dir = makeDir();
|
||||
// dirInfoCachedMaybeLog: bun.copy into dir_info_uncached_path
|
||||
await run(String(dir), `\`/${long}/mixed\``);
|
||||
});
|
||||
|
||||
it("absolute path with >256 short components (dir_entry_paths_to_resolve queue)", async () => {
|
||||
using dir = makeDir();
|
||||
// Walk-up loop indexed into a fixed [256]DirEntryResolveQueueItem
|
||||
await run(String(dir), `\`/\${"a/".repeat(300)}x\``);
|
||||
});
|
||||
});
|
||||
|
||||
837
test/js/bun/util/sliceAnsi-fuzz.test.ts
Normal file
837
test/js/bun/util/sliceAnsi-fuzz.test.ts
Normal file
@@ -0,0 +1,837 @@
|
||||
// Fuzz/robustness tests for Bun.sliceAnsi.
|
||||
// These complement sliceAnsi.test.ts with property-based and adversarial cases.
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
// Seeded PRNG for reproducibility. Change seed to explore different cases.
|
||||
function makeRng(seed: number) {
|
||||
return () => {
|
||||
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||||
return seed / 0x7fffffff;
|
||||
};
|
||||
}
|
||||
|
||||
// Some random-string cases include orphaned C1 controls (0x90, 0x98, 0x9C,
|
||||
// 0x9E, 0x9F) that sliceAnsi consumes as control tokens but stripANSI leaves
|
||||
// in (they're not SGR/OSC). To avoid testing that minor inconsistency, strip
|
||||
// both before comparing. Everything else uses stringWidth directly now that
|
||||
// the ANSI-breaks-grapheme bug is fixed.
|
||||
const visibleWidth = (s: string) => Bun.stringWidth(Bun.stripANSI(s));
|
||||
|
||||
// ============================================================================
|
||||
// Invariants that MUST hold for ANY input (property tests)
|
||||
// ============================================================================
|
||||
|
||||
describe("sliceAnsi invariants", () => {
|
||||
// Property: output width ≤ requested width.
|
||||
// sliceAnsi(s, a, b) should never produce visible content wider than (b - a).
|
||||
// (May be narrower if wide char doesn't fit at boundary.)
|
||||
test("output width never exceeds requested range", () => {
|
||||
const rng = makeRng(0xc0ffee);
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const s = randomString(rng, 0, 100);
|
||||
const w = Bun.stringWidth(s);
|
||||
const a = Math.floor(rng() * (w + 5));
|
||||
const b = a + Math.floor(rng() * (w + 5));
|
||||
const out = Bun.sliceAnsi(s, a, b);
|
||||
// +1 tolerance: a wide cluster (width 2) whose START is inside the range
|
||||
// is emitted in full even if it extends 1 col past `end`. This matches
|
||||
// upstream slice-ansi semantics (clusters are atomic; a wide char at
|
||||
// the cut boundary either goes in whole or not at all).
|
||||
expect(visibleWidth(out)).toBeLessThanOrEqual(Math.max(0, b - a) + 1);
|
||||
}
|
||||
});
|
||||
|
||||
// Property: stripANSI(slice) == slice of stripped.
|
||||
// The visible text of the slice should match plain string.slice on stripped input
|
||||
// (modulo wide-char boundary rounding — we allow prefix match).
|
||||
test("slice of stripped equals stripped slice (for 1-width chars)", () => {
|
||||
const rng = makeRng(0xbeef);
|
||||
for (let i = 0; i < 200; i++) {
|
||||
// Limit to width-1 chars for this property (wide chars may skip positions)
|
||||
const s = randomAnsiAscii(rng, 0, 80);
|
||||
const plain = Bun.stripANSI(s);
|
||||
const a = Math.floor(rng() * (plain.length + 2));
|
||||
const b = a + Math.floor(rng() * (plain.length + 2));
|
||||
const sliced = Bun.stripANSI(Bun.sliceAnsi(s, a, b));
|
||||
const expected = plain.slice(a, b);
|
||||
expect(sliced).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
// Property: concat of adjacent slices reconstructs the visible content.
|
||||
test("adjacent slices cover full visible string", () => {
|
||||
const rng = makeRng(0xdead);
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const s = randomAnsiAscii(rng, 0, 60);
|
||||
const w = Bun.stringWidth(s);
|
||||
const mid = Math.floor(rng() * (w + 1));
|
||||
const left = Bun.stripANSI(Bun.sliceAnsi(s, 0, mid));
|
||||
const right = Bun.stripANSI(Bun.sliceAnsi(s, mid, w));
|
||||
expect(left + right).toBe(Bun.stripANSI(s));
|
||||
}
|
||||
});
|
||||
|
||||
// Property: slice result is a valid string (no surrogates split, no garbage).
|
||||
test("output is always well-formed UTF-16", () => {
|
||||
const rng = makeRng(0xface);
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const s = randomString(rng, 0, 100);
|
||||
const a = Math.floor(rng() * 50) - 10;
|
||||
const b = Math.floor(rng() * 50) - 10;
|
||||
const out = Bun.sliceAnsi(s, a, b);
|
||||
// Iterating codepoints should not throw; no lone surrogates at boundaries.
|
||||
// Note: lone surrogates in INPUT may pass through (we don't sanitize input),
|
||||
// but we should never CREATE new lone surrogates by splitting a pair.
|
||||
for (const cp of out) {
|
||||
const c = cp.codePointAt(0)!;
|
||||
if (c >= 0xd800 && c <= 0xdfff) {
|
||||
// If input didn't have this lone surrogate at an index the slice touched,
|
||||
// we created it — that's a bug. But for fuzz purposes, just assert it
|
||||
// existed in input (conservative check).
|
||||
expect(s).toContain(cp);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Property: identity. slice(s, 0, Infinity) == s (modulo ANSI normalization).
|
||||
test("full slice preserves visible content", () => {
|
||||
const rng = makeRng(0x1234);
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const s = randomString(rng, 0, 100);
|
||||
const out = Bun.sliceAnsi(s, 0);
|
||||
// Note: sliceAnsi consumes standalone C1 ST (0x9C) as a control token,
|
||||
// but stripANSI leaves it in (it's not an SGR/OSC sequence). To avoid
|
||||
// testing that inconsistency, strip 0x9C from both sides for comparison.
|
||||
// Same for other standalone C1 controls (0x90, 0x98, 0x9E, 0x9F) which
|
||||
// sliceAnsi will now fall-through as width-0 visible chars.
|
||||
const normalize = (x: string) => x.replace(/[\u0090\u0098\u009C\u009E\u009F]/g, "");
|
||||
expect(normalize(Bun.stripANSI(out))).toBe(normalize(Bun.stripANSI(s)));
|
||||
expect(visibleWidth(out)).toBe(visibleWidth(s));
|
||||
}
|
||||
});
|
||||
|
||||
// Property: idempotence. slice(slice(s, a, b), 0, b-a) == slice(s, a, b) visually.
|
||||
test("slicing a slice is idempotent on visible content", () => {
|
||||
const rng = makeRng(0x5678);
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const s = randomString(rng, 0, 80);
|
||||
const w = Bun.stringWidth(s);
|
||||
const a = Math.floor(rng() * (w + 1));
|
||||
const b = a + Math.floor(rng() * (w - a + 1));
|
||||
const once = Bun.sliceAnsi(s, a, b);
|
||||
const twice = Bun.sliceAnsi(once, 0, b - a);
|
||||
expect(Bun.stripANSI(twice)).toBe(Bun.stripANSI(once));
|
||||
}
|
||||
});
|
||||
|
||||
// Property: ellipsis width accounting. Output width with ellipsis ≤ requested.
|
||||
test("ellipsis output width respects budget", () => {
|
||||
const rng = makeRng(0xe111);
|
||||
const ellipses = ["…", ".", "...", "→", ""];
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const s = randomString(rng, 5, 100);
|
||||
const n = Math.floor(rng() * 40) + 1;
|
||||
const e = ellipses[Math.floor(rng() * ellipses.length)];
|
||||
const out = Bun.sliceAnsi(s, 0, n, e);
|
||||
// +1 tolerance for wide cluster at the cut boundary (same as above).
|
||||
// Also: if ellipsis itself is wider than n (degenerate), it's returned
|
||||
// as-is — output may exceed n by up to ellipsisWidth-1.
|
||||
const ew = visibleWidth(e);
|
||||
const tolerance = Math.max(1, ew > n ? ew - n : 0);
|
||||
expect(visibleWidth(out)).toBeLessThanOrEqual(n + tolerance);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Adversarial inputs designed to stress edge cases
|
||||
// ============================================================================
|
||||
|
||||
describe("sliceAnsi adversarial", () => {
|
||||
// Strings near SIMD stride boundaries (16 bytes / 8 shorts).
|
||||
test("inputs near SIMD stride boundaries", () => {
|
||||
for (const len of [0, 1, 7, 8, 9, 15, 16, 17, 31, 32, 33, 63, 64, 65]) {
|
||||
const s = Buffer.alloc(len, "x").toString();
|
||||
expect(Bun.sliceAnsi(s, 0, len)).toBe(s);
|
||||
expect(Bun.sliceAnsi(s, 0, Math.floor(len / 2))).toBe(s.slice(0, Math.floor(len / 2)));
|
||||
// With ANSI
|
||||
const ansi = "\x1b[31m" + s + "\x1b[39m";
|
||||
expect(Bun.stripANSI(Bun.sliceAnsi(ansi, 0, len))).toBe(s);
|
||||
}
|
||||
});
|
||||
|
||||
// 0x9C (C1 ST) at various positions relative to SIMD stride.
|
||||
test("C1 ST at SIMD boundary positions", () => {
|
||||
for (const pos of [0, 1, 7, 8, 15, 16, 17]) {
|
||||
const prefix = Buffer.alloc(pos, "x").toString();
|
||||
const s = prefix + "\u009C" + "A";
|
||||
// 0x9C is consumed by sliceAnsi as a standalone ST control token
|
||||
// (width 0, not emitted pre-include). But stripANSI doesn't strip it.
|
||||
// So compare against stringWidth-based slicing instead.
|
||||
const out = Bun.sliceAnsi(s, 0, pos + 1);
|
||||
// Output width should be pos + 1 (prefix 'x's + 'A').
|
||||
expect(Bun.stringWidth(out)).toBe(pos + 1);
|
||||
// 0x9C should NOT appear in output (consumed as control pre-include).
|
||||
// Note: if pos > 0, include is already true by the time we hit 0x9C
|
||||
// (position >= start=0 triggers on first char), so 0x9C DOES get emitted
|
||||
// as a Control token when include=true. Behavior matches upstream.
|
||||
// Just check width for now:
|
||||
expect(Bun.stringWidth(out)).toBe(pos + 1);
|
||||
}
|
||||
});
|
||||
|
||||
// Unterminated ANSI sequences.
|
||||
test("unterminated CSI sequences don't hang or overread", () => {
|
||||
const cases = [
|
||||
"\x1b", // lone ESC
|
||||
"\x1b[", // CSI introducer, no final
|
||||
"\x1b[31", // CSI params, no final
|
||||
"\x1b[31;", // CSI params with trailing ;
|
||||
"\x1b]", // OSC introducer, no body
|
||||
"\x1b]8", // OSC 8 fragment
|
||||
"\x1b]8;;", // OSC 8 no URL no terminator
|
||||
"\x1b]8;;http://x", // OSC 8 URL no terminator
|
||||
"\x1bP", // DCS no body
|
||||
"\x1b_", // APC no body
|
||||
"\u009b", // C1 CSI, no params
|
||||
"\u009b31", // C1 CSI, no final
|
||||
"\u009d8;;http://x", // C1 OSC unterminated
|
||||
];
|
||||
for (const c of cases) {
|
||||
// Should not hang, not crash, return some finite string.
|
||||
const out = Bun.sliceAnsi(c, 0, 10);
|
||||
expect(typeof out).toBe("string");
|
||||
expect(out.length).toBeLessThanOrEqual(c.length);
|
||||
// With content after
|
||||
const withAfter = c + "XYZ";
|
||||
const out2 = Bun.sliceAnsi(withAfter, 0, 10);
|
||||
expect(typeof out2).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
// Deeply nested / many SGR codes (stress SgrStyleState).
|
||||
test("many SGR codes don't overflow or quadratic-slow", () => {
|
||||
// 100 nested styles. SgrStyleState has inline capacity 4, so this spills to heap.
|
||||
let s = "";
|
||||
for (let i = 1; i <= 9; i++) s += `\x1b[3${i}m`; // 9 fg colors (last wins)
|
||||
for (let i = 0; i < 50; i++) s += `\x1b[1m\x1b[3m\x1b[4m\x1b[7m`; // bold italic underline inverse ×50
|
||||
s += "X";
|
||||
for (let i = 0; i < 50; i++) s += `\x1b[22m\x1b[23m\x1b[24m\x1b[27m`;
|
||||
for (let i = 9; i >= 1; i--) s += `\x1b[39m`;
|
||||
const out = Bun.sliceAnsi(s, 0, 1);
|
||||
expect(Bun.stripANSI(out)).toBe("X");
|
||||
// Time bound: should be O(n), not O(n²). Generous threshold for debug builds.
|
||||
const start = Bun.nanoseconds();
|
||||
for (let i = 0; i < 1000; i++) Bun.sliceAnsi(s, 0, 1);
|
||||
const elapsed = (Bun.nanoseconds() - start) / 1e6;
|
||||
expect(elapsed).toBeLessThan(5000); // < 5s for 1000 iters
|
||||
});
|
||||
|
||||
// Huge SGR parameter values.
|
||||
test("huge SGR params don't overflow uint32", () => {
|
||||
const s = "\x1b[99999999999999999999mX\x1b[0m";
|
||||
const out = Bun.sliceAnsi(s, 0, 1);
|
||||
expect(Bun.stripANSI(out)).toBe("X");
|
||||
});
|
||||
|
||||
// Many semicolons (SGR param count).
|
||||
test("SGR with many parameters", () => {
|
||||
const params = Array(1000).fill("0").join(";");
|
||||
const s = `\x1b[${params}mX\x1b[0m`;
|
||||
const out = Bun.sliceAnsi(s, 0, 1);
|
||||
expect(Bun.stripANSI(out)).toBe("X");
|
||||
});
|
||||
|
||||
// All zero-width codepoints (position never advances in naive impl).
|
||||
test("string of only zero-width chars doesn't hang", () => {
|
||||
const zw = "\u200B".repeat(1000); // ZWSP × 1000
|
||||
const out = Bun.sliceAnsi(zw, 0, 5);
|
||||
// Width 0, so [0, 5) should emit all of them (all at position 0).
|
||||
expect(Bun.stringWidth(out)).toBe(0);
|
||||
// Should terminate — not hang.
|
||||
const start = Bun.nanoseconds();
|
||||
Bun.sliceAnsi(zw, 0, 5);
|
||||
expect(Bun.nanoseconds() - start).toBeLessThan(1e9); // < 1s
|
||||
});
|
||||
|
||||
// Very long ZWJ chain (stresses GraphemeWidthState).
|
||||
test("very long ZWJ emoji chain", () => {
|
||||
// 👨👩👧👦 repeated — each family is one cluster.
|
||||
const family = "\u{1F468}\u200D\u{1F469}\u200D\u{1F467}\u200D\u{1F466}";
|
||||
const many = family.repeat(100);
|
||||
expect(Bun.stringWidth(many)).toBe(200); // 100 families × width 2
|
||||
const out = Bun.sliceAnsi(many, 0, 10);
|
||||
expect(Bun.stringWidth(out)).toBe(10); // 5 families
|
||||
});
|
||||
|
||||
// Extreme indices.
|
||||
test("extreme index values", () => {
|
||||
const s = "hello";
|
||||
// Should not crash/hang for any of these.
|
||||
expect(Bun.sliceAnsi(s, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER)).toBe("");
|
||||
expect(Bun.sliceAnsi(s, -Number.MAX_SAFE_INTEGER, -Number.MAX_SAFE_INTEGER)).toBe("");
|
||||
expect(Bun.sliceAnsi(s, -Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER)).toBe("hello");
|
||||
expect(Bun.sliceAnsi(s, 0, 0)).toBe("");
|
||||
expect(Bun.sliceAnsi(s, NaN, 3)).toBe("hel"); // NaN → 0 per toIntegerOrInfinity
|
||||
expect(Bun.sliceAnsi(s, 0, NaN)).toBe(""); // NaN → 0
|
||||
// @ts-expect-error — testing coercion
|
||||
expect(Bun.sliceAnsi(s, "1", "3")).toBe("el"); // string coercion
|
||||
});
|
||||
|
||||
// OSC with very long URL.
|
||||
test("OSC 8 with very long URL", () => {
|
||||
const longUrl = "https://example.com/" + "x".repeat(10000);
|
||||
const s = `\x1b]8;;${longUrl}\x07link\x1b]8;;\x07`;
|
||||
const out = Bun.sliceAnsi(s, 0, 4);
|
||||
expect(Bun.stripANSI(out)).toBe("link");
|
||||
// The URL should be preserved.
|
||||
expect(out).toContain(longUrl);
|
||||
});
|
||||
|
||||
// Interleaved everything at once.
|
||||
test("ANSI + emoji + CJK + hyperlinks interleaved", () => {
|
||||
const s =
|
||||
"\x1b[1m安\x1b[31m\x1b]8;;http://a\x07👨👩👧\x1b]8;;\x07\x1b[39m宁\x1b[22m" + "\x1b[4mhello\x1b[24m\u200B\u5b89world";
|
||||
// Just verify no crash, width is sane, stripping works.
|
||||
const w = Bun.stringWidth(s);
|
||||
for (let a = 0; a <= w; a++) {
|
||||
for (let b = a; b <= w; b++) {
|
||||
const out = Bun.sliceAnsi(s, a, b);
|
||||
// +1 tolerance for wide cluster at cut boundary.
|
||||
expect(visibleWidth(out)).toBeLessThanOrEqual(b - a + 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ANSI codes between every character.
|
||||
test("ANSI code between every single visible char", () => {
|
||||
const chars = "abcdefghij";
|
||||
let s = "";
|
||||
for (const c of chars) s += `\x1b[3${chars.indexOf(c) % 8}m${c}`;
|
||||
s += "\x1b[39m";
|
||||
// Every slice range should produce correct visible text.
|
||||
for (let a = 0; a < chars.length; a++) {
|
||||
for (let b = a; b <= chars.length; b++) {
|
||||
expect(Bun.stripANSI(Bun.sliceAnsi(s, a, b))).toBe(chars.slice(a, b));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ANSI inside grapheme cluster.
|
||||
test("ANSI between base and combining mark", () => {
|
||||
const s = "e\x1b[31m\u0301\x1b[39m"; // 'e' + red + combining acute + reset
|
||||
// é is one cluster, width 1.
|
||||
expect(Bun.stringWidth(Bun.stripANSI(s))).toBe(1);
|
||||
const out = Bun.sliceAnsi(s, 0, 1);
|
||||
expect(Bun.stripANSI(out)).toBe("e\u0301");
|
||||
});
|
||||
|
||||
// Rope string edge case (JSC may represent concatenated strings as ropes).
|
||||
test("rope string (concatenation without flattening)", () => {
|
||||
// Force a rope by repeated concat without intermediate reads.
|
||||
let rope = "";
|
||||
for (let i = 0; i < 100; i++) rope = rope + "x\x1b[31my\x1b[39m";
|
||||
// toString in the binding should flatten; verify correctness.
|
||||
const out = Bun.sliceAnsi(rope, 0, 50);
|
||||
expect(Bun.stripANSI(out).length).toBe(50);
|
||||
});
|
||||
|
||||
// Ellipsis that contains ANSI codes.
|
||||
test("ellipsis string containing ANSI codes", () => {
|
||||
// User shouldn't do this, but we shouldn't crash.
|
||||
const s = "hello world";
|
||||
const out = Bun.sliceAnsi(s, 0, 5, "\x1b[31m…\x1b[39m");
|
||||
// ellipsisWidth is computed via visibleWidthExcludeANSI → 1 for "…"
|
||||
expect(typeof out).toBe("string");
|
||||
expect(out).toContain("…");
|
||||
});
|
||||
|
||||
// Ellipsis wider than slice range.
|
||||
test("ellipsis wider than available range", () => {
|
||||
const s = "abcdef";
|
||||
// Range width 2, ellipsis "..." width 3 → degenerate
|
||||
const out = Bun.sliceAnsi(s, 0, 2, "...");
|
||||
// Should return ellipsis.toString() per degenerate case handling.
|
||||
expect(out).toBe("...");
|
||||
});
|
||||
|
||||
// Negative-index + ellipsis (stresses computeTotalWidth pre-pass).
|
||||
test("negative index with ellipsis (exercises pre-pass)", () => {
|
||||
const s = "\x1b[31m" + "x".repeat(100) + "\x1b[39m";
|
||||
const out = Bun.sliceAnsi(s, -10, undefined, "…");
|
||||
// Last 10 chars with leading ellipsis: "…" + 9 x's = width 10
|
||||
expect(Bun.stringWidth(out)).toBe(10);
|
||||
expect(Bun.stripANSI(out)).toBe("…" + "x".repeat(9));
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Consistency cross-checks with Bun.stringWidth / Bun.stripANSI
|
||||
// ============================================================================
|
||||
|
||||
describe("sliceAnsi consistency with other Bun APIs", () => {
|
||||
test("slice width matches stringWidth delta", () => {
|
||||
const rng = makeRng(0xabcd);
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const s = randomString(rng, 10, 80);
|
||||
const totalW = Bun.stringWidth(s);
|
||||
// Slice [0, totalW) should give back the full width.
|
||||
// Use stripped width on both sides to avoid Bun.stringWidth's
|
||||
// ANSI-breaks-grapheme-state bug (see visibleWidth comment at top).
|
||||
expect(visibleWidth(Bun.sliceAnsi(s, 0, totalW))).toBe(visibleWidth(s));
|
||||
}
|
||||
});
|
||||
|
||||
test("stripANSI(sliceAnsi(s)) == sliceAnsi(stripANSI(s)) for width-1 text", () => {
|
||||
const rng = makeRng(0xd00d);
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const s = randomAnsiAscii(rng, 0, 60);
|
||||
const plain = Bun.stripANSI(s);
|
||||
const a = Math.floor(rng() * plain.length);
|
||||
const b = a + Math.floor(rng() * (plain.length - a + 1));
|
||||
expect(Bun.stripANSI(Bun.sliceAnsi(s, a, b))).toBe(Bun.sliceAnsi(plain, a, b));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
function randomString(rng: () => number, minLen: number, maxLen: number): string {
|
||||
const len = minLen + Math.floor(rng() * (maxLen - minLen + 1));
|
||||
const pieces: string[] = [];
|
||||
for (let i = 0; i < len; ) {
|
||||
const r = rng();
|
||||
if (r < 0.4) {
|
||||
// ASCII char
|
||||
pieces.push(String.fromCharCode(0x20 + Math.floor(rng() * 95)));
|
||||
i++;
|
||||
} else if (r < 0.55) {
|
||||
// SGR code
|
||||
pieces.push(`\x1b[${Math.floor(rng() * 108)}m`);
|
||||
} else if (r < 0.65) {
|
||||
// CJK (width 2)
|
||||
pieces.push(String.fromCodePoint(0x4e00 + Math.floor(rng() * 0x5000)));
|
||||
i += 2;
|
||||
} else if (r < 0.72) {
|
||||
// Emoji (surrogate pair, width 2)
|
||||
pieces.push(String.fromCodePoint(0x1f600 + Math.floor(rng() * 50)));
|
||||
i += 2;
|
||||
} else if (r < 0.78) {
|
||||
// Combining mark (joins to prev, width 0)
|
||||
pieces.push(String.fromCodePoint(0x0300 + Math.floor(rng() * 0x70)));
|
||||
} else if (r < 0.82) {
|
||||
// ZWJ sequence fragment
|
||||
pieces.push("\u200D");
|
||||
} else if (r < 0.86) {
|
||||
// Variation selector
|
||||
pieces.push(rng() < 0.5 ? "\uFE0E" : "\uFE0F");
|
||||
} else if (r < 0.9) {
|
||||
// Hyperlink
|
||||
pieces.push(`\x1b]8;;http://e.x/${Math.floor(rng() * 1000)}\x07`);
|
||||
} else if (r < 0.93) {
|
||||
// Control char
|
||||
pieces.push(String.fromCharCode(Math.floor(rng() * 0x20)));
|
||||
} else if (r < 0.96) {
|
||||
// C1 control
|
||||
pieces.push(String.fromCharCode(0x80 + Math.floor(rng() * 0x20)));
|
||||
} else {
|
||||
// Truecolor SGR
|
||||
pieces.push(`\x1b[38;2;${Math.floor(rng() * 256)};${Math.floor(rng() * 256)};${Math.floor(rng() * 256)}m`);
|
||||
}
|
||||
}
|
||||
return pieces.join("");
|
||||
}
|
||||
|
||||
// ASCII-only with random SGR (width-1 chars only, for strict property checks).
|
||||
function randomAnsiAscii(rng: () => number, minLen: number, maxLen: number): string {
|
||||
const len = minLen + Math.floor(rng() * (maxLen - minLen + 1));
|
||||
const pieces: string[] = [];
|
||||
let visibleCount = 0;
|
||||
while (visibleCount < len) {
|
||||
if (rng() < 0.3) {
|
||||
pieces.push(`\x1b[${Math.floor(rng() * 50)}m`);
|
||||
} else {
|
||||
pieces.push(String.fromCharCode(0x21 + Math.floor(rng() * 94))); // ! to ~
|
||||
visibleCount++;
|
||||
}
|
||||
}
|
||||
pieces.push("\x1b[0m");
|
||||
return pieces.join("");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Negative-index / computeTotalWidth property tests
|
||||
// ============================================================================
|
||||
// Negative indices trigger the ONLY 2-pass code path (computeTotalWidth pre-
|
||||
// pass). It was less exercised by the unit tests, which mostly use [0, n).
|
||||
|
||||
describe("sliceAnsi negative indices", () => {
|
||||
test("negative slice equals positive slice via totalWidth", () => {
|
||||
const rng = makeRng(0x1de4);
|
||||
for (let i = 0; i < 150; i++) {
|
||||
const s = randomAnsiAscii(rng, 5, 60);
|
||||
const w = Bun.stringWidth(Bun.stripANSI(s));
|
||||
// slice(s, -k) should equal slice(s, w - k, w)
|
||||
const k = Math.floor(rng() * w) + 1;
|
||||
const neg = Bun.sliceAnsi(s, -k);
|
||||
const pos = Bun.sliceAnsi(s, w - k, w);
|
||||
expect(Bun.stripANSI(neg)).toBe(Bun.stripANSI(pos));
|
||||
}
|
||||
});
|
||||
|
||||
test("computeTotalWidth matches stringWidth for cluster-rich input", () => {
|
||||
// Negative indices with clustering (emoji, ZWJ, combining) stress the
|
||||
// pre-pass path. It should give the same totalWidth as stringWidth.
|
||||
// Note: use stringWidth(s) directly (NOT stripANSI) — stripANSI's
|
||||
// consumeANSI swallows unterminated OSC to EOF, but both stringWidth
|
||||
// and sliceAnsi correctly treat malformed introducers as standalone.
|
||||
const rng = makeRng(0x70741);
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const s = randomString(rng, 10, 80);
|
||||
const w = Bun.stringWidth(s);
|
||||
// slice(s, -w) should return everything (start resolves to 0).
|
||||
const out = Bun.sliceAnsi(s, -w);
|
||||
expect(Bun.stringWidth(out)).toBe(w);
|
||||
}
|
||||
});
|
||||
|
||||
test("negative end with ellipsis (cutEndKnown=true path)", () => {
|
||||
const rng = makeRng(0x1de5);
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const s = randomAnsiAscii(rng, 10, 60);
|
||||
const w = Bun.stringWidth(Bun.stripANSI(s));
|
||||
// [0, -5) with ellipsis — cutEnd is KNOWN (negative end forces pre-pass).
|
||||
const out = Bun.sliceAnsi(s, 0, -5, "\u2026");
|
||||
// Should be at most w-5+1 cols (+1 for wide-at-boundary).
|
||||
expect(visibleWidth(out)).toBeLessThanOrEqual(Math.max(0, w - 5) + 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// ambiguousIsNarrow option fuzz
|
||||
// ============================================================================
|
||||
|
||||
describe("sliceAnsi ambiguousIsNarrow fuzz", () => {
|
||||
test("narrow slice ⊆ wide slice visibly (narrow chars are subset)", () => {
|
||||
// With ambiguous-wide, each ambiguous char takes 2 cols → fewer fit in
|
||||
// the same range → narrow result should be a prefix (or equal) of wide... no,
|
||||
// actually the RELATIONSHIP is: same budget, wider chars → fewer chars.
|
||||
// Let's just check that both respect the budget.
|
||||
const rng = makeRng(0xa4b16);
|
||||
for (let i = 0; i < 100; i++) {
|
||||
// Mix ambiguous (Greek) + non-ambiguous (ASCII) + ANSI
|
||||
const pieces = [];
|
||||
const n = 5 + Math.floor(rng() * 30);
|
||||
for (let j = 0; j < n; j++) {
|
||||
const r = rng();
|
||||
if (r < 0.3)
|
||||
pieces.push(String.fromCodePoint(0x03b1 + Math.floor(rng() * 24))); // Greek
|
||||
else if (r < 0.5)
|
||||
pieces.push(String.fromCharCode(0x21 + Math.floor(rng() * 94))); // ASCII
|
||||
else if (r < 0.6)
|
||||
pieces.push(`\x1b[${30 + Math.floor(rng() * 8)}m`); // SGR
|
||||
else pieces.push(String.fromCodePoint(0x0410 + Math.floor(rng() * 32))); // Cyrillic (ambiguous)
|
||||
}
|
||||
const s = pieces.join("") + "\x1b[0m";
|
||||
const budget = Math.floor(rng() * 20) + 1;
|
||||
const narrow = Bun.sliceAnsi(s, 0, budget, { ambiguousIsNarrow: true });
|
||||
const wide = Bun.sliceAnsi(s, 0, budget, { ambiguousIsNarrow: false });
|
||||
expect(Bun.stringWidth(Bun.stripANSI(narrow), { ambiguousIsNarrow: true })).toBeLessThanOrEqual(budget + 1);
|
||||
expect(Bun.stringWidth(Bun.stripANSI(wide), { ambiguousIsNarrow: false })).toBeLessThanOrEqual(budget + 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Encoding equivalence (Latin-1 vs UTF-16 internal representation)
|
||||
// ============================================================================
|
||||
// JSC stores strings as either Latin-1 (8-bit) or UTF-16. sliceAnsi templates
|
||||
// on both. The same visible content in either encoding should slice identically.
|
||||
|
||||
describe("sliceAnsi encoding equivalence", () => {
|
||||
test("ASCII in Latin-1 vs UTF-16 gives identical results", () => {
|
||||
const rng = makeRng(0xe1c0d);
|
||||
for (let i = 0; i < 50; i++) {
|
||||
// Build a string that COULD be Latin-1 (all < 0x100).
|
||||
const latin1 = randomAnsiAscii(rng, 10, 50);
|
||||
// Force to UTF-16 by concatenating then removing a high char.
|
||||
const utf16 = (latin1 + "\u{1F600}").slice(0, -2);
|
||||
// Now latin1 is probably Latin-1, utf16 is definitely UTF-16. Same content.
|
||||
for (const a of [0, 2, 5]) {
|
||||
for (const b of [10, 20, 100]) {
|
||||
expect(Bun.sliceAnsi(utf16, a, b)).toBe(Bun.sliceAnsi(latin1, a, b));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("Latin-1-range non-ASCII in both encodings", () => {
|
||||
// Chars 0x80-0xFF exist in both encodings. 0xA9 (©), 0xE9 (é), etc.
|
||||
const s8 = "\u00A9\u00E9\u00DF\u00F1"; // ©éßñ — likely Latin-1 internally
|
||||
const s16 = (s8 + "\u{1F600}").slice(0, -2); // force UTF-16
|
||||
for (let a = 0; a <= 4; a++) {
|
||||
for (let b = a; b <= 4; b++) {
|
||||
expect(Bun.sliceAnsi(s16, a, b)).toBe(Bun.sliceAnsi(s8, a, b));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Speculative zone (lazy cutEnd) edge cases
|
||||
// ============================================================================
|
||||
// The spec-zone buffer is one of the trickiest parts: content in [end-ew, end)
|
||||
// is tentatively emitted to a side buffer, then either discarded (cut) or
|
||||
// flushed (no cut). Stress the boundaries.
|
||||
|
||||
describe("sliceAnsi speculative zone", () => {
|
||||
test("string width exactly equals budget (no cut, spec zone flushes)", () => {
|
||||
// 5 chars, slice [0, 5) with ellipsis. No cut → spec zone content appended,
|
||||
// ellipsis NOT emitted.
|
||||
const s = "hello";
|
||||
expect(Bun.sliceAnsi(s, 0, 5, "\u2026")).toBe("hello");
|
||||
expect(Bun.sliceAnsi(s, 0, 5, "...")).toBe("hello");
|
||||
// With ANSI (forces slow path but same outcome)
|
||||
const sa = "\x1b[31mhello\x1b[39m";
|
||||
expect(Bun.stripANSI(Bun.sliceAnsi(sa, 0, 5, "\u2026"))).toBe("hello");
|
||||
});
|
||||
|
||||
test("string width exactly one over budget (cut, spec zone discarded)", () => {
|
||||
const s = "hello!";
|
||||
// budget 5, string width 6 → cut. spec zone had 'o' (cols 4-5). Discarded.
|
||||
expect(Bun.sliceAnsi(s, 0, 5, "\u2026")).toBe("hell\u2026");
|
||||
});
|
||||
|
||||
test("wide char straddling spec zone boundary", () => {
|
||||
// budget 5, ellipsis "…" (ew=1). end adjusted to 4, specEnd=5.
|
||||
// Content: "ab安" (a=1, b=1, 安=2). 安 starts at col 2, fits to col 4.
|
||||
// Then "cde" — c starts at col 4 ∈ [end=4, specEnd=5) → spec zone.
|
||||
// d starts at col 5 = specEnd → cut. Output: "ab安" + ellipsis.
|
||||
const s = "ab\u5B89cde";
|
||||
expect(Bun.sliceAnsi(s, 0, 5, "\u2026")).toBe("ab\u5B89\u2026");
|
||||
});
|
||||
|
||||
test("spec zone with ANSI between zone content and next char", () => {
|
||||
// Make sure trailing ANSI in the spec zone ends up in the right place.
|
||||
// budget 5, "abcd[SGR]e[SGR]f". e is at col 4 (spec zone). f at 5 → cut.
|
||||
// Pending ANSI after 'e' should be close-only filtered, not carry forward.
|
||||
const s = "abcd\x1b[31me\x1b[39mf";
|
||||
const out = Bun.sliceAnsi(s, 0, 5, "\u2026");
|
||||
// Expect "abcd…" — spec zone discarded (including its ANSI), ellipsis emitted.
|
||||
expect(Bun.stripANSI(out)).toBe("abcd\u2026");
|
||||
expect(Bun.stringWidth(Bun.stripANSI(out))).toBe(5);
|
||||
});
|
||||
|
||||
test("SGR opening into spec zone → wraps ellipsis (style inheritance)", () => {
|
||||
// 'd' at col 3, [SGR] pending, 'e' at col 4 (spec zone), 'f' at col 5 → cut.
|
||||
// [SGR 31] was pending before 'e' → at 'e' (break in zone) flushed to result.
|
||||
// Then 'f' cuts, spec zone discarded. Ellipsis emitted inside the [31m.
|
||||
// This is correct style inheritance: the ellipsis replaces content that
|
||||
// WOULD have been red, so it inherits red.
|
||||
const s = "abcd\x1b[31mef\x1b[39m";
|
||||
const out = Bun.sliceAnsi(s, 0, 5, "\u2026");
|
||||
expect(out).toBe("abcd\x1b[31m\u2026\x1b[39m");
|
||||
expect(Bun.stringWidth(out)).toBe(5);
|
||||
});
|
||||
|
||||
test("SGR opening AFTER spec zone content → discarded with zone (no leak)", () => {
|
||||
// 'e' at col 4 (spec zone), THEN [SGR 31] pending, THEN 'f' at col 5 → cut.
|
||||
// [SGR] is pending when 'f' triggers cut → close-only filter → [31m is
|
||||
// NOT a close → dropped. No red leak into output.
|
||||
const s = "abcde\x1b[31mf\x1b[39m";
|
||||
const out = Bun.sliceAnsi(s, 0, 5, "\u2026");
|
||||
// Clean: no SGR at all (31m was dropped, nothing active to close).
|
||||
expect(out).toBe("abcd\u2026");
|
||||
expect(Bun.stringWidth(out)).toBe(5);
|
||||
});
|
||||
|
||||
test("spec zone NOT cut (EOF before overflow) → zone flushed, no ellipsis", () => {
|
||||
// budget 5, string is exactly "abcde" (width 5). end adjusted to 4,
|
||||
// specEnd=5. 'e' at col 4 goes to spec zone. EOF reached — no cut.
|
||||
// Zone flushed to result, ellipsis cancelled.
|
||||
const s = "abcde";
|
||||
expect(Bun.sliceAnsi(s, 0, 5, "\u2026")).toBe("abcde");
|
||||
// Same with ANSI (slow path).
|
||||
const sa = "\x1b[31mabcde\x1b[39m";
|
||||
const out = Bun.sliceAnsi(sa, 0, 5, "\u2026");
|
||||
expect(Bun.stripANSI(out)).toBe("abcde");
|
||||
});
|
||||
|
||||
test("spec zone fuzz: lazy cutEnd never produces invalid width", () => {
|
||||
// Property: for random strings with ellipsis and non-negative indices,
|
||||
// width of output is ALWAYS ≤ budget + 1 (atomic wide cluster). The lazy
|
||||
// cutEnd path must never leak spec-zone content into the result.
|
||||
const rng = makeRng(0x5bec);
|
||||
for (let i = 0; i < 300; i++) {
|
||||
const s = randomAnsiAscii(rng, 5, 80);
|
||||
const n = 3 + Math.floor(rng() * 30);
|
||||
const e = rng() < 0.5 ? "\u2026" : "...";
|
||||
const out = Bun.sliceAnsi(s, 0, n, e);
|
||||
const ow = Bun.stringWidth(Bun.stripANSI(out));
|
||||
expect(ow).toBeLessThanOrEqual(n + 1);
|
||||
// And the output is well-formed ANSI (stripANSI doesn't throw).
|
||||
expect(typeof Bun.stripANSI(out)).toBe("string");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Exception safety — option getters that throw
|
||||
// ============================================================================
|
||||
|
||||
describe("sliceAnsi exception safety", () => {
|
||||
test("throwing ellipsis getter doesn't corrupt state", () => {
|
||||
const s = "hello world";
|
||||
// First call throws, second should work normally.
|
||||
expect(() =>
|
||||
Bun.sliceAnsi(s, 0, 5, {
|
||||
get ellipsis() {
|
||||
throw new Error("boom");
|
||||
},
|
||||
}),
|
||||
).toThrow("boom");
|
||||
expect(Bun.sliceAnsi(s, 0, 5)).toBe("hello");
|
||||
});
|
||||
|
||||
test("throwing ambiguousIsNarrow getter doesn't corrupt state", () => {
|
||||
const s = "hello";
|
||||
expect(() =>
|
||||
Bun.sliceAnsi(s, 0, 3, {
|
||||
get ambiguousIsNarrow() {
|
||||
throw new Error("boom");
|
||||
},
|
||||
}),
|
||||
).toThrow("boom");
|
||||
expect(Bun.sliceAnsi(s, 0, 3)).toBe("hel");
|
||||
});
|
||||
|
||||
test("non-primitive coercion in indices", () => {
|
||||
const s = "abcdef";
|
||||
let calls = 0;
|
||||
const obj = {
|
||||
valueOf() {
|
||||
calls++;
|
||||
return 2;
|
||||
},
|
||||
};
|
||||
// @ts-expect-error testing coercion
|
||||
expect(Bun.sliceAnsi(s, obj, 5)).toBe("cde");
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Bulk-ASCII boundary stress (leave-one-behind logic)
|
||||
// ============================================================================
|
||||
// The bulk-emit processes asciiLen-1 chars, leaving the last for per-char
|
||||
// seeding. Stress the boundary between bulk and per-char processing.
|
||||
|
||||
describe("sliceAnsi bulk-ASCII boundary", () => {
|
||||
test("ASCII run ending at slice boundary (bulk processes N-1)", () => {
|
||||
// 10 ASCII chars, slice [0, 10). bulkN=9, last 'j' goes through per-char.
|
||||
// Then emoji (non-ASCII) follows — breaks on 'j', advances position, cuts.
|
||||
const s = "abcdefghij\u{1F600}";
|
||||
expect(Bun.stripANSI(Bun.sliceAnsi(s, 0, 10))).toBe("abcdefghij");
|
||||
// emoji starts at col 10, width 2. [0, 11): col 10 < 11 → emitted atomically.
|
||||
expect(Bun.stripANSI(Bun.sliceAnsi(s, 0, 11))).toBe("abcdefghij\u{1F600}");
|
||||
// [0, 10): emoji starts at col 10 = end → NOT emitted.
|
||||
expect(Bun.stripANSI(Bun.sliceAnsi(s, 0, 10))).toBe("abcdefghij");
|
||||
expect(Bun.stripANSI(Bun.sliceAnsi(s, 0, 12))).toBe("abcdefghij\u{1F600}");
|
||||
});
|
||||
|
||||
test("single ASCII char (bulkN=0, all goes to per-char)", () => {
|
||||
// asciiLen=1 → bulkN=0 → no bulk processing. Covers the edge case.
|
||||
const s = "\x1b[31ma\x1b[39m\u{1F600}";
|
||||
expect(Bun.stripANSI(Bun.sliceAnsi(s, 0, 1))).toBe("a");
|
||||
expect(Bun.stripANSI(Bun.sliceAnsi(s, 0, 3))).toBe("a\u{1F600}");
|
||||
});
|
||||
|
||||
test("ASCII + combining mark at the leave-one-behind position", () => {
|
||||
// "abcde\u0301" — combining acute attaches to 'e'. bulkN=4 (leave 'e').
|
||||
// Per-char processes 'e' (seeds gs), then \u0301 joins → cluster "é" width 1.
|
||||
const s = "abcde\u0301";
|
||||
expect(Bun.stringWidth(s)).toBe(5);
|
||||
expect(Bun.sliceAnsi(s, 0, 5)).toBe("abcde\u0301");
|
||||
expect(Bun.sliceAnsi(s, 4, 5)).toBe("e\u0301");
|
||||
});
|
||||
|
||||
test("many short ASCII runs between ANSI (bulk rarely engages)", () => {
|
||||
// Alternate 2 ASCII chars + SGR. bulkN=1 each time, barely engages.
|
||||
let s = "";
|
||||
for (let i = 0; i < 50; i++) s += "ab\x1b[3" + (i % 8) + "m";
|
||||
s += "\x1b[0m";
|
||||
// 100 visible chars. Slice [25, 75).
|
||||
expect(Bun.stripANSI(Bun.sliceAnsi(s, 25, 75)).length).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Now that stringWidth is fixed, check the direct invariant
|
||||
// ============================================================================
|
||||
|
||||
describe("sliceAnsi direct stringWidth invariant (post-fix)", () => {
|
||||
test("stringWidth(slice) ≤ budget + 1 without stripANSI workaround", () => {
|
||||
// Before the stringWidth fix, we used visibleWidth (stripANSI first).
|
||||
// Now stringWidth correctly preserves grapheme state across ANSI, so
|
||||
// we can test the direct invariant. Keep +1 for wide-at-boundary.
|
||||
//
|
||||
// KNOWN LIMITATION: stringWidth doesn't recognize C1 (8-bit) escape
|
||||
// sequences (0x9B CSI, 0x9D OSC, 0x90 DCS, etc.) — only 7-bit (ESC[).
|
||||
// sliceAnsi DOES handle C1. So inputs with C1 sequences will show
|
||||
// stringWidth > sliceAnsi's internal width. We exclude C1 from this
|
||||
// test's generator; C1 coverage is in the adversarial tests above.
|
||||
const rng = makeRng(0xd1ec7);
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const s = randomStringNoC1(rng, 0, 100);
|
||||
const w = Bun.stringWidth(s);
|
||||
const a = Math.floor(rng() * (w + 3));
|
||||
const b = a + Math.floor(rng() * (w + 3));
|
||||
const out = Bun.sliceAnsi(s, a, b);
|
||||
// stringWidth directly — no stripANSI. If this fails but visibleWidth
|
||||
// passes, there's a NEW stringWidth/sliceAnsi inconsistency.
|
||||
expect(Bun.stringWidth(out)).toBeLessThanOrEqual(Math.max(0, b - a) + 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Like randomString but excludes C1 control bytes (0x80-0x9F). Used for tests
|
||||
// that compare directly against Bun.stringWidth, which doesn't recognize C1
|
||||
// escape sequences (only 7-bit ESC-based).
|
||||
function randomStringNoC1(rng: () => number, minLen: number, maxLen: number): string {
|
||||
const len = minLen + Math.floor(rng() * (maxLen - minLen + 1));
|
||||
const pieces: string[] = [];
|
||||
for (let i = 0; i < len; ) {
|
||||
const r = rng();
|
||||
if (r < 0.4) {
|
||||
pieces.push(String.fromCharCode(0x20 + Math.floor(rng() * 95)));
|
||||
i++;
|
||||
} else if (r < 0.55) {
|
||||
pieces.push(`\x1b[${Math.floor(rng() * 108)}m`);
|
||||
} else if (r < 0.65) {
|
||||
pieces.push(String.fromCodePoint(0x4e00 + Math.floor(rng() * 0x5000)));
|
||||
i += 2;
|
||||
} else if (r < 0.72) {
|
||||
pieces.push(String.fromCodePoint(0x1f600 + Math.floor(rng() * 50)));
|
||||
i += 2;
|
||||
} else if (r < 0.78) {
|
||||
pieces.push(String.fromCodePoint(0x0300 + Math.floor(rng() * 0x70)));
|
||||
} else if (r < 0.82) {
|
||||
pieces.push("\u200D");
|
||||
} else if (r < 0.86) {
|
||||
pieces.push(rng() < 0.5 ? "\uFE0E" : "\uFE0F");
|
||||
} else if (r < 0.9) {
|
||||
pieces.push(`\x1b]8;;http://e.x/${Math.floor(rng() * 1000)}\x07`);
|
||||
} else if (r < 0.95) {
|
||||
pieces.push(String.fromCharCode(Math.floor(rng() * 0x20)));
|
||||
} // C0 only (no C1)
|
||||
else {
|
||||
pieces.push(`\x1b[38;2;${Math.floor(rng() * 256)};${Math.floor(rng() * 256)};${Math.floor(rng() * 256)}m`);
|
||||
}
|
||||
}
|
||||
return pieces.join("");
|
||||
}
|
||||
1274
test/js/bun/util/sliceAnsi.test.ts
Normal file
1274
test/js/bun/util/sliceAnsi.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -819,4 +819,56 @@ describe("stringWidth extended", () => {
|
||||
expect(Bun.stringWidth("क्क्क")).toBe(3); // 1+0+1+0+1
|
||||
});
|
||||
});
|
||||
|
||||
// ANSI escape sequences should NOT affect grapheme cluster state. Previously,
|
||||
// the CSI final byte (e.g. 'm') was tracked as the "previous codepoint" for
|
||||
// graphemeBreak, so a zero-width joiner/extender immediately after an SGR
|
||||
// code would wrongly attach to the 'm' instead of the last visible char.
|
||||
// Found by sliceAnsi fuzz testing.
|
||||
describe("ANSI sequences preserve grapheme state", () => {
|
||||
test("VS16 after SGR code has width 0 (not 1)", () => {
|
||||
// VS16 (U+FE0F) is a zero-width variation selector. It should contribute
|
||||
// width 0 regardless of whether an ANSI code precedes it.
|
||||
expect(Bun.stringWidth("\uFE0F?")).toBe(1); // baseline: no ANSI
|
||||
expect(Bun.stringWidth("\x1b[1m\uFE0F?")).toBe(1); // SGR before: same
|
||||
expect(Bun.stringWidth("\x1b[31m\uFE0F\x1b[39m?")).toBe(1); // SGR both sides
|
||||
});
|
||||
|
||||
test("combining mark after SGR attaches to previous visible char, not 'm'", () => {
|
||||
// 'e' + SGR + U+0301 (combining acute) should form one cluster "é" (width 1).
|
||||
// Previously the CSI 'm' byte was the graphemeBreak prev → combining mark
|
||||
// attached to 'm' → 'e' finalized alone (width 1) + new cluster (width 0)
|
||||
// → total 1. This happens to be correct by accident, but let's lock it in.
|
||||
expect(Bun.stringWidth("e\u0301")).toBe(1); // baseline
|
||||
expect(Bun.stringWidth("e\x1b[1m\u0301")).toBe(1); // SGR between base and mark
|
||||
});
|
||||
|
||||
test("ZWJ after SGR doesn't break emoji cluster", () => {
|
||||
// 👩 + SGR + ZWJ + SGR + 💻 should still be one width-2 cluster.
|
||||
expect(Bun.stringWidth("\u{1F469}\u200D\u{1F4BB}")).toBe(2); // baseline
|
||||
expect(Bun.stringWidth("\u{1F469}\x1b[1m\u200D\x1b[22m\u{1F4BB}")).toBe(2);
|
||||
});
|
||||
|
||||
test("ANSI + VS16 + ZWJ (orphaned joiners at start) has width 0", () => {
|
||||
// Orphaned VS16 + ZWJ at string start, with ANSI before/between.
|
||||
// Both are zero-width; no visible chars → width 0.
|
||||
expect(Bun.stringWidth("\uFE0F\u200D")).toBe(0); // baseline
|
||||
expect(Bun.stringWidth("\x1b[1m\uFE0F\u200D")).toBe(0);
|
||||
expect(Bun.stringWidth("\x1b[1m\uFE0F\x1b[31m\u200D")).toBe(0);
|
||||
});
|
||||
|
||||
test("consistency: stringWidth(s) == stringWidth(stripANSI(s))", () => {
|
||||
// The fundamental invariant: ANSI codes should be transparent to width.
|
||||
const cases = [
|
||||
"\x1b[1m\uFE0F?",
|
||||
"\x1b[31me\x1b[39m\u0301",
|
||||
"\x1b[1m\u{1F469}\x1b[22m\u200D\u{1F4BB}",
|
||||
"\x1b[38;2;255;0;0m\u5B89\u5B81\x1b[39m",
|
||||
"\x1b[4m\u{1F1FA}\x1b[24m\u{1F1F8}", // regional indicator pair split by SGR
|
||||
];
|
||||
for (const s of cases) {
|
||||
expect(Bun.stringWidth(s)).toBe(Bun.stringWidth(Bun.stripANSI(s)));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
106
test/js/node/buffer-compare-bounds.test.ts
Normal file
106
test/js/node/buffer-compare-bounds.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
describe("Buffer.compare bounds validation", () => {
|
||||
// Ensure out-of-range end offsets throw ERR_OUT_OF_RANGE, matching Node.js behavior
|
||||
test("targetEnd exceeding target length throws ERR_OUT_OF_RANGE", () => {
|
||||
const a = Buffer.alloc(10, 0x61);
|
||||
const b = Buffer.alloc(10, 0x62);
|
||||
expect(() => a.compare(b, 0, 100)).toThrow();
|
||||
});
|
||||
|
||||
test("sourceEnd exceeding source length throws ERR_OUT_OF_RANGE", () => {
|
||||
const a = Buffer.alloc(10, 0x61);
|
||||
const b = Buffer.alloc(10, 0x62);
|
||||
expect(() => a.compare(b, 0, 10, 0, 100)).toThrow();
|
||||
});
|
||||
|
||||
// When start > end (inverted/zero-length range), Node.js returns early without
|
||||
// checking start against buffer length. This matches Node.js semantics.
|
||||
test("targetStart exceeding target length with default targetEnd returns 1 (zero-length target)", () => {
|
||||
const a = Buffer.alloc(10, 0x61);
|
||||
const b = Buffer.alloc(10, 0x62);
|
||||
// targetStart=100, targetEnd=10 (default), targetStart >= targetEnd → return 1
|
||||
expect(a.compare(b, 100)).toBe(1);
|
||||
});
|
||||
|
||||
test("sourceStart exceeding source length with default sourceEnd returns -1 (zero-length source)", () => {
|
||||
const a = Buffer.alloc(10, 0x61);
|
||||
const b = Buffer.alloc(10, 0x62);
|
||||
// sourceStart=100, sourceEnd=10 (default), sourceStart >= sourceEnd → return -1
|
||||
expect(a.compare(b, 0, 10, 100)).toBe(-1);
|
||||
});
|
||||
|
||||
// Inverted ranges where both start and end exceed buffer length must throw
|
||||
// because end is validated against buffer length BEFORE the start>=end early return
|
||||
test("inverted target range with both values out of bounds throws (targetEnd > buffer length)", () => {
|
||||
const a = Buffer.alloc(10, 0x61);
|
||||
const b = Buffer.alloc(10, 0x62);
|
||||
// targetStart=100, targetEnd=50 — targetEnd(50) > b.length(10) → throws
|
||||
expect(() => a.compare(b, 100, 50)).toThrow();
|
||||
});
|
||||
|
||||
test("inverted source range with both values out of bounds throws (sourceEnd > buffer length)", () => {
|
||||
const a = Buffer.alloc(10, 0x61);
|
||||
const b = Buffer.alloc(10, 0x62);
|
||||
// sourceStart=100, sourceEnd=50 — sourceEnd(50) > a.length(10) → throws
|
||||
expect(() => a.compare(b, 0, 10, 100, 50)).toThrow();
|
||||
});
|
||||
|
||||
// Mixed: one side OOB end, should throw
|
||||
test("mixed OOB: targetEnd and sourceEnd both exceed buffer lengths throws", () => {
|
||||
const small = Buffer.alloc(10, 0x41);
|
||||
const oracle = Buffer.alloc(10, 0x42);
|
||||
// targetEnd=50 > oracle.length(10) → throws before anything else
|
||||
expect(() => small.compare(oracle, 100, 50, 0, 40)).toThrow();
|
||||
});
|
||||
|
||||
// After the fix, OOB sourceEnd is caught even when sourceStart < sourceEnd
|
||||
test("sourceEnd past buffer with valid sourceStart throws", () => {
|
||||
const a = Buffer.alloc(10, 0x61);
|
||||
const b = Buffer.alloc(10, 0x62);
|
||||
// sourceStart=0, sourceEnd=40 > a.length(10) → throws
|
||||
expect(() => a.compare(b, 0, 10, 0, 40)).toThrow();
|
||||
});
|
||||
|
||||
// Verify that valid ranges still work correctly
|
||||
test("valid sub-range comparison works", () => {
|
||||
const a = Buffer.from([1, 2, 3, 4, 5]);
|
||||
const b = Buffer.from([3, 4, 5, 6, 7]);
|
||||
// Compare a[2..5] vs b[0..3] -> [3,4,5] vs [3,4,5] -> 0
|
||||
expect(a.compare(b, 0, 3, 2)).toBe(0);
|
||||
});
|
||||
|
||||
test("zero-length ranges return correct values", () => {
|
||||
const a = Buffer.alloc(10, 0x61);
|
||||
const b = Buffer.alloc(10, 0x62);
|
||||
// sourceStart == sourceEnd -> zero-length source, non-zero target -> -1
|
||||
expect(a.compare(b, 0, 5, 3, 3)).toBe(-1);
|
||||
// targetStart == targetEnd -> zero-length target, non-zero source -> 1
|
||||
expect(a.compare(b, 3, 3, 0, 5)).toBe(1);
|
||||
// Both zero-length -> 0
|
||||
expect(a.compare(b, 3, 3, 3, 3)).toBe(0);
|
||||
});
|
||||
|
||||
test("start equal to buffer length with matching end is zero-length", () => {
|
||||
const a = Buffer.alloc(10, 0x61);
|
||||
const b = Buffer.alloc(10, 0x62);
|
||||
// targetStart=10, targetEnd=10 -> zero-length target -> 1
|
||||
expect(a.compare(b, 10, 10, 0, 5)).toBe(1);
|
||||
// sourceStart=10, sourceEnd=10 -> zero-length source -> -1
|
||||
expect(a.compare(b, 0, 5, 10, 10)).toBe(-1);
|
||||
});
|
||||
|
||||
test("end values at exact buffer length are valid", () => {
|
||||
const a = Buffer.alloc(10, 0x61);
|
||||
const b = Buffer.alloc(10, 0x62);
|
||||
// targetEnd=10 (== b.length) and sourceEnd=10 (== a.length) should be fine
|
||||
expect(a.compare(b, 0, 10, 0, 10)).toBe(-1);
|
||||
});
|
||||
|
||||
test("end values one past buffer length throw", () => {
|
||||
const a = Buffer.alloc(10, 0x61);
|
||||
const b = Buffer.alloc(10, 0x62);
|
||||
expect(() => a.compare(b, 0, 11, 0, 10)).toThrow();
|
||||
expect(() => a.compare(b, 0, 10, 0, 11)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { bunEnv, bunExe, tmpdirSync } from "harness";
|
||||
import { bunEnv, bunExe, isASAN, tmpdirSync } from "harness";
|
||||
import { join } from "node:path";
|
||||
import tls from "node:tls";
|
||||
|
||||
@@ -263,7 +263,7 @@ describe.concurrent("fetch-tls", () => {
|
||||
});
|
||||
const start = performance.now();
|
||||
const TIMEOUT = 200;
|
||||
const THRESHOLD = 150;
|
||||
const THRESHOLD = 150 * (isASAN ? 2 : 1); // ASAN can be very slow, so we need to increase the threshold for it
|
||||
|
||||
try {
|
||||
await fetch(server.url, {
|
||||
|
||||
126
test/js/web/websocket/test-ws-bidir-proxy.test.ts
Normal file
126
test/js/web/websocket/test-ws-bidir-proxy.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
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);
|
||||
@@ -195,7 +195,7 @@ test/js/node/test/parallel/test-http-server-stale-close.js
|
||||
test/js/third_party/comlink/comlink.test.ts
|
||||
test/regression/issue/22635/22635.test.ts
|
||||
test/js/node/test/parallel/test-http-url.parse-https.request.js
|
||||
|
||||
test/bundler/bundler_compile_autoload.test.ts
|
||||
|
||||
# Bun::JSNodeHTTPServerSocket::clearSocketData
|
||||
test/js/node/test/parallel/test-http-server-keep-alive-max-requests-null.js
|
||||
|
||||
Reference in New Issue
Block a user