Skip to content
8 changes: 5 additions & 3 deletions src/bun_core/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1844,10 +1844,12 @@ pub(crate) mod strings_impl {
}

/// Port of `elementLengthUTF16IntoUTF8` — exact UTF-8 byte length of a UTF-16
/// (LE) input. simdutf-backed; falls back to scalar would be in unicode_draft.
/// (LE) input, charging 3 bytes (U+FFFD) per unpaired surrogate. This is
/// exactly what `copy_utf16_into_utf8` / `to_utf8_alloc` write, so it is a
/// valid exact allocation size even for ill-formed UTF-16.
#[inline]
pub fn element_length_utf16_into_utf8(utf16: &[u16]) -> usize {
simdutf::length::utf8::from::utf16::le(utf16)
simdutf::length::utf8::from::utf16::le_with_replacement(utf16)
Comment thread
robobun marked this conversation as resolved.
Resolved
}

/// Port of `elementLengthLatin1IntoUTF8`.
Expand All @@ -1869,7 +1871,7 @@ pub(crate) mod strings_impl {
let utf8_len = if worst_case <= buf.len() {
worst_case
} else {
simdutf::length::utf8::from::utf16::le(utf16)
element_length_utf16_into_utf8(utf16)
};
copy_utf16_into_utf8_with_utf8_len(buf, utf16, utf8_len)
}
Expand Down
16 changes: 8 additions & 8 deletions src/bun_core/string/StringBuilder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,14 @@ impl StringBuilder {
}

pub fn count16(&mut self, slice: &[u16]) {
self.cap += simdutf::length::utf8::from::utf16::le(slice);
self.cap += crate::string::strings::element_length_utf16_into_utf8(slice);
}

pub fn count16_z(&mut self, slice: &[u16]) {
// Callers pass &[u16] (WStr has no len method on its DST slice yet).
// For WTF-16 with lone surrogates the simdutf length estimate
// overestimates by 0-1 bytes, which is fine for a capacity reservation.
self.cap += simdutf::length::utf8::from::utf16::le(slice) + 1;
// element_length_utf16_into_utf8 charges 3 bytes (U+FFFD) per unpaired
// surrogate, which is exactly what append16's fallback writes.
self.cap += crate::string::strings::element_length_utf16_into_utf8(slice) + 1;
}

