Compare commits

...

12 Commits

Author SHA1 Message Date
Sosuke Suzuki
28722f6f1b fix: Proxy-wrapped arrays causing null deref crashes in isArray+jsCast paths
JSC::isArray() follows the ECMA-262 IsArray abstract operation, which
returns true for Proxy objects wrapping arrays. However, jsCast<JSArray*>
and jsDynamicCast<JSArray*> both fail on Proxy (assertion/nullptr), so
any code doing `if (isArray(x)) { jsCast<JSArray*>(x)->... }` would crash.

Fixes:
- Buffer.concat(new Proxy([], {})) -> SEGV at 0x4 (now iterates via get())
- process.setgroups(new Proxy([], {})) -> SEGV at 0x4 (now throws TypeError)
- vm.compileFunction("", new Proxy([], {})) -> debug assertion (now throws)
- vm.compileFunction contextExtensions with Proxy -> debug assertion (now throws)
- new Bun.CookieMap(new Proxy([], {})) -> debug assertion (now falls through to record path)
- expect(proxy).toEqual(expect.arrayContaining([...])) -> UBSan null deref (now FAIL)
- jsHTTPSetHeader with Proxy value -> debug assertion (now falls through to single-value path)

Buffer.concat is the only one that supports Proxy iteration (Node.js compat).
Others reject Proxy with TypeError since Node.js also rejects/crashes on them.

