Conversation
There was a problem hiding this comment.
I'm Bonk, and I've done a quick review of your PR.
This PR adds a Rust-backed BufferUtil implementation behind the RUST_BACKED_NODE_BUFFER autogate, using the simdutf Rust crate linked against workerd's vendored simdutf C++ library. The autogate-gated approach is the right pattern for this kind of migration.
Issues (ranked by severity)
-
[High]
to_string: panics onstart > end— ifstart.value()exceedsend.value()(after clamping),&data[s..e]panics at runtime. Untrusted JS can trigger this. -
[High]
fill_impl: panics onstart > endorstart >= buffer.len()—data[start..end].fill(0)panics ifstart > end, anddata[i]panics ifstart >= buffer.len()whenendis also unclamped forstart. -
[Medium]
index_of_offset:length as i32wraps for buffers > 2 GiB —let len = length as i32silently wraps to negative for buffers withlength > i32::MAX. While V8TypedArraylimits make this unlikely in practice, it contradicts the#![expect(clippy::cast_sign_loss)]rationale that "all casts are checked or bounded by the caller". -
[Medium] Massive code duplication between
to_stringandto_string_for_encoding— every encoding branch is copy-pasted between these two functions. A single function with a slice parameter would eliminate ~70 lines of duplicated unsafe code. -
[Low]
decode_stringfor Base64/Base64url: duplicated branches — the two branches are identical except forBase64Options::DefaultvsBase64Options::Url. Could be unified. -
[Low]
byte_lengthignores encoding — the C++byteLengthtakes aJsString(with encoding semantics). The Rust version accepts aStringand returnsstr.len()(UTF-8 byte length), which is only correct for UTF-8 encoding. Other encodings would produce wrong results.
|
I'm Bonk, and I've done a quick review of your PR. This PR adds a Rust-backed I posted a review with 6 inline comments on PR #6360. The key findings ranked by severity:
Each comment includes a concrete suggestion where applicable. |
00d2f75 to
29a1e01
Compare
Merging this PR will not alter performance
Comparing Footnotes
|
ab8b578 to
faea404
Compare
|
/bigbonk review carefully specifically looking for definite or possible breaking behavioral changes. identify anything that could potentially break user assumptions |
There was a problem hiding this comment.
Follow-up review after deeper analysis. These are new findings beyond my initial review.
- [Medium]
index_ofuses naive O(n*m) search vs C++SearchString(Boyer-Moore-Horspool) — likely source of CodSpeed regression - [Medium]
decode_stringASCII/Latin1 combined branch has wrong ASCII semantics - [Medium]
nbytes::base64_decodedoesn't distinguish Base64 from Base64url encoding - [Low]
decodeUTF-16LE surrogate detection bitmask is too narrow (faithfully ported from C++) - [Low]
nbytesFFI does element-by-element copy instead of bulk memcpy
This review was generated by an AI code review assistant and may contain inaccuracies.
There was a problem hiding this comment.
Follow-up deep review with additional findings beyond the initial review.
- [Medium]
index_ofuses naive O(n*m) search vs C++ Boyer-Moore-Horspool — likely CodSpeed regression source - [Medium]
decode_stringASCII branch has wrong semantics (should mask to 7 bits) - [Medium]
decode_stringdoes not distinguish Base64 from Base64url - [Low]
decodeUTF-16LE surrogate detection bitmask is too narrow (faithfully ported from C++) - [Low]
nbytesFFI does element-by-element copy instead of bulk memcpy
This review was generated by an AI code review assistant and may contain inaccuracies.
src/rust/api/buffer.rs
Outdated
| #[expect(clippy::cast_sign_loss, reason = "opt_offset verified non-negative")] | ||
| let start = opt_offset as usize; | ||
| if is_forward { | ||
| for i in start..=data.len().saturating_sub(needle.len()) { |
There was a problem hiding this comment.
Naive O(n*m) search — likely perf regression source: The C++ indexOf uses SearchString (Boyer-Moore-Horspool from buffer-string-search.h) for efficient substring search. This Rust version uses a byte-by-byte comparison loop, which is O(n*m) worst case. For large buffers with long needles, this is significantly slower.
This is likely the root cause of the CodSpeed simpleStringBody[Response] regression (-26.77%). Even if this specific benchmark does not call indexOf directly, the naive pattern matching approach suggests other hot paths may have similar regressions.
Consider:
memchrcrate (SIMD-accelerated) for single-byte needlesdata.windows(needle.len()).position(|w| w == needle)for a compiler-optimized multi-byte fallback- Porting
SearchString(Boyer-Moore-Horspool) for parity with the C++ version
src/rust/api/buffer.rs
Outdated
| Ok(match enc { | ||
| // ASCII and Latin1 both take the low byte of each Unicode code point. | ||
| // We convert from UTF-8 chars (not raw bytes) to get the correct code points. | ||
| Encoding::Ascii | Encoding::Latin1 => string.chars().map(|c| c as u8).collect(), |
There was a problem hiding this comment.
ASCII/Latin1 branches produce identical output, but ASCII should mask to 7 bits: The C++ Encoding::ASCII handling masks each byte with & 0x7f (see toStringImpl in buffer.c++). But this Rust decode_string combines both into a single branch that just truncates to u8 via c as u8. For Latin1, this is correct (low byte of Unicode code point). For ASCII, this is wrong — Node.js Buffer.from("\u00e9", "ascii") should produce 0x69 (= 0xe9 & 0x7f), not 0xe9.
| Encoding::Ascii | Encoding::Latin1 => string.chars().map(|c| c as u8).collect(), | |
| Encoding::Ascii => string.chars().map(|c| (c as u8) & 0x7f).collect(), | |
| Encoding::Latin1 => string.chars().map(|c| c as u8).collect(), |
| // Node.js uses nbytes::Base64Decode which is very lenient — it | ||
| // ignores invalid characters and decodes whatever valid base64 | ||
| // chars are present. | ||
| nbytes::base64_decode(src) |
There was a problem hiding this comment.
Base64 and Base64url use the same decode path: nbytes::base64_decode(src) delegates to C++ nbytes::Base64Decode, which uses standard Base64 alphabet (+, /). For Base64url, the alphabet should be - and _ instead. Node.js Buffer.from(data, "base64url") should decode URL-safe base64.
The C++ decodeStringImpl also uses nbytes::Base64Decode for both, which happens to be lenient enough to accept both alphabets. If that leniency is intentional and tested, this is fine — but it would be good to add a comment explaining the intentional fallthrough, and ideally a test for URL-safe characters (- and _).
src/rust/nbytes/ffi.c++
Outdated
| ::rust::Vec<uint8_t> result; | ||
| result.reserve(actual); | ||
| auto* data = reinterpret_cast<const uint8_t*>(buf.get()); | ||
| std::copy(data, data + actual, std::back_inserter(result)); |
There was a problem hiding this comment.
Element-by-element copy: std::back_inserter copies bytes one at a time. Since you already have a contiguous buffer, consider using a bulk copy:
| std::copy(data, data + actual, std::back_inserter(result)); | |
| ::rust::Vec<uint8_t> result; | |
| result.reserve(actual); | |
| auto* data = reinterpret_cast<const uint8_t*>(buf.get()); | |
| std::copy_n(data, actual, std::back_inserter(result)); |
Or even better, if rust::Vec supports it, construct from the range directly. The current code is equivalent to std::copy with iterators, which the compiler may optimize to memcpy — but it is not guaranteed.
src/rust/api/buffer.rs
Outdated
| }; | ||
|
|
||
| let data = buffer.as_slice(); | ||
| let offset = byte_offset.value() as i32; |
There was a problem hiding this comment.
byte_offset f64→i32 truncation: byte_offset.value() as i32 silently truncates values outside the i32 range. JavaScript numbers can represent offsets larger than i32::MAX (e.g., 2147483648). The as i32 cast wraps to negative, changing search semantics. Consider:
let offset = byte_offset.value() as i64;
// then use i64 throughout the offset computationThis is related to my earlier comment about length as i32, but byte_offset is also affected and is more likely to actually receive large values from user code.
|
Review posted successfully on PR #6360. Here's a summary of what was done: Initial review (already posted): 6 inline comments covering panics on untrusted input, silent i32 truncation, code duplication, and Follow-up deep review (just posted): 6 additional inline comments with new findings:
The ASCII semantics bug ( Limerick incoming!
|
24b7e96 to
a4f1655
Compare
97e306b to
bcbf3ee
Compare
|
|
||
| // Thin wrapper around workerd::util::Autogate::isEnabled(). | ||
| // The key is a string matching the kebab-case name (e.g. "rust-backed-node-buffer"). | ||
| inline bool is_enabled(::rust::String key) { |
There was a problem hiding this comment.
autogate checks are supposed to be super cheap, this function is not super cheap nor a thing wrapper
There was a problem hiding this comment.
Yes, this is not ideal and will be changed before merging. Just did a quick hack to see the test suite results.
|
|
||
| /// Decodes hex, truncating at the first invalid hex pair (Node.js behavior). | ||
| /// In strict mode, returns an error on invalid input instead of truncating. | ||
| fn hex_decode(src: &[u8], strict: bool) -> Result<Vec<u8>, jsg::Error> { |
There was a problem hiding this comment.
can we have something other than bool args?
There was a problem hiding this comment.
yes, I know the existing c++ code has bool args but that's just because I haven't gotten around to updating everything to use WD_STRONG_BOOL yet
There was a problem hiding this comment.
I think we need this clippy rule to be enabled: fn_params_excessive_bools https://rust-lang.github.io/rust-clippy/master/index.html#fn_params_excessive_bools
There was a problem hiding this comment.
I'm not sure how that addresses my ask? It's not about excessive bools, it's about bool args period. Is it not possible to have something like WD_STRONG_BOOL in rust?
| #[jsg_static_constant] | ||
| pub const BASE64URL: u8 = 5; | ||
| #[jsg_static_constant] | ||
| pub const HEX: u8 = 6; |
There was a problem hiding this comment.
Nit ... a comment indicating that these need to be kept in sync with the enum would be helpful.
There was a problem hiding this comment.
Yeah, we should just use enum values rather than constant values here.
| pub const HEX: u8 = 6; | ||
|
|
||
| #[jsg_method] | ||
| pub fn is_ascii(&self, bytes: jsg::v8::Local<jsg::v8::Uint8Array>) -> bool { |
There was a problem hiding this comment.
Ideally I think long term we'd want to have an equivalent to jsg::Js* wrapper types in Rust like we do in C++. the jsg::v8::Local<...> pattern here is unfortunate.
There was a problem hiding this comment.
I think we just don't need it because they're actually v8::XXX. v8::String stores a pointer to v8::ffi::String and there is no blocker to make v8::String act like jsg::JsString.
There was a problem hiding this comment.
I'm not following what you're saying here... specifically, I don't know what you mean by, "there is no blocker to make v8::String act like jsg::JsString".
I should not have to think about the jsg::v8::Local<...> part of this at all as it just makes things more verbose and isn't a detail I really should have to worry about. A jsg::v8::JsUint8Array or jsg::v8::JsString, etc is less verbose and is just easier to work with.
I'm firmly -1 on the jsg::v8::Local<T> pattern unless there's an essential reason for keeping it, which I'm not seeing.
There was a problem hiding this comment.
My recommendation is to keep this as is. Move the v8::* bindings to a different crate, and implement jsg::JsUint8Array or any other class using the v8 bindings and abstract the Local handle logic.
677011e to
3e59d17
Compare
a4f1655 to
0656546
Compare
0656546 to
1d5c469
Compare
| byte_offset: jsg::Number, | ||
| encoding: jsg::Number, | ||
| is_forward: jsg::NonCoercible<bool>, | ||
| ) -> Result<jsg::Number, jsg::Error> { |
There was a problem hiding this comment.
The performance of this algorithm is going to be O(n*m) at the best. That going to be too much of a performance hit for this.
Built on top of #6272 branch. Not yet ready to review & merge.