pub fn append16(&mut self, slice: &[u16]) -> Option<&mut ZStr> {
Expand All @@ -76,10 +76,10 @@ impl StringBuilder {
Some(unsafe { ZStr::from_raw_mut(buf_ptr, count) })
} else {
// Fallback: WTF-16 → WTF-8 via the slow path that handles lone surrogates.
// The signature returns a borrow into `self`, so we copy
// the WTF-8 bytes into the builder's reserved buffer (count16_z reserved
// enough — simdutf's length estimate is an upper bound for WTF-16) and
// drop the temporary Vec normally. No `mem::forget`.
// The signature returns a borrow into `self`, so we copy the WTF-8
// bytes into the builder's reserved buffer (count16_z uses the same
// replacement-aware length, so the reservation is exact) and drop
// the temporary Vec normally. No `mem::forget`.
let out = crate::string::strings::to_utf8_alloc(slice);
let len = out.len();
let avail = self.cap - self.len;
Expand Down
6 changes: 5 additions & 1 deletion src/jsc/ErrorCode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -711,9 +711,11 @@ impl ErrorCode {
pub const FS_CP_SYMLINK_TO_SUBDIRECTORY: ErrorCode = ErrorCode(326);
/// `ERR_DIR_CONCURRENT_OPERATION` (instanceof Error)
pub const DIR_CONCURRENT_OPERATION: ErrorCode = ErrorCode(327);
/// `ERR_INVALID_BUFFER_SIZE` (instanceof RangeError)
pub const INVALID_BUFFER_SIZE: ErrorCode = ErrorCode(328);

/// == C++ `NODE_ERROR_COUNT`.
pub const COUNT: u16 = 328;
pub const COUNT: u16 = 329;
}

// ──────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -861,6 +863,7 @@ impl ErrorCode {
pub const ERR_INVALID_ARG_TYPE: ErrorCode = ErrorCode::INVALID_ARG_TYPE;
pub const ERR_INVALID_ARG_VALUE: ErrorCode = ErrorCode::INVALID_ARG_VALUE;
pub const ERR_INVALID_ASYNC_ID: ErrorCode = ErrorCode::INVALID_ASYNC_ID;
pub const ERR_INVALID_BUFFER_SIZE: ErrorCode = ErrorCode::INVALID_BUFFER_SIZE;
pub const ERR_INVALID_CHAR: ErrorCode = ErrorCode::INVALID_CHAR;
pub const ERR_INVALID_CURSOR_POS: ErrorCode = ErrorCode::INVALID_CURSOR_POS;
pub const ERR_INVALID_FD_TYPE: ErrorCode = ErrorCode::INVALID_FD_TYPE;
Expand Down Expand Up @@ -1429,6 +1432,7 @@ static CODE_STR: [&str; ErrorCode::COUNT as usize] = [
"ERR_FS_CP_EEXIST",
"ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY",
"ERR_DIR_CONCURRENT_OPERATION",
"ERR_INVALID_BUFFER_SIZE",
];

// ──────────────────────────────────────────────────────────────────────────
Expand Down
1 change: 1 addition & 0 deletions src/jsc/bindings/ErrorCode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,5 +339,6 @@ const errors: ErrorCodeMapping = [
["ERR_FS_CP_EEXIST", Error],
["ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY", Error],
["ERR_DIR_CONCURRENT_OPERATION", Error],
["ERR_INVALID_BUFFER_SIZE", RangeError],
];
export default errors;
92 changes: 50 additions & 42 deletions src/jsc/bindings/JSBuffer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1319,7 +1319,7 @@

auto value = callFrame->uncheckedArgument(0);
// Capture byteLength up front for two orthogonal purposes:
// 1. The upper-bound argument to validateNumber(end) so `end >
// 1. The upper-bound argument to validateInteger(end) so `end >
// buf.length` throws ERR_OUT_OF_RANGE with Node's wording and
// against the length the caller saw (matches Node: parseEncoding
// may run a user toString before this check, but the Node-
Expand Down Expand Up @@ -1362,7 +1362,7 @@
}

// ── 1. Encoding parse (FIRST validation) ────────────────────────────
// Node validates encoding before either `validateNumber` call, so
// Node validates encoding before either `validateInteger` call, so
// `fill("a", 0, buf.length + 1, "bogus")` and `fill("a", -1, 0,
// "bogus")` throw ERR_UNKNOWN_ENCODING (not ERR_OUT_OF_RANGE) — the
// encoding error wins. parseEncoding is also the first
Expand All @@ -1375,23 +1375,21 @@
}

// ── 2. Pure offset / end coercion (no user JS) ──────────────────────
// validateNumber rejects non-numbers without coercion, and toLength
// on a number is a C++ conversion. parseEncoding above may have
// Node routes both through validateOffset (= validateInteger), so a
// fractional or NaN offset/end throws ERR_OUT_OF_RANGE "an integer"
// instead of being truncated. parseEncoding above may have
// detached/resized the buffer, but the `limit` captured pre-coercion
// is still the correct Node-compat upper bound for ERR_OUT_OF_RANGE;
// the final write range is clamped against a separate post-coercion
// byteLength read further down.
// https://github.com/nodejs/node/blob/v22.9.0/lib/buffer.js#L1066-L1079
// https://github.com/nodejs/node/blob/v22.9.0/lib/buffer.js#L122
if (!offsetValue.isUndefined()) {
Bun::V::validateNumber(scope, lexicalGlobalObject, offsetValue, "offset"_s, jsNumber(0), jsNumber(Bun::Buffer::kMaxLength));
Bun::V::validateInteger(scope, lexicalGlobalObject, offsetValue, "offset"_s, jsNumber(0), jsNumber(Bun::Buffer::kMaxLength), &offset);
RETURN_IF_EXCEPTION(scope, {});
offset = offsetValue.toLength(lexicalGlobalObject);
}
if (!endValue.isUndefined()) {
Bun::V::validateNumber(scope, lexicalGlobalObject, endValue, "end"_s, jsNumber(0), jsNumber(limit));
Bun::V::validateInteger(scope, lexicalGlobalObject, endValue, "end"_s, jsNumber(0), jsNumber(limit), &end);
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
RETURN_IF_EXCEPTION(scope, {});
end = endValue.toLength(lexicalGlobalObject);
}

