diff --git a/example/src/tests/utils/encoding_tests.ts b/example/src/tests/utils/encoding_tests.ts index 12408e0d..d9411681 100644 --- a/example/src/tests/utils/encoding_tests.ts +++ b/example/src/tests/utils/encoding_tests.ts @@ -8,6 +8,226 @@ const SUITE = 'utils'; const toU8 = (ab: ArrayBuffer): Uint8Array => new Uint8Array(ab); +// --- Range arguments --- + +test( + SUITE, + "[Node.js] bufferToString: Return empty string if start >= buffer's length.", + () => { + const ab = stringToBuffer('abc', 'ascii'); + expect(bufferToString(ab, 'ascii', 3)).to.equal(''); + expect(bufferToString(ab, 'ascii', Number.POSITIVE_INFINITY)).to.equal(''); + expect(bufferToString(ab, 'ascii', 3.14, 3)).to.equal(''); + expect( + bufferToString(ab, 'ascii', 'Infinity' as unknown as number, 3), + ).to.equal(''); + }, +); + +test(SUITE, '[Node.js] bufferToString: Return empty string if end <= 0', () => { + const ab = stringToBuffer('abc', 'ascii'); + expect(bufferToString(ab, 'ascii', 1, 0)).to.equal(''); + expect(bufferToString(ab, 'ascii', 1, -1.2)).to.equal(''); + expect(bufferToString(ab, 'ascii', 1, -100)).to.equal(''); + expect(bufferToString(ab, 'ascii', 1, Number.NEGATIVE_INFINITY)).to.equal(''); +}); + +test( + SUITE, + '[Node.js] bufferToString: If start < 0, start will be taken as zero.', + () => { + const ab = stringToBuffer('abc', 'ascii'); + const starts = [ + -1, + -1.99, + Number.NEGATIVE_INFINITY, + '-1', + '-1.99', + '-Infinity', + ] as const; + + for (const start of starts) { + expect( + bufferToString(ab, 'ascii', start as unknown as number, 3), + ).to.equal('abc'); + } + }, +); + +test( + SUITE, + '[Node.js] bufferToString: If start is an invalid integer, start will be taken as zero.', + () => { + const ab = stringToBuffer('abc', 'ascii'); + const starts = ['node.js', {}, [], NaN, null, undefined, false, '']; + + for (const start of starts) { + expect( + bufferToString(ab, 'ascii', start as unknown as number, 3), + ).to.equal('abc'); + } + }, +); + +test( + SUITE, + '[Node.js] bufferToString: Use start values that coerce to integers', + () => { + const ab = stringToBuffer('abc', 'ascii'); + const cases: Array<[unknown, string]> = [ + ['-1', 'abc'], + ['1', 'bc'], + ['-Infinity', 'abc'], + ['3', ''], + [Number(3), ''], + ['3.14', ''], + ['1.99', 'bc'], + ['-1.99', 'abc'], + [1.99, 'bc'], + [true, 'bc'], + ]; + + for (const [start, expected] of cases) { + expect( + bufferToString(ab, 'ascii', start as unknown as number, 3), + ).to.equal(expected); + } + }, +); + +test( + SUITE, + "[Node.js] bufferToString: If end > buffer's length, end will be taken as buffer's length.", + () => { + const ab = stringToBuffer('abc', 'ascii'); + const ends = [5, 6.99, Number.POSITIVE_INFINITY, '5', '6.99', 'Infinity']; + + for (const end of ends) { + expect(bufferToString(ab, 'ascii', 0, end as unknown as number)).to.equal( + 'abc', + ); + } + }, +); + +test( + SUITE, + '[Node.js] bufferToString: Handle invalid end values according to Buffer.prototype.toString() coercion rules.', + () => { + const ab = stringToBuffer('abc', 'ascii'); + const cases: Array<[unknown, string]> = [ + ['node.js', ''], + [{}, ''], + [NaN, ''], + [undefined, 'abc'], + [null, ''], + [[], ''], + [false, ''], + ['', ''], + ]; + + for (const [end, expected] of cases) { + expect(bufferToString(ab, 'ascii', 0, end as unknown as number)).to.equal( + expected, + ); + } + expect(bufferToString(ab, 'ascii', 0)).to.equal('abc'); + }, +); + +test( + SUITE, + '[Node.js] bufferToString: Use end values that coerce to integers', + () => { + const ab = stringToBuffer('abc', 'ascii'); + const cases: Array<[unknown, string]> = [ + ['-1', ''], + ['1', 'a'], + ['-Infinity', ''], + ['3', 'abc'], + [Number(3), 'abc'], + ['3.14', 'abc'], + ['1.99', 'a'], + ['-1.99', ''], + [1.99, 'a'], + [true, 'a'], + ]; + + for (const [end, expected] of cases) { + expect(bufferToString(ab, 'ascii', 0, end as unknown as number)).to.equal( + expected, + ); + } + }, +); + +test( + SUITE, + '[Node.js] bufferToString: Test hex/base64/base64url partial range encoding', + () => { + const hex = new Uint8Array([0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe]) + .buffer as ArrayBuffer; + expect(bufferToString(hex, 'hex')).to.equal('deadbeefcafe'); + expect(bufferToString(hex, 'hex', 0, 3)).to.equal('deadbe'); + expect(bufferToString(hex, 'hex', 2, 5)).to.equal('beefca'); + expect(bufferToString(hex, 'hex', 4)).to.equal('cafe'); + expect(bufferToString(hex, 'hex', 6)).to.equal(''); + expect(bufferToString(hex, 'hex', 0, 0)).to.equal(''); + + const hello = stringToBuffer('Hello, World!', 'utf8'); + expect(bufferToString(hello, 'base64')).to.equal('SGVsbG8sIFdvcmxkIQ=='); + expect(bufferToString(hello, 'base64', 0, 5)).to.equal('SGVsbG8='); + expect(bufferToString(hello, 'base64', 7)).to.equal('V29ybGQh'); + expect(bufferToString(hello, 'base64', 0, 0)).to.equal(''); + + expect(bufferToString(hello, 'base64url')).to.equal('SGVsbG8sIFdvcmxkIQ'); + expect(bufferToString(hello, 'base64url', 0, 5)).to.equal('SGVsbG8'); + expect(bufferToString(hello, 'base64url', 7)).to.equal('V29ybGQh'); + expect(bufferToString(hello, 'base64url', 0, 0)).to.equal(''); + }, +); + +test( + SUITE, + '[Node.js] bufferToString: Test with pool-allocated buffer (has non-zero byteOffset)', + () => { + const data = toU8(stringToBuffer('test data for hex encoding', 'utf8')); + const backing = new Uint8Array(data.length + 8); + backing.set(data, 4); + + const poolBuf = backing.subarray(4, 4 + data.length); + const exactHex = bufferToString(data.buffer as ArrayBuffer, 'hex'); + const dataHex = bufferToString(stringToBuffer('data', 'utf8'), 'hex'); + + expect( + bufferToString( + poolBuf.buffer as ArrayBuffer, + 'hex', + poolBuf.byteOffset, + poolBuf.byteOffset + poolBuf.byteLength, + ), + ).to.equal(exactHex); + expect( + bufferToString( + poolBuf.buffer as ArrayBuffer, + 'hex', + poolBuf.byteOffset + 5, + poolBuf.byteOffset + 9, + ), + ).to.equal(dataHex); + }, +); + +test(SUITE, '[Node.js] bufferToString: Test an invalid slice end.', () => { + const ab = new Uint8Array([1, 2, 3, 4, 5]).buffer as ArrayBuffer; + const b2 = bufferToString(ab, 'hex', 1, 10000); + const b3 = bufferToString(ab, 'hex', 1, 5); + const b4 = bufferToString(ab, 'hex', 1); + + expect(b2).to.equal(b3); + expect(b2).to.equal(b4); +}); + // --- Hex --- test(SUITE, 'hex encode empty buffer', () => { @@ -643,6 +863,19 @@ test(SUITE, '[Node.js] Decodes UTF-16LE bytes back to Japanese text.', () => { ); }); +test( + SUITE, + '[Node.js] Decode UTF-16LE bytes back to Japanese text from byte offset 1.', + () => { + const bytes = new Uint8Array([ + 0xff, 0x42, 0x30, 0x44, 0x30, 0x46, 0x30, 0x48, 0x30, 0x4a, 0x30, + ]); + expect(bufferToString(bytes.buffer as ArrayBuffer, 'utf16le', 1)).to.equal( + 'あいうえお', + ); + }, +); + // --- Latin1 / Binary --- test( diff --git a/packages/react-native-quick-crypto/cpp/utils/HybridUtils.cpp b/packages/react-native-quick-crypto/cpp/utils/HybridUtils.cpp index 43c09b4c..7e01be50 100644 --- a/packages/react-native-quick-crypto/cpp/utils/HybridUtils.cpp +++ b/packages/react-native-quick-crypto/cpp/utils/HybridUtils.cpp @@ -234,44 +234,56 @@ bool HybridUtils::timingSafeEqual(const std::shared_ptr& a, const s 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 - if (argCount != 2) [[unlikely]] { + if (argCount != 4) [[unlikely]] { throw facebook::jsi::JSError(runtime, - "`Utils.bufferToString(...)` expected 2 arguments, but received " + std::to_string(argCount) + "!"); + "`Utils.bufferToString(...)` expected 4 arguments, but received " + std::to_string(argCount) + "!"); } // Exception wrapper from react-native-nitro-modules/cpp/core/HybridFunction.hpp try { - // bufferToString(buffer: ArrayBuffer, encoding: string): string; Defined in utils/conversion.ts + // bufferToString(buffer: ArrayBuffer, encoding: string, start: number, end: number): string; + // Defined in utils/conversion.ts auto buffer = JSIConverter>::fromJSI(runtime, args[0]); std::string encoding = JSIConverter::fromJSI(runtime, args[1]); + const size_t bufferSize = buffer->size(); + // `start` and `end` are normalized in the TS code + // so it's safe to use `static_cast` here + const size_t start = static_cast(JSIConverter::fromJSI(runtime, args[2])); + const size_t end = static_cast(JSIConverter::fromJSI(runtime, args[3])); + if (start > end || end > bufferSize) { + // This should never happen if called from the TS wrapper + // Add this check to avoid out of bounds access + throw std::runtime_error("Invalid start/end value"); + } + const size_t offset = start; + const size_t length = end - start; - const auto* data = reinterpret_cast(buffer->data()); - size_t len = buffer->size(); + const auto* data = reinterpret_cast(buffer->data() + offset); if (encoding == "hex") { - return facebook::jsi::String::createFromUtf8(runtime, encodeHex(data, len)); + return facebook::jsi::String::createFromUtf8(runtime, encodeHex(data, length)); } if (encoding == "base64") { - return facebook::jsi::String::createFromUtf8(runtime, encodeBase64(data, len)); + return facebook::jsi::String::createFromUtf8(runtime, encodeBase64(data, length)); } if (encoding == "base64url") { - return facebook::jsi::String::createFromUtf8(runtime, encodeBase64Url(data, len)); + return facebook::jsi::String::createFromUtf8(runtime, encodeBase64Url(data, length)); } if (encoding == "utf8" || encoding == "utf-8") { - return facebook::jsi::String::createFromUtf8(runtime, data, len); + return facebook::jsi::String::createFromUtf8(runtime, data, length); } if (encoding == "latin1" || encoding == "binary") { - return facebook::jsi::String::createFromUtf8(runtime, encodeLatin1(data, len)); + return facebook::jsi::String::createFromUtf8(runtime, encodeLatin1(data, length)); } if (encoding == "ascii") { - std::string result(reinterpret_cast(data), len); + std::string result(reinterpret_cast(data), length); for (auto& c : result) { c &= 0x7F; } return facebook::jsi::String::createFromUtf8(runtime, result); } if (encoding == "utf16le") { - return createUtf16LeString(runtime, data, len); + return createUtf16LeString(runtime, data, length); } throw std::runtime_error("Unsupported encoding: " + encoding); } catch (const std::exception& exception) { @@ -329,7 +341,7 @@ facebook::jsi::Value HybridUtils::jsiStringToBuffer(facebook::jsi::Runtime& runt void HybridUtils::loadHybridMethods() { HybridUtilsSpec::loadHybridMethods(); registerHybrids(this, [](Prototype& prototype) { - prototype.registerRawHybridMethod("bufferToString", 2, &HybridUtils::bufferToJsiString); + prototype.registerRawHybridMethod("bufferToString", 4, &HybridUtils::bufferToJsiString); prototype.registerRawHybridMethod("stringToBuffer", 2, &HybridUtils::jsiStringToBuffer); }); } diff --git a/packages/react-native-quick-crypto/src/utils/conversion.ts b/packages/react-native-quick-crypto/src/utils/conversion.ts index 77f9675c..3a9f7566 100644 --- a/packages/react-native-quick-crypto/src/utils/conversion.ts +++ b/packages/react-native-quick-crypto/src/utils/conversion.ts @@ -6,7 +6,12 @@ import type { ABV, BinaryLikeNode, BufferLike } from './types'; import { Platform } from 'react-native'; type UtilsWithStringConverter = Utils & { - bufferToString(buffer: ArrayBuffer, encoding: string): string; + bufferToString( + buffer: ArrayBuffer, + encoding: string, + start?: number, + end?: number, + ): string; stringToBuffer(str: string, encoding: string): ArrayBuffer; }; @@ -223,19 +228,46 @@ export function binaryLikeToArrayBuffer( ); } -export function ab2str(buf: ArrayBuffer, encoding: string = 'hex'): string { +export function ab2str( + buf: ArrayBuffer, + encoding: string = 'hex', + start?: number, + end?: number, +): string { if (nativeBufferToStringEncodings.has(encoding)) { - return utils.bufferToString(buf, encoding); + return bufferToString(buf, encoding, start, end); } - return CraftzdogBuffer.from(buf).toString(encoding); + + return CraftzdogBuffer.from(buf).toString(encoding, start, end); } -/** Native C++ buffer-to-string — exposed for benchmarking */ +/** Native C++ buffer-to-string with arguments normalization*/ export function bufferToString( buf: ArrayBuffer, encoding: string = 'hex', + start?: number, + end?: number, ): string { - return utils.bufferToString(buf, encoding); + // https://github.com/nodejs/node/blob/v24.15.0/lib/buffer.js#L915-L928 + if (start === undefined || start < 0) { + start = 0; + } else if (start >= buf.byteLength) { + return ''; + } else { + start = Math.trunc(start) || 0; + } + + if (end === undefined || end > buf.byteLength) { + end = buf.byteLength; + } else { + end = Math.trunc(end) || 0; + } + + if (end <= start) { + return ''; + } + + return utils.bufferToString(buf, encoding, start, end); } /** Native C++ string-to-buffer — exposed for benchmarking */