Also replaces getIndexQuickly with getIndex + exception check in NodeVM.cpp
to handle sparse arrays safely.
2026-02-28 02:21:46 +09:00
robobun
6e317c861f fix(http): harden request header buffer bounds in buildRequest (#27501)
## Summary

- Cap the number of user-supplied headers written into the fixed-size
`shared_request_headers_buf` (256 entries) in `buildRequest` to prevent
out-of-bounds writes when a `fetch()` call includes more headers than
the buffer can hold.
- Six slots are reserved for default headers (Connection, User-Agent,
Accept, Host, Accept-Encoding, Content-Length/Transfer-Encoding),
leaving 250 for user-supplied headers. Excess headers are silently
dropped while their semantic flags (e.g. `override_host_header`) are
still processed correctly.
- Added tests verifying that fetch with 300 headers completes without
crashing and that 250 custom headers are all delivered correctly.

## Test plan

- [x] `bun bd test test/js/bun/http/fetch-header-count-limit.test.ts`
passes (2 tests)
- [ ] CI passes on all platforms

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-26 23:38:40 -08:00
robobun
5c9172cf34 feat: implement Bun.sliceAnsi for ANSI & grapheme-aware string slicing (#26963)
## `Bun.sliceAnsi(str, start?, end?, options?)`

Replaces both `slice-ansi` and `cli-truncate` npm packages. Slices
strings by terminal column width while preserving ANSI escape codes (SGR
colors, OSC 8 hyperlinks) and respecting grapheme cluster boundaries
(emoji, combining marks, flags).

```ts
// Plain slice (slice-ansi replacement)
Bun.sliceAnsi(line, from, to)
Bun.sliceAnsi("\x1b[31mhello\x1b[39m", 1, 4)  // "\x1b[31mell\x1b[39m"

// Truncation with ellipsis (cli-truncate replacement)
Bun.sliceAnsi("unicorn", 0, 4, "…")              // "uni…"
Bun.sliceAnsi("unicorn", -4, undefined, "…")     // "…orn"
```

The ellipsis is emitted **inside** active SGR styles (inherits
color/bold) but **outside** hyperlinks. Also supports `{
ambiguousIsNarrow }` matching `stringWidth`/`wrapAnsi`.

---

## Design

### Three-tier dispatch

| Tier | Input | Passes | Allocation |
|------|-------|--------|------------|
| **SIMD ASCII** | All code units ∈ `[0x20, 0x7E]`, or slice range
strictly inside the ASCII prefix | 1 SIMD scan | Zero-copy when nothing
cut |
| **Single-pass streaming** | Non-negative indices (99% of calls) |
**1** input walk | Stack only |
| **Negative indices** | `start < 0` or `end < 0` | 2 (width + emit) |
Stack only |

### Single-pass streaming emit (the hot path)

- `position` advances **only at cluster boundaries** (when
`graphemeBreak` says a new cluster starts) — always correct at decision
points, no correction needed.
- Inline grapheme tracking — no Vector, no pre-pass.
- **SIMD skip-ahead**: `findEscapeCharacter` finds the next escape byte,
then **bulk-emit** the ASCII-printable sub-run in one `append` (process
`asciiLen - 1` chars, leave the last for per-char to seed grapheme
state).
- **One tiny lookahead**: 4-entry inline buffer for ANSI between
consecutive visible chars. Flushed when the next char's break status is
known (continuation → flush all; break past-end → filter close-only).
- **Lazy `cutEnd` for ellipsis**: speculative zone `[end - ew, end)` →
side buffer. Cut detected → discard zone, emit ellipsis. EOF first →
flush zone, cancel ellipsis.

### Debug build bench (200k iters)

| Path | ns/op |
|------|------:|
| ASCII SIMD fast path | ~2,300 |
| ASCII no-op (zero-copy) | ~2,100 |
| ANSI + bulk-ASCII emit | ~17,500 |
| CJK (per-char width-2) | ~9,500 |
| ZWJ emoji (clustering) | ~19,100 |
| Negative index (2-pass) | ~128,000 |

---

## Correctness & hardening

### Fixes found by fuzzing

- **DoS**: unterminated DCS/SOS/PM/APC (`\x90`, `\x98`, `\x9E`, `\x9F`
or ESC variants) previously consumed to EOF. A single `\x90` byte would
swallow the entire string. Now treated as a standalone width-0 control
char — matches `Bun.stringWidth`.
- **`Bun.stringWidth` grapheme bug**: `prev` was updated for ALL bytes
including ANSI. After `\x1b[1m`, `prev='m'`; a following
VS16/ZWJ/combining mark would `graphemeBreak('m', FE0F) = false` →
`add()` on uninitialized state → width 1 instead of 0. Fixed with
separate `prev_visible`.

### Bounds & overflow

- Index resolution matches JSC's `stringSlice<double>`: clamp in double
space, cast to `size_t` only after `[0, totalW]` verified. No int64, no
UB.
- `SgrParams` is a fixed 32-entry stack struct (ECMA-48 caps at 16,
xterm ~30). Overflow → opaque passthrough. Param accumulator clamped at
100,000.
- Ellipsis passed as zero-copy `StringView` — never materialized.

---

## Shared helpers (`ANSIHelpers.h`)

- `firstNonAsciiPrintable<Lane>(span) → index` — SIMD range check via
wrapping sub + unsigned compare, templated for Latin-1/UTF-16
- `sgrCloseCode` / `isSgrEndCode` — dense jump-table switch
- `decodeUTF16` — thin wrapper over ICU's `U16_NEXT` (wrapAnsi now also
uses this)
- `findEscapeCharacter` now matches `0x9C` (C1 ST) for SIMD/tail
consistency

---

## Tests

**347 tests, 5,647 assertions** across 4 files:

- `sliceAnsi.test.ts` — 152 unit tests (upstream slice-ansi parity, OSC
8 hyperlinks, grapheme edge cases, ellipsis style inheritance,
`ambiguousIsNarrow`)
- `sliceAnsi-fuzz.test.ts` — 48 property/adversarial tests (seeded PRNG,
SIMD stride boundaries, unterminated sequences, spec-zone ordering,
encoding equivalence, exception safety, negative-index consistency, 300+
property checks per invariant)
- `stringWidth.test.ts` — +5 for the ANSI-grapheme invariant
`stringWidth(s) == stringWidth(stripANSI(s))`
- `wrapAnsi.test.ts` — 32 regression (unchanged)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-02-26 23:36:42 -08:00
robobun
c099ad3fff harden Buffer.compare offset bounds validation (#27496)
## Summary
- Tighten bounds checks in `Buffer.prototype.compare` to properly
validate `targetEnd` and `sourceEnd` against their respective buffer
lengths unconditionally, matching Node.js semantics
- Previously certain combinations of start/end offset values could
bypass the range validation due to conjunctive check conditions; now end
values are checked first, then zero-length ranges return early, then
start values are validated
- Add comprehensive test coverage for Buffer.compare bounds edge cases

## Test plan
- [x] `bun bd test test/js/node/buffer-compare-bounds.test.ts` — 13/13
pass
- [x] `bun bd test/js/node/test/parallel/test-buffer-compare-offset.js`
— passes cleanly
- [x] `bun bd test/js/node/test/parallel/test-buffer-compare.js` —
passes cleanly
- [x] `bun bd test test/js/node/buffer.test.js` — 457/457 pass
- [x] `USE_SYSTEM_BUN=1 bun test
test/js/node/buffer-compare-bounds.test.ts` — 2 failures confirm tests
catch the issue

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-26 23:24:15 -08:00
Jarred Sumner
6450e91aa4 Bump 2026-02-26 23:02:43 -08:00
Jarred Sumner
57a36224f3 Deflake test/js/web/fetch/fetch.tls.test.ts 2026-02-26 23:02:14 -08:00
Jarred Sumner
49937251ba fix(resolver): prevent buffer overflow on very long import paths (#27492)
## Summary
- `_joinAbsStringBuf` used a fixed `MAX_PATH_BYTES * 2` stack buffer for
its scratch space; joining a long user-provided import specifier with
tsconfig `baseUrl` / `node_modules` / source dir would overflow it and
panic in `normalizeStringGenericTZ`.
- Replace the fixed buffer with a `JoinScratch` stack-fallback allocator
sized to the input (zero-alloc for normal paths, heap-alloc only for
pathological inputs).
- Add `absBufChecked` / `joinAbsStringBufChecked` that return `null`
when the normalized result would still exceed the destination buffer,
and switch resolver call sites that handle user-controlled specifiers to
use it (treating overflow as not-found).
- Guard `ESModule.Package.parseSubpath`, tsconfig `paths` wildcard
concat, and the VM cache-bust join against overflow.

## Test plan
- [ ] `bun bd test test/js/bun/resolve/resolve-error.test.ts` — new test
exercises bare packages via tsconfig baseUrl, tsconfig `paths`
wildcards, relative paths, and long paths with `..` normalization, none
of which should crash
- [ ] CI green on all platforms (Windows path joining also changed)

🤖 Generated with [Claude Code](https://claude.com/claude-code) (0%
31-shotted by claude)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 23:00:32 -08:00
robobun
06858cbef0 fix(http): harden chunked encoding hex digit parsing (#27497)
## Summary

- Tightens hex digit validation in the chunked Transfer-Encoding parser
(`ChunkedEncoding.h`) to strictly accept only RFC 9110 HEXDIG characters
(`0-9`, `a-f`, `A-F`)
- The previous implementation used arithmetic range checks that were
slightly too permissive, accepting certain non-HEXDIG ASCII characters
(e.g. those between `'9'` and `'A'` or after `'F'`/`'f'` in the ASCII
table)
- This aligns Bun's chunked parser with other strict HTTP
implementations (nginx, Apache, HAProxy, Node.js) and ensures consistent
chunk size interpretation across all servers in a proxy chain

## Test plan

- [x] Added 30 new tests in `test/js/bun/http/request-smuggling.test.ts`
covering:
  - Valid hex digits (`0-9`, `a-f`, `A-F`) are accepted
  - Multi-digit hex chunk sizes work correctly
  - Invalid characters `G`, `g`, `Z`, `z`, `x`, `X` are rejected
- ASCII 58-64 characters (`:`, `<`, `=`, `>`, `?`, `@`) between '9' and
'A' are rejected
- Other special characters (`!`, `#`, `$`, `%`, `^`, `&`, `*`, etc.) are
rejected
- [x] Verified new tests fail on system bun (before fix) and pass on
debug build (after fix)
- [x] All 44 tests in `request-smuggling.test.ts` pass (14 existing + 30
new)
- [x] No regressions in existing chunked encoding tests

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
2026-02-26 22:55:43 -08:00
robobun
492e0f533c fix(http): harden header parsing for pipelined requests with empty headers (#27494)
## Summary

- Write a null terminator to the headers array when the empty-headers
early return path is taken in `getHeaders()` (`HttpParser.h:723`). This
matches the existing behavior on the normal parsing path (line 796) and
ensures headers are properly isolated between pipelined requests.
- Without this, when a pipelined request has no headers (request line
immediately followed by `\r\n\r\n`), stale header `string_view`s from
the previous request on the same connection could remain in the reused
`HttpRequest` object's headers array.

## Test plan

- [x] Added 3 new pipelined request header isolation tests to
`test/js/bun/http/request-smuggling.test.ts`
- [x] Verified new test (`pipelined headerless request is rejected and
does not inherit stale content-length`) **fails without the fix** and
**passes with the fix**
- [x] All 17 tests in the request-smuggling test file pass
- [x] `bun bd test test/js/bun/http/request-smuggling.test.ts` — all
pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-26 22:31:00 -08:00
Jarred Sumner
1524632cbb fIx some CI things 2026-02-26 21:53:53 -08:00
Dylan Conway
5b8b72522c ci: move Windows code signing to dedicated x64 step (#27451)
## What does this PR do?

Moves Windows code signing from an inline CMake `POST_BUILD` step to a
dedicated Buildkite step (`windows-sign`) that runs on an x64 agent
after all Windows builds complete.

### Why

DigiCert `smctl` is x64-only and silently fails under ARM64 emulation.
With the old inline approach, ARM64 builds were never signed (`ci.mjs`
skipped it with `target.arch !== "aarch64"`). Now that we're shipping
Windows ARM64, we need all Windows binaries signed.

### How it works

```
windows-x64-build-bun          ─┐
windows-x64-baseline-build-bun  ├─→ windows-sign (x64 agent) ─→ release
windows-aarch64-build-bun      ─┘
```

The `windows-sign` step:
1. Downloads all 6 Windows zips (x64, x64-baseline, aarch64 × {release,
profile})
2. Extracts each, signs the exe with smctl, re-packs
3. Re-uploads with the **same filenames**
4. `upload-release.sh` pins Windows artifact downloads to `--step
windows-sign` to guarantee signed zips are released

### When signing runs

- On `main` with non-canary builds (normal release path)
- When `[sign windows]` is in the commit message (for testing on a
branch — **this PR uses it**)

Canary builds are never signed (DigiCert charges per signature).

### Cleanup

- Removed `ENABLE_WINDOWS_CODESIGNING` CMake option
- Removed inline `POST_BUILD` signing from `BuildBun.cmake`
- Removed SM_* secret fetching from `scripts/build.mjs`
- Replaced `sign-windows.ps1` (2-exe signer) with
`sign-windows-artifacts.ps1` (batch zip signer)

### Testing

The commit message contains `[sign windows]` so this PR's CI should run
the sign step. Will verify:
- All 6 zips are downloaded, signed, re-uploaded
- `Get-AuthenticodeSignature` verification passes for each exe
- smctl healthcheck works on the x64 test agent

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-02-26 17:15:44 -08:00
igorkofman
9e3330a9ad fix(websocket): route sendBuffer through proxy tunnel TLS layer (#27433)
## Summary

`sendBuffer()` was writing directly to `this.tcp` (the raw socket),
which is **detached** in proxy tunnel mode (`wss://` through HTTP
CONNECT proxy). This caused unencrypted WebSocket frame data to be
written to a detached socket, corrupting the connection and causing
immediate disconnection (close code 1006).

## Root Cause

The fast path in `writeString` → `enqueueEncodedBytes` correctly checks
for `proxy_tunnel` and routes through `tunnel.write()`. But the slow
path (`sendData` → `sendDataUncompressed` → `sendBuffer`), taken when
there is backpressure or the data needs to be buffered in `send_buffer`,
bypassed the tunnel entirely.

Under **bidirectional traffic** (simultaneous reads and writes),
backpressure builds up and pushes writes through the `sendBuffer` path,
killing the connection within seconds.

## Reproduction

The bug manifests when using `wss://` through an HTTP CONNECT proxy with
bidirectional WebSocket traffic. Specifically this was causing constant
disconnections in Claude Code when using Bun's native WebSocket client —
the ping/pong keepalive mechanism never received pong responses because
the connection died before they could arrive.

Key conditions:
- `wss://` (TLS) through an HTTP CONNECT proxy (uses
`WebSocketProxyTunnel`)
- Bidirectional traffic (client writes AND receives data simultaneously)
- Works fine without proxy, or without TLS, or with read-only traffic

## Fix

- `sendBuffer`: Check for `proxy_tunnel` and route writes through
`tunnel.write()` instead of `this.tcp.write()`
- `sendDataUncompressed` (2 locations): Guard debug assertions
(`isShutdown`/`isClosed`/`isEstablished`) with `proxy_tunnel == null`
since they crash on the detached socket

## Test

Added `test-ws-bidir-proxy.test.ts` which sets up a TLS WebSocket
server, HTTP CONNECT proxy, and a client that does simultaneous
bidirectional traffic with ping/pong. Before this fix, the test fails
with close code 1006 after ~13 messages and 0 pongs. After the fix, it
completes with 9+ pongs and 140+ messages.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-02-26 11:08:41 -08:00
49 changed files with 5665 additions and 760 deletions

View File

@@ -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());

View 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
}
}

View File

@@ -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
}

View File

@@ -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

2
LATEST
View File

@@ -1 +1 @@
1.3.9
1.3.10

View File

@@ -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=="],
}
}

View File

@@ -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",

View 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();

View File

@@ -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")

View File

@@ -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

View File

@@ -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`

View File

@@ -1,7 +1,7 @@
{
"private": true,
"name": "bun",
"version": "1.3.10",
"version": "1.3.11",
"workspaces": [
"./packages/bun-types",
"./packages/@types/bun"

View File

@@ -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.

View File

@@ -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;
}

View File

@@ -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));
}

View File

@@ -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,

View File

@@ -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`)

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -2812,7 +2812,11 @@ JSC_DEFINE_HOST_FUNCTION(Process_functionsetgroups, (JSGlobalObject * globalObje
auto groups = callFrame->argument(0);
Bun::V::validateArray(scope, globalObject, groups, "groups"_s, jsUndefined());
RETURN_IF_EXCEPTION(scope, {});
auto groupsArray = JSC::jsDynamicCast<JSC::JSArray*>(groups);
auto* groupsArray = JSC::jsDynamicCast<JSC::JSArray*>(groups);
if (!groupsArray) [[unlikely]] {
// validateArray uses JSC::isArray() which accepts Proxy->Array, but jsDynamicCast returns null.
return Bun::ERR::INVALID_ARG_TYPE(scope, globalObject, "groups"_s, "Array"_s, groups);
}
auto count = groupsArray->length();
gid_t groupsStack[64];
if (count > 64) return Bun::ERR::OUT_OF_RANGE(scope, globalObject, "groups.length"_s, 0, 64, groups);

View File

@@ -837,11 +837,27 @@ static JSC::EncodedJSValue jsBufferConstructorFunction_concatBody(JSC::JSGlobalO
Bun::V::validateArray(throwScope, lexicalGlobalObject, listValue, "list"_s, jsUndefined());
RETURN_IF_EXCEPTION(throwScope, {});
auto array = JSC::jsDynamicCast<JSC::JSArray*>(listValue);
size_t arrayLength = array->length();
if (arrayLength < 1) {
// Note: `validateArray` uses `JSC::isArray()` which returns true for Proxy->Array.
// `jsDynamicCast<JSArray*>` returns nullptr for Proxy, so we must fall back to
// the generic get() path to match Node.js behavior.
auto* array = JSC::jsDynamicCast<JSC::JSArray*>(listValue);
uint64_t arrayLength64;
if (array) [[likely]] {
arrayLength64 = array->length();
} else {
JSValue lengthValue = listValue.get(lexicalGlobalObject, vm.propertyNames->length);
RETURN_IF_EXCEPTION(throwScope, {});
arrayLength64 = lengthValue.toLength(lexicalGlobalObject);
RETURN_IF_EXCEPTION(throwScope, {});
}
if (arrayLength64 < 1) {
RELEASE_AND_RETURN(throwScope, constructBufferEmpty(lexicalGlobalObject));
}
if (arrayLength64 > std::numeric_limits<unsigned>::max()) [[unlikely]] {
throwOutOfMemoryError(lexicalGlobalObject, throwScope);
return {};
}
unsigned arrayLength = static_cast<unsigned>(arrayLength64);
JSValue totalLengthValue = callFrame->argument(1);
@@ -857,7 +873,7 @@ static JSC::EncodedJSValue jsBufferConstructorFunction_concatBody(JSC::JSGlobalO
}
for (unsigned i = 0; i < arrayLength; i++) {
JSValue element = array->getIndex(lexicalGlobalObject, i);
JSValue element = array ? array->getIndex(lexicalGlobalObject, i) : listValue.get(lexicalGlobalObject, i);
RETURN_IF_EXCEPTION(throwScope, {});
auto* typedArray = JSC::jsDynamicCast<JSC::JSUint8Array*>(element);
@@ -1092,21 +1108,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;

View File

@@ -928,8 +928,9 @@ JSC_DEFINE_HOST_FUNCTION(jsHTTPSetHeader, (JSGlobalObject * globalObject, CallFr
if (valueValue.isUndefined())
return JSValue::encode(jsUndefined());
if (isArray(globalObject, valueValue)) {
auto* array = jsCast<JSArray*>(valueValue);
// Note: isArray() accepts Proxy->Array, but jsDynamicCast returns null for Proxy.
// Fall through to the single-value path in that case.
if (auto* array = jsDynamicCast<JSArray*>(valueValue)) {
unsigned length = array->length();
if (length > 0) {
JSValue item = array->getIndex(globalObject, 0);

View File

@@ -1220,10 +1220,15 @@ JSC_DEFINE_HOST_FUNCTION(vmModuleCompileFunction, (JSGlobalObject * globalObject
return ERR::INVALID_ARG_INSTANCE(scope, globalObject, "params"_s, "Array"_s, paramsArg);
}
auto* paramsArray = jsCast<JSArray*>(paramsArg);
auto* paramsArray = jsDynamicCast<JSArray*>(paramsArg);
if (!paramsArray) [[unlikely]] {
// isArray() accepts Proxy->Array, but jsDynamicCast returns null.
return ERR::INVALID_ARG_INSTANCE(scope, globalObject, "params"_s, "Array"_s, paramsArg);
}
unsigned length = paramsArray->length();
for (unsigned i = 0; i < length; i++) {
JSValue param = paramsArray->getIndexQuickly(i);
JSValue param = paramsArray->getIndex(globalObject, i);
RETURN_IF_EXCEPTION(scope, {});
if (!param.isString()) {
return ERR::INVALID_ARG_TYPE(scope, globalObject, "params"_s, "Array<string>"_s, paramsArg);
}
@@ -1267,8 +1272,8 @@ JSC_DEFINE_HOST_FUNCTION(vmModuleCompileFunction, (JSGlobalObject * globalObject
JSScope* functionScope = options.parsingContext ? options.parsingContext : globalObject;
if (!options.contextExtensions.isUndefinedOrNull() && !options.contextExtensions.isEmpty() && options.contextExtensions.isObject() && isArray(globalObject, options.contextExtensions)) {
auto* contextExtensionsArray = jsCast<JSArray*>(options.contextExtensions);
unsigned length = contextExtensionsArray->length();
auto* contextExtensionsArray = jsDynamicCast<JSArray*>(options.contextExtensions);
unsigned length = contextExtensionsArray ? contextExtensionsArray->length() : 0;
if (length > 0) {
// Get the global scope from the parsing context
@@ -1276,7 +1281,8 @@ JSC_DEFINE_HOST_FUNCTION(vmModuleCompileFunction, (JSGlobalObject * globalObject
// Create JSWithScope objects for each context extension
for (unsigned i = 0; i < length; i++) {
JSValue extension = contextExtensionsArray->getIndexQuickly(i);
JSValue extension = contextExtensionsArray->getIndex(globalObject, i);
RETURN_IF_EXCEPTION(scope, {});
if (extension.isObject()) {
JSObject* extensionObject = asObject(extension);
currentScope = JSWithScope::create(vm, options.parsingContext, currentScope, extensionObject);
@@ -1780,10 +1786,15 @@ bool CompileFunctionOptions::fromJS(JSC::JSGlobalObject* globalObject, JSC::VM&
return ERR::INVALID_ARG_TYPE(scope, globalObject, "options.contextExtensions"_s, "Array"_s, contextExtensionsValue);
// Validate that all items in the array are objects
auto* contextExtensionsArray = jsCast<JSArray*>(contextExtensionsValue);
auto* contextExtensionsArray = jsDynamicCast<JSArray*>(contextExtensionsValue);
if (!contextExtensionsArray) [[unlikely]] {
// isArray() accepts Proxy->Array, but jsDynamicCast returns null.
return ERR::INVALID_ARG_TYPE(scope, globalObject, "options.contextExtensions"_s, "Array"_s, contextExtensionsValue);
}
unsigned length = contextExtensionsArray->length();
for (unsigned i = 0; i < length; i++) {
JSValue extension = contextExtensionsArray->getIndexQuickly(i);
JSValue extension = contextExtensionsArray->getIndex(globalObject, i);
RETURN_IF_EXCEPTION(scope, {});
if (!extension.isObject())
return ERR::INVALID_ARG_TYPE(scope, globalObject, "options.contextExtensions[0]"_s, "object"_s, extension);
}

View File

@@ -462,43 +462,41 @@ AsymmetricMatcherResult matchAsymmetricMatcherAndGetFlags(JSGlobalObject* global
JSValue expectedArrayValue = expectArrayContaining->m_arrayValue.get();
if (JSC::isArray(globalObject, otherProp)) {
if (JSC::isArray(globalObject, expectedArrayValue)) {
JSArray* expectedArray = jsDynamicCast<JSArray*>(expectedArrayValue);
JSArray* otherArray = jsDynamicCast<JSArray*>(otherProp);
unsigned expectedLength = expectedArray->length();
unsigned otherLength = otherArray->length();
// A empty array is all array's subset
if (expectedLength == 0) {
return AsymmetricMatcherResult::PASS;
}
// O(m*n) but works for now
for (unsigned m = 0; m < expectedLength; m++) {
JSValue expectedValue = expectedArray->getIndex(globalObject, m);
bool found = false;
for (unsigned n = 0; n < otherLength; n++) {
JSValue otherValue = otherArray->getIndex(globalObject, n);
Vector<std::pair<JSValue, JSValue>, 16> stack;
MarkedArgumentBuffer gcBuffer;
bool foundNow = Bun__deepEquals<false, true>(globalObject, expectedValue, otherValue, gcBuffer, stack, throwScope, true);
RETURN_IF_EXCEPTION(throwScope, AsymmetricMatcherResult::FAIL);
if (foundNow) {
found = true;
break;
}
}
if (!found) {
return AsymmetricMatcherResult::FAIL;
}
}
// Note: isArray() accepts Proxy->Array, but jsDynamicCast returns null for Proxy.
JSArray* expectedArray = jsDynamicCast<JSArray*>(expectedArrayValue);
JSArray* otherArray = jsDynamicCast<JSArray*>(otherProp);
if (expectedArray && otherArray) {
unsigned expectedLength = expectedArray->length();
unsigned otherLength = otherArray->length();
// A empty array is all array's subset
if (expectedLength == 0) {
return AsymmetricMatcherResult::PASS;
}
// O(m*n) but works for now
for (unsigned m = 0; m < expectedLength; m++) {
JSValue expectedValue = expectedArray->getIndex(globalObject, m);
bool found = false;
for (unsigned n = 0; n < otherLength; n++) {
JSValue otherValue = otherArray->getIndex(globalObject, n);
Vector<std::pair<JSValue, JSValue>, 16> stack;
MarkedArgumentBuffer gcBuffer;
bool foundNow = Bun__deepEquals<false, true>(globalObject, expectedValue, otherValue, gcBuffer, stack, throwScope, true);
RETURN_IF_EXCEPTION(throwScope, AsymmetricMatcherResult::FAIL);
if (foundNow) {
found = true;
break;
}
}
if (!found) {
return AsymmetricMatcherResult::FAIL;
}
}
return AsymmetricMatcherResult::PASS;
}
return AsymmetricMatcherResult::FAIL;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,9 @@
#pragma once
#include "root.h"
namespace Bun {
JSC_DECLARE_HOST_FUNCTION(jsFunctionBunSliceAnsi);
}

View File

@@ -113,8 +113,9 @@ template<> JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES JSCookieMapDOMConstructo
} else if (initValue.isObject()) {
auto* object = initValue.getObject();
if (isArray(lexicalGlobalObject, object)) {
auto* array = jsCast<JSArray*>(object);
// Note: isArray() accepts Proxy->Array, but jsDynamicCast returns null for Proxy.
auto* array = jsDynamicCast<JSArray*>(object);
if (array) {
Vector<Vector<String>> seqSeq;
uint32_t length = array->length();

View File

@@ -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)

View File

@@ -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);
}

View File

@@ -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]),

View File

@@ -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();

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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];

View File

@@ -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);

View File

@@ -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;

View File

@@ -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");

View 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");
});

View File

@@ -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);
});
});

View File

@@ -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\``);
});
});

View 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("");
}

File diff suppressed because it is too large Load Diff

View File

@@ -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)));
}
});
});
});