// Node short-circuits empty/inverted ranges before coercing `value`,
Expand Down Expand Up @@ -1557,6 +1555,22 @@
return idx * 2;
}

// UCS2 searches operate on whole uint16_t units (Node's SearchString<uint16_t>),
// so a match can only start on an even byte offset.
static int64_t lastIndexOf16(const uint8_t* thisPtr, int64_t thisLength, const uint8_t* valuePtr, int64_t valueLength, int64_t byteOffset)
{
if (thisLength == 1) return -1;
if (valueLength == 1) return -1;
thisLength /= 2;
valueLength /= 2;
byteOffset /= 2;
auto haystack = std::span<const uint16_t>((const uint16_t*)(thisPtr), std::min(thisLength, byteOffset + valueLength));
auto needle = std::span<const uint16_t>((const uint16_t*)(valuePtr), valueLength);
auto it = std::find_end(haystack.begin(), haystack.end(), needle.begin(), needle.end());
if (it == haystack.end()) return -1;
return std::distance(haystack.begin(), it) * 2;
}

static int64_t lastIndexOf(const uint8_t* thisPtr, int64_t thisLength, const uint8_t* valuePtr, int64_t valueLength, int64_t byteOffset)
{
auto start = thisPtr;
Expand Down Expand Up @@ -1651,14 +1665,12 @@
if (encoding == BufferEncodingType::ucs2) searchEnd &= ~static_cast<size_t>(1);

const uint8_t* typedVectorValue = arrayValue->typedVector();
if (last) {
return lastIndexOf(typedVector, searchEnd, typedVectorValue, needleLength, byteOffset);
}
if (encoding == BufferEncodingType::ucs2) {
return indexOf16(typedVector, searchEnd, typedVectorValue, needleLength, byteOffset);
return last ? lastIndexOf16(typedVector, searchEnd, typedVectorValue, needleLength, byteOffset)
: indexOf16(typedVector, searchEnd, typedVectorValue, needleLength, byteOffset);
}

return indexOf(typedVector, searchEnd, typedVectorValue, needleLength, byteOffset);
return last ? lastIndexOf(typedVector, searchEnd, typedVectorValue, needleLength, byteOffset)
: indexOf(typedVector, searchEnd, typedVectorValue, needleLength, byteOffset);
}

static int64_t indexOfBuffer(JSC::JSGlobalObject* lexicalGlobalObject, bool last, const uint8_t* typedVector, size_t byteLength, double byteOffsetD, double endD, JSC::JSGenericTypedArrayView<JSC::Uint8Adaptor>* array, BufferEncodingType encoding)
Expand All @@ -1674,13 +1686,12 @@
if (encoding == BufferEncodingType::ucs2) searchEnd &= ~static_cast<size_t>(1);

const uint8_t* typedVectorValue = array->typedVector();
if (last) {
return lastIndexOf(typedVector, searchEnd, typedVectorValue, needleLength, byteOffset);
}
if (encoding == BufferEncodingType::ucs2) {
return indexOf16(typedVector, searchEnd, typedVectorValue, needleLength, byteOffset);
return last ? lastIndexOf16(typedVector, searchEnd, typedVectorValue, needleLength, byteOffset)
: indexOf16(typedVector, searchEnd, typedVectorValue, needleLength, byteOffset);
}
return indexOf(typedVector, searchEnd, typedVectorValue, needleLength, byteOffset);
return last ? lastIndexOf(typedVector, searchEnd, typedVectorValue, needleLength, byteOffset)
: indexOf(typedVector, searchEnd, typedVectorValue, needleLength, byteOffset);
}

static int64_t indexOf(JSC::JSGlobalObject* lexicalGlobalObject, ThrowScope& scope, JSC::CallFrame* callFrame, typename IDLOperation<JSArrayBufferView>::ClassParameter buffer, bool last)
Expand All @@ -1690,7 +1701,9 @@
std::optional<BufferEncodingType> encoding = std::nullopt;
double byteOffsetD = 0;

if (byteLength == 0) return -1;
// An empty haystack is NOT an immediate -1: Node still finds an empty
// needle in it (Buffer.alloc(0).indexOf(Buffer.alloc(0)) === 0).
// computeIndexOfRange handles both the empty haystack and empty needle.

