diff --git a/example/src/tests/utils/encoding_tests.ts b/example/src/tests/utils/encoding_tests.ts index d9411681..05e59853 100644 --- a/example/src/tests/utils/encoding_tests.ts +++ b/example/src/tests/utils/encoding_tests.ts @@ -767,12 +767,6 @@ test(SUITE, 'utf8 encode/decode multibyte', () => { expect(bufferToString(ab, 'utf-8')).to.equal(str); }); -test(SUITE, 'utf8 alias "utf8" works', () => { - const str = 'test'; - const ab = stringToBuffer(str, 'utf8'); - expect(bufferToString(ab, 'utf8')).to.equal(str); -}); - test(SUITE, '[Node.js] Test for proper UTF-8 Encoding', () => { expect(toU8(stringToBuffer('\u00fcber', 'utf8'))).to.deep.equal( new Uint8Array([195, 188, 98, 101, 114]), @@ -800,12 +794,6 @@ test( // --- UTF-16LE --- -test(SUITE, '[Node.js] Roundtrips ASCII text through utf16le encoding.', () => { - const str = 'foo'; - const ab = stringToBuffer(str, 'utf16le'); - expect(bufferToString(ab, 'utf16le')).to.equal(str); -}); - test( SUITE, 'Roundtrips UTF-16LE text containing an unpaired high surrogate.', @@ -876,6 +864,44 @@ test( }, ); +// --- General --- + +test( + SUITE, + '[Node.js] Try to create 0-length buffers. Should not throw.', + () => { + const encodings = [ + 'utf8', + 'utf16le', + 'ascii', + 'latin1', + 'binary', + 'base64', + 'base64url', + 'hex', + ] as const; + + for (const encoding of encodings) { + const ab = stringToBuffer('', encoding); + expect(ab.byteLength).to.equal(0); + expect(bufferToString(ab, encoding)).to.equal(''); + } + }, +); + +test( + SUITE, + "[Node.js] Buffer.from('foo', encoding).toString(encoding) returns 'foo'.", + () => { + const encodings = ['utf8', 'utf16le', 'ascii', 'latin1', 'binary'] as const; + + for (const encoding of encodings) { + const ab = stringToBuffer('foo', encoding); + expect(bufferToString(ab, encoding)).to.equal('foo'); + } + }, +); + // --- Latin1 / Binary --- test( @@ -899,6 +925,42 @@ test( }, ); +test(SUITE, '[Node.js] Data "Hello, ÆÊÎÖÿ".', () => { + const str = 'Hello, ÆÊÎÖÿ'; + const expected = new Uint8Array([ + ...Array.from('Hello, ', c => c.charCodeAt(0)), + 0xc6, + 0xca, + 0xce, + 0xd6, + 0xff, + ]); + const ab = stringToBuffer(str, 'latin1'); + + expect(toU8(ab)).to.deep.equal(expected); + expect(bufferToString(expected.buffer as ArrayBuffer, 'latin1')).to.equal( + str, + ); +}); + +test( + SUITE, + '[Node.js] Verify that StringBytes::Write converts two-byte characters to one-byte characters, even if there is no valid one-byte representation.', + () => { + const expected = new Uint8Array([ + ...Array.from('Hello, ', c => c.charCodeAt(0)), + 0x16, + 0x4c, + ]); + const ab = stringToBuffer('Hello, 世界', 'latin1'); + + expect(toU8(ab)).to.deep.equal(expected); + expect(bufferToString(ab, 'latin1')).to.equal( + String.fromCharCode(...expected), + ); + }, +); + test(SUITE, 'latin1 roundtrip all byte values 0x00-0xFF', () => { const bytes = new Uint8Array(256); for (let i = 0; i < 256; i++) bytes[i] = i; @@ -947,8 +1009,62 @@ test( }, ); +test( + SUITE, + '[Node.js] Manually controlled string for checking binary output', + () => { + const ucs2Control = 'a\u0000'; + const writeStr = 'a'; + const bytes = toU8(stringToBuffer(writeStr, 'utf16le')); + + expect(bytes[0]).to.equal(0x61); + expect(bytes[1]).to.equal(0); + expect(bufferToString(bytes.buffer as ArrayBuffer, 'latin1')).to.equal( + ucs2Control, + ); + expect(bufferToString(bytes.buffer as ArrayBuffer, 'binary')).to.equal( + ucs2Control, + ); + }, +); + // --- ASCII --- +test(SUITE, '[Node.js] ASCII slice test', () => { + { + const asciiString = 'hello world'; + const bytes = new Uint8Array(128); + + for (let i = 0; i < asciiString.length; i++) { + bytes[i] = asciiString.charCodeAt(i); + } + const asciiSlice = bufferToString( + bytes.buffer as ArrayBuffer, + 'ascii', + 0, + asciiString.length, + ); + + expect(asciiSlice).to.equal(asciiString); + } + + { + const asciiString = 'hello world'; + const offset = 100; + const bytes = new Uint8Array(128); + + bytes.set(toU8(stringToBuffer(asciiString, 'ascii')), offset); + const asciiSlice = bufferToString( + bytes.buffer as ArrayBuffer, + 'ascii', + offset, + offset + asciiString.length, + ); + + expect(asciiSlice).to.equal(asciiString); + } +}); + test(SUITE, 'ascii roundtrip printable ASCII', () => { const str = 'Hello, World! 123'; const ab = stringToBuffer(str, 'ascii'); diff --git a/packages/react-native-quick-crypto/cpp/utils/HybridUtils.cpp b/packages/react-native-quick-crypto/cpp/utils/HybridUtils.cpp index af4ae1f9..bbb57086 100644 --- a/packages/react-native-quick-crypto/cpp/utils/HybridUtils.cpp +++ b/packages/react-native-quick-crypto/cpp/utils/HybridUtils.cpp @@ -175,7 +175,7 @@ namespace { throw std::runtime_error("Unsupported encoding: utf16le"); } - std::vector decodeLatin1(const std::string& str) { + std::vector decodeLatin1FromUtf8(const std::string& str) { std::vector result; result.reserve(str.size()); size_t i = 0; @@ -204,6 +204,43 @@ namespace { return result; } + template + std::vector decodeLatin1(facebook::jsi::Runtime& runtime, bool isHermes, const JSIString& str) { + if constexpr (HasStringGetStringData) { + if (isHermes) { + std::vector result; + auto chunkCallback = [&result](bool isAscii, const void* data, size_t num) { + if (num == 0) { + return; + } + + size_t offset = result.size(); + result.reserve(offset + num); // Allocate buffer conservatively + + if (isAscii) { + // Fast&direct copy path + const auto* asciiSrc = reinterpret_cast(data); + result.insert(result.end(), asciiSrc, asciiSrc + num); + return; + } + + result.resize(offset + num); + const auto* utf16Src = reinterpret_cast(data); + auto* dst = result.data() + offset; + for (size_t i = 0; i < num; i++) { + // Node.js-like behavior + dst[i] = static_cast(utf16Src[i] & 0xFFu); + } + }; + + str.getStringData(runtime, chunkCallback); + return result; + } + } + // Slow path for non-Hermes runtime/old RN versions + return decodeLatin1FromUtf8(str.utf8(runtime)); + } + std::string encodeLatin1(const uint8_t* data, size_t len) { if (len == 0) { return {}; @@ -231,6 +268,15 @@ bool HybridUtils::timingSafeEqual(const std::shared_ptr& a, const s return CRYPTO_memcmp(a->data(), b->data(), aLen) == 0; } +bool HybridUtils::isHermesRuntime(facebook::jsi::Runtime& runtime) { + // Cache assumes runtimes are long-lived and calls happen on the JS thread. + if (cachedRuntime_ != &runtime) [[unlikely]] { + cachedRuntime_ = &runtime; + cachedIsHermesRuntime_ = runtime.global().hasProperty(runtime, "HermesInternal"); + } + return cachedIsHermesRuntime_; +} + facebook::jsi::Value HybridUtils::bufferToJsiString(facebook::jsi::Runtime& runtime, const facebook::jsi::Value&, const facebook::jsi::Value* args, size_t argCount) { // Runtime argument check from react-native-nitro-modules/cpp/core/HybridFunction.hpp @@ -322,7 +368,7 @@ facebook::jsi::Value HybridUtils::jsiStringToBuffer(facebook::jsi::Runtime& runt runtime, ArrayBuffer::copy(reinterpret_cast(utf8Str.data()), utf8Str.size())); } if (encoding == "latin1" || encoding == "binary" || encoding == "ascii") { - auto decoded = decodeLatin1(str.utf8(runtime)); + auto decoded = decodeLatin1(runtime, isHermesRuntime(runtime), str); return JSIConverter>::toJSI(runtime, ArrayBuffer::move(std::move(decoded))); } if (encoding == "utf16le") { diff --git a/packages/react-native-quick-crypto/cpp/utils/HybridUtils.hpp b/packages/react-native-quick-crypto/cpp/utils/HybridUtils.hpp index 4d8bb108..4859ecbc 100644 --- a/packages/react-native-quick-crypto/cpp/utils/HybridUtils.hpp +++ b/packages/react-native-quick-crypto/cpp/utils/HybridUtils.hpp @@ -15,6 +15,10 @@ class HybridUtils : public HybridUtilsSpec { void loadHybridMethods() override; private: + facebook::jsi::Runtime* cachedRuntime_ = nullptr; + bool cachedIsHermesRuntime_ = false; + + bool isHermesRuntime(facebook::jsi::Runtime& runtime); facebook::jsi::Value bufferToJsiString(facebook::jsi::Runtime& runtime, const facebook::jsi::Value& thisArg, const facebook::jsi::Value* args, size_t argCount); facebook::jsi::Value jsiStringToBuffer(facebook::jsi::Runtime& runtime, const facebook::jsi::Value& thisArg,