View 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();
});
});

View File

@@ -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, {

View 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);

View File

@@ -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

View File

@@ -0,0 +1,91 @@
// Fixes for isArray() + jsCast<JSArray*> / jsDynamicCast<JSArray*> null dereference crashes.
//
// JSC::isArray() returns true for Proxy wrapping an Array (per ECMA-262 IsArray).
// But jsCast<JSArray*> on a Proxy is a type confusion (debug assertion / release UB),
// and jsDynamicCast<JSArray*> returns nullptr for Proxy.
//
// Before this fix, several APIs would crash with a segfault when given a Proxy
// wrapping an Array:
// - Buffer.concat(new Proxy([], {})) -> SEGV at 0x4
// - process.setgroups(new Proxy([], {})) -> SEGV at 0x4
// - vm.compileFunction("", new Proxy([], {})) -> debug assertion
// - new Bun.CookieMap(new Proxy([], {})) -> debug assertion
// - expect(proxy).toEqual(expect.arrayContaining([...])) -> UBSan null deref
import { describe, expect, test } from "bun:test";
import vm from "vm";
describe("isArray + Proxy crash fixes", () => {
test("Buffer.concat accepts empty Proxy-wrapped array", () => {
// Node.js returns an empty buffer here; before the fix, Bun would SEGV.
const result = Buffer.concat(new Proxy([], {}));
expect(result.length).toBe(0);
});
test("Buffer.concat iterates Proxy-wrapped array correctly", () => {
// Node.js compatibility: Proxy-wrapped arrays should be iterated via get() traps.
const b = Buffer.from("hi");
const result = Buffer.concat(new Proxy([b, b], {}));
expect(result.toString()).toBe("hihi");
});
test("Buffer.concat with Proxy get trap", () => {
const b1 = Buffer.from("foo");
const b2 = Buffer.from("bar");
const accesses: string[] = [];
const list = new Proxy([b1, b2], {
get(target, prop, receiver) {
accesses.push(String(prop));
return Reflect.get(target, prop, receiver);
},
});
const result = Buffer.concat(list);
expect(result.toString()).toBe("foobar");
// Must access length and indices via Proxy traps
expect(accesses).toContain("length");
expect(accesses).toContain("0");
expect(accesses).toContain("1");
});
test.skipIf(process.platform === "win32")("process.setgroups throws TypeError for Proxy (no crash)", () => {
// Node.js also rejects Proxy here (with a native assertion). We throw a TypeError.
expect(() => process.setgroups(new Proxy([], {}))).toThrow(TypeError);
});
test("vm.compileFunction throws for Proxy params (no crash)", () => {
// Before: debug assertion in jsCast<JSArray*>. Now: proper TypeError.
expect(() => vm.compileFunction("return 1", new Proxy([], {}))).toThrow();
});
test("vm.compileFunction throws for Proxy contextExtensions (no crash)", () => {
expect(() =>
vm.compileFunction("return 1", [], {
contextExtensions: new Proxy([], {}),
}),
).toThrow();
});
test("new Bun.CookieMap does not crash with Proxy-wrapped array", () => {
// Before: debug assertion in jsCast<JSArray*>. Now: falls through to record path.
// A Proxy wrapping [] has no own enumerable string keys, so this yields an empty map.
const map = new Bun.CookieMap(new Proxy([], {}));
expect(map.size).toBe(0);
});
test("expect.arrayContaining does not crash with Proxy receiver", () => {
// Before: UBSan null deref on otherArray->length(). Now: FAIL (doesn't match).
const proxy = new Proxy([1, 2, 3], {});
// Proxy is not a real JSArray, so arrayContaining matcher falls to FAIL path.
// The important thing is: no crash.
expect(() => {
expect(proxy).toEqual(expect.arrayContaining([1]));
}).toThrow(); // toEqual assertion fails, but process doesn't crash
});
test("expect.arrayContaining with Proxy expected value does not crash", () => {
const proxyExpected = new Proxy([1], {});
expect(() => {
expect([1, 2, 3]).toEqual(expect.arrayContaining(proxyExpected));
}).toThrow(); // assertion fails, no crash
});
});