auto valueValue = callFrame->argument(0);
auto byteOffsetValue = callFrame->argument(1);
Expand Down Expand Up @@ -1733,7 +1746,8 @@
// after all JS calls in each code path are complete.

// Helper: re-fetch buffer state after JS calls. Returns false if the buffer
// was detached; caller treats this as an empty buffer (matches Node.js: -1).
// was detached; the caller returns -1 (matches Node.js for a detached
// haystack). A merely EMPTY haystack is not -1; see computeIndexOfRange.
auto refetchBufferState = [&](const uint8_t*& typedVector, size_t& len) -> bool {
if (buffer->isDetached()) [[unlikely]] {
typedVector = nullptr;
Expand All @@ -1752,7 +1766,6 @@
RETURN_IF_EXCEPTION(scope, -1);
const uint8_t* typedVector;
if (!refetchBufferState(typedVector, byteLength)) return -1;
if (byteLength == 0) return -1;
return indexOfNumber(lexicalGlobalObject, last, typedVector, byteLength, byteOffsetD, endD, byteValue);
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
}

Expand All @@ -1773,15 +1786,13 @@
RETURN_IF_EXCEPTION(scope, -1);
const uint8_t* typedVector;
if (!refetchBufferState(typedVector, byteLength)) return -1;
if (byteLength == 0) return -1;
return indexOfString(lexicalGlobalObject, last, typedVector, byteLength, byteOffsetD, endD, str, encoding.value());
}

