diff --git a/package.json b/package.json index 2934606..f5a176c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@loop-payments/quickjs", - "version": "3.2.0", + "version": "3.2.1", "description": "A typescript package to execute JavaScript and TypeScript code in a WebAssembly QuickJS sandbox", "engines": { "node": ">=18.0.0" diff --git a/src/modules/util.js b/src/modules/util.js index 7e9261e..dc3b563 100644 --- a/src/modules/util.js +++ b/src/modules/util.js @@ -13,6 +13,32 @@ class TextEncoder { } } +// The engine limits the number of arguments for one function call. The +// decoder sends the bytes in chunks of this size to stay under the limit. +const DECODE_CHUNK_SIZE = 8192; + +// The WHATWG encoding standard defines the decoder input as a BufferSource. A +// BufferSource is an ArrayBuffer or a view on an ArrayBuffer. The decoder +// reads the bytes through a Uint8Array. An ArrayBuffer has no index access, +// and a signed view returns negative numbers. +function toUint8Array(input) { + if (input === undefined) { + return new Uint8Array(); + } + if (input instanceof Uint8Array) { + return input; + } + if (ArrayBuffer.isView(input)) { + // The new array must keep the offset and the length of the input + // view. An array over the full buffer decodes the wrong bytes. + return new Uint8Array(input.buffer, input.byteOffset, input.byteLength); + } + if (input instanceof ArrayBuffer) { + return new Uint8Array(input); + } + throw new TypeError('TextDecoder.decode accepts an ArrayBuffer or a view on an ArrayBuffer'); +} + class TextDecoder { constructor(encoding = 'utf-8') { if (encoding !== 'utf-8') { @@ -20,12 +46,16 @@ class TextDecoder { } } - decode(input = new Uint8Array()) { - let str = ''; - for (let i = 0; i < input.length; i++) { - str += String.fromCharCode(input[i]); + decode(input) { + const bytes = toUint8Array(input); + // One call to String.fromCharCode per chunk is much faster than one + // call per byte. A chunk that splits a multibyte sequence is safe, + // because decodeURIComponent reads the full string. + let latin1 = ''; + for (let i = 0; i < bytes.length; i += DECODE_CHUNK_SIZE) { + latin1 += String.fromCharCode.apply(null, bytes.subarray(i, i + DECODE_CHUNK_SIZE)); } - return decodeURIComponent(escape(str)); + return decodeURIComponent(escape(latin1)); } } diff --git a/src/test/async/util.test.ts b/src/test/async/util.test.ts index a3e6fe6..526a94f 100644 --- a/src/test/async/util.test.ts +++ b/src/test/async/util.test.ts @@ -92,4 +92,95 @@ describe('async - node:util - base', () => { expect(result.ok).toBeTrue() expect((result as OkResponse).data).toBe('Test passed') }) + + describe('TextDecoder', () => { + const decode = async (setup: string, argument: string) => { + const result = await runtime.runSandboxed(async ({ evalCode }) => { + return await evalCode(` + ${setup} + export default new TextDecoder().decode(${argument}) + `) + }) + expect(result.ok).toBeTrue() + return (result as OkResponse).data + } + + const decodeError = async (argument: string) => { + const result = await runtime.runSandboxed(async ({ evalCode }) => { + return await evalCode(` + let outcome = 'the decode call did not throw' + try { + new TextDecoder().decode(${argument}) + } catch (error) { + outcome = error.constructor.name + ': ' + error.message + } + export default outcome + `) + }) + expect(result.ok).toBeTrue() + return (result as OkResponse).data + } + + const helloWorld = "const bytes = new TextEncoder().encode('hello world')" + + it('decodes an ArrayBuffer', async () => { + expect(await decode(helloWorld, 'bytes.buffer')).toBe('hello world') + }) + + it('decodes a DataView', async () => { + expect(await decode(helloWorld, 'new DataView(bytes.buffer)')).toBe('hello world') + }) + + it('decodes a Uint8Array', async () => { + expect(await decode(helloWorld, 'bytes')).toBe('hello world') + }) + + it('decodes a view that starts at a byte offset', async () => { + expect(await decode(helloWorld, 'new Uint8Array(bytes.buffer, 6)')).toBe('world') + }) + + it('decodes a view that has a shorter byte length', async () => { + expect(await decode(helloWorld, 'new Uint8Array(bytes.buffer, 0, 5)')).toBe('hello') + }) + + it('decodes a view that has signed elements', async () => { + const setup = "const bytes = new TextEncoder().encode('héllo')" + expect(await decode(setup, 'new Int8Array(bytes.buffer)')).toBe('héllo') + }) + + it('decodes multibyte characters from an ArrayBuffer', async () => { + const setup = "const bytes = new TextEncoder().encode('héllo 🌍')" + expect(await decode(setup, 'bytes.buffer')).toBe('héllo 🌍') + }) + + it('returns an empty string for no argument', async () => { + expect(await decode('', '')).toBe('') + }) + + it('returns an empty string for an empty ArrayBuffer', async () => { + expect(await decode('', 'new ArrayBuffer(0)')).toBe('') + }) + + it('decodes a payload that is larger than one internal chunk', async () => { + const result = await runtime.runSandboxed(async ({ evalCode }) => { + return await evalCode(` + let source = '' + while (source.length < 100000) source += 'héllo 🌍 world ' + const bytes = new TextEncoder().encode(source) + const decoded = new TextDecoder().decode(bytes.buffer) + export default decoded === source ? 'match' : 'mismatch at length ' + decoded.length + `) + }) + expect(result.ok).toBeTrue() + expect((result as OkResponse).data).toBe('match') + }) + + it('throws a TypeError for a string', async () => { + expect(await decodeError("'hello world'")).toStartWith('TypeError') + }) + + it('throws a TypeError for null', async () => { + expect(await decodeError('null')).toStartWith('TypeError') + }) + }) }) diff --git a/src/test/sync/util.test.ts b/src/test/sync/util.test.ts index 8695711..829ed00 100644 --- a/src/test/sync/util.test.ts +++ b/src/test/sync/util.test.ts @@ -92,4 +92,95 @@ describe('sync - node:util - base', () => { expect(result.ok).toBeTrue() expect((result as OkResponse).data).toBe('Test passed') }) + + describe('TextDecoder', () => { + const decode = async (setup: string, argument: string) => { + const result = await runtime.runSandboxed(async ({ evalCode }) => { + return await evalCode(` + ${setup} + export default new TextDecoder().decode(${argument}) + `) + }) + expect(result.ok).toBeTrue() + return (result as OkResponse).data + } + + const decodeError = async (argument: string) => { + const result = await runtime.runSandboxed(async ({ evalCode }) => { + return await evalCode(` + let outcome = 'the decode call did not throw' + try { + new TextDecoder().decode(${argument}) + } catch (error) { + outcome = error.constructor.name + ': ' + error.message + } + export default outcome + `) + }) + expect(result.ok).toBeTrue() + return (result as OkResponse).data + } + + const helloWorld = "const bytes = new TextEncoder().encode('hello world')" + + it('decodes an ArrayBuffer', async () => { + expect(await decode(helloWorld, 'bytes.buffer')).toBe('hello world') + }) + + it('decodes a DataView', async () => { + expect(await decode(helloWorld, 'new DataView(bytes.buffer)')).toBe('hello world') + }) + + it('decodes a Uint8Array', async () => { + expect(await decode(helloWorld, 'bytes')).toBe('hello world') + }) + + it('decodes a view that starts at a byte offset', async () => { + expect(await decode(helloWorld, 'new Uint8Array(bytes.buffer, 6)')).toBe('world') + }) + + it('decodes a view that has a shorter byte length', async () => { + expect(await decode(helloWorld, 'new Uint8Array(bytes.buffer, 0, 5)')).toBe('hello') + }) + + it('decodes a view that has signed elements', async () => { + const setup = "const bytes = new TextEncoder().encode('héllo')" + expect(await decode(setup, 'new Int8Array(bytes.buffer)')).toBe('héllo') + }) + + it('decodes multibyte characters from an ArrayBuffer', async () => { + const setup = "const bytes = new TextEncoder().encode('héllo 🌍')" + expect(await decode(setup, 'bytes.buffer')).toBe('héllo 🌍') + }) + + it('returns an empty string for no argument', async () => { + expect(await decode('', '')).toBe('') + }) + + it('returns an empty string for an empty ArrayBuffer', async () => { + expect(await decode('', 'new ArrayBuffer(0)')).toBe('') + }) + + it('decodes a payload that is larger than one internal chunk', async () => { + const result = await runtime.runSandboxed(async ({ evalCode }) => { + return await evalCode(` + let source = '' + while (source.length < 100000) source += 'héllo 🌍 world ' + const bytes = new TextEncoder().encode(source) + const decoded = new TextDecoder().decode(bytes.buffer) + export default decoded === source ? 'match' : 'mismatch at length ' + decoded.length + `) + }) + expect(result.ok).toBeTrue() + expect((result as OkResponse).data).toBe('match') + }) + + it('throws a TypeError for a string', async () => { + expect(await decodeError("'hello world'")).toStartWith('TypeError') + }) + + it('throws a TypeError for null', async () => { + expect(await decodeError('null')).toStartWith('TypeError') + }) + }) })