if (auto* array = dynamicDowncast<JSC::JSUint8Array>(valueValue)) {
if (!encoding.has_value()) encoding = BufferEncodingType::utf8;
const uint8_t* typedVector;
if (!refetchBufferState(typedVector, byteLength)) return -1;
if (byteLength == 0) return -1;
// A needle whose backing buffer was detached by a valueOf/toPrimitive
// callback above reports byteLength()==0; computeIndexOfRange's
// empty-needle path handles that (clamped to `end`) without touching
Expand Down Expand Up @@ -1907,8 +1918,7 @@
constexpr size_t elemSize = 2;
size_t length = castedThis->byteLength();
if (length % elemSize != 0) {
throwNodeRangeError(lexicalGlobalObject, scope, "Buffer size must be a multiple of 16-bits"_s);
return {};
return Bun::throwError(lexicalGlobalObject, scope, Bun::ErrorCode::ERR_INVALID_BUFFER_SIZE, "Buffer size must be a multiple of 16-bits"_s);
}

if (castedThis->isDetached()) [[unlikely]] {
Expand Down Expand Up @@ -1937,8 +1947,7 @@
constexpr int elemSize = 4;
int64_t length = static_cast<int64_t>(castedThis->byteLength());
if (length % elemSize != 0) {
throwNodeRangeError(lexicalGlobalObject, scope, "Buffer size must be a multiple of 32-bits"_s);
return {};
return Bun::throwError(lexicalGlobalObject, scope, Bun::ErrorCode::ERR_INVALID_BUFFER_SIZE, "Buffer size must be a multiple of 32-bits"_s);
}

if (castedThis->isDetached()) [[unlikely]] {
Expand Down Expand Up @@ -1972,8 +1981,7 @@
constexpr size_t elemSize = 8;
size_t length = castedThis->byteLength();
if (length % elemSize != 0) {
throwNodeRangeError(lexicalGlobalObject, scope, "Buffer size must be a multiple of 64-bits"_s);
return {};
return Bun::throwError(lexicalGlobalObject, scope, Bun::ErrorCode::ERR_INVALID_BUFFER_SIZE, "Buffer size must be a multiple of 64-bits"_s);
}

if (castedThis->isDetached()) [[unlikely]] {
Expand Down Expand Up @@ -2654,8 +2662,10 @@

if (offsetVal.isInt32()) {
int32_t offsetI = offsetVal.asInt32();
// Node's boundsError reports a negative offset as ERR_OUT_OF_RANGE
// (">= 0 and <= max"), not ERR_BUFFER_OUT_OF_BOUNDS.
if (offsetI < 0) [[unlikely]] {
Bun::ERR::BUFFER_OUT_OF_BOUNDS(scope, lexicalGlobalObject, "offset"_s);
Bun::ERR::OUT_OF_RANGE(scope, lexicalGlobalObject, "offset"_s, 0, maxOffset, offsetVal);
return 0;
}

Expand All @@ -2675,24 +2685,22 @@
}

auto offsetD = offsetVal.asNumber();
if (offsetD < 0) [[unlikely]] {
Bun::ERR::BUFFER_OUT_OF_BOUNDS(scope, lexicalGlobalObject, "offset"_s);
return 0;
}

if (std::fmod(offsetD, 1.0) != 0) [[unlikely]] {
// Node's boundsError tests `Math.floor(value) !== value` before the range,
// so -1.5 and NaN report "an integer" while +-Infinity (for which floor is
// an identity) fall through to the range message.
if (std::floor(offsetD) != offsetD) [[unlikely]] {

Check notice on line 2691 in src/jsc/bindings/JSBuffer.cpp

View check run for this annotation

Claude / Claude Code Review

validateOffsetBigInt64: byteLength<8 check precedes Node's integer/type checks

🟣 Pre-existing, in the same function this PR reorders for Node parity but on lines it does not touch (2650-2654): the `if (byteLength < 8)` → `ERR_BUFFER_OUT_OF_BOUNDS` check fires before `offsetVal` is examined at all, while Node's `checkBounds` runs `validateNumber(offset)` first and `boundsError` tests `Math.floor(value) !== value` before the `length < 0` branch. So `Buffer.alloc(7).writeBigInt64LE(1n, 1.5)` → Bun `ERR_BUFFER_OUT_OF_BOUNDS` vs Node `ERR_OUT_OF_RANGE` "an integer", and `Buffer
Comment thread
robobun marked this conversation as resolved.
Resolved
Bun::ERR::OUT_OF_RANGE(scope, lexicalGlobalObject, "offset"_s, "an integer"_s, offsetVal);
return 0;
}

offset = truncateDoubleToUint64(offsetD);

if (offset > maxOffset) [[unlikely]] {
// Range-check the double before truncating so +-Infinity never reaches
// truncateDoubleToUint64.
if (offsetD < 0 || offsetD > static_cast<double>(maxOffset)) [[unlikely]] {
Bun::ERR::OUT_OF_RANGE(scope, lexicalGlobalObject, "offset"_s, 0, maxOffset, offsetVal);
return 0;
}

return offset;
return truncateDoubleToUint64(offsetD);
}

JSC_DEFINE_JIT_OPERATION(jsBufferConstructorAllocWithoutTypeChecks, JSUint8Array*, (JSC::JSGlobalObject * lexicalGlobalObject, void* thisValue, int byteLength))
Expand Down
2 changes: 0 additions & 2 deletions src/runtime/webcore/encoding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,6 @@ pub(crate) unsafe extern "C" fn Bun__encoding__writeUTF16(
r.unwrap_or(0)
}

// TODO(@190n) handle unpaired surrogates
/// # Safety
/// Caller (C++) must guarantee `input[..len]` is valid for reading.
#[unsafe(no_mangle)]
Expand All @@ -156,7 +155,6 @@ pub(crate) unsafe extern "C" fn Bun__encoding__byteLengthLatin1AsUTF8(
unsafe { byte_length_u8::<{ enc::UTF8 }>(input, len) }
}

Comment thread
claude[bot] marked this conversation as resolved.
Resolved
// TODO(@190n) handle unpaired surrogates
/// # Safety
/// Caller (C++) must guarantee `input[..len]` is valid for reading.
#[unsafe(no_mangle)]
Expand Down
9 changes: 9 additions & 0 deletions src/simdutf_sys/bun-simdutf.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,15 @@ size_t simdutf__utf8_length_from_utf16le(const char16_t* input, size_t length)
return simdutf::utf8_length_from_utf16le(input, length);
}

// Unlike the non-validating variant above (which charges every surrogate code
// unit 2 bytes), this charges 3 bytes (U+FFFD) for each unpaired surrogate,
// matching what the replacement encoder produces. `.count` is documented to be
// correct even when `.error` is SURROGATE.
Comment thread
robobun marked this conversation as resolved.
Resolved
size_t simdutf__utf8_length_from_utf16le_with_replacement(const char16_t* input, size_t length)
{
return simdutf::utf8_length_from_utf16le_with_replacement(input, length).count;
}

size_t simdutf__utf8_length_from_utf16be(const char16_t* input, size_t length)
{
return simdutf::utf8_length_from_utf16be(input, length);
Expand Down
Loading
Loading