diff --git a/reconcile-js/src/core.ts b/reconcile-js/src/core.ts index c277642..e9cd27b 100644 --- a/reconcile-js/src/core.ts +++ b/reconcile-js/src/core.ts @@ -133,7 +133,11 @@ export interface TextWithOptionalCursors { * relative positions as text is inserted, deleted, or modified around them. */ export interface CursorPosition { - /** Unique identifier for the cursor (can be any number, must be unique within the document) */ + /** + * Unique identifier for the cursor, unique within the document. + * Must be an integer from 0 through 2^32-1 inclusive (unsigned 32-bit). + * Non-integer or out-of-range input IDs throw `RangeError`. + */ id: number; /** @@ -325,6 +329,11 @@ export function makeReconcileApi< let textLength: number | undefined; try { for (const { id, position } of innerCursors) { + // Both backends store IDs as wasm32 usize values + if (!Number.isInteger(id) || id < 0 || id > 0xffff_ffff) { + throw new RangeError(`Cursor ID ${id} must be an integer between 0 and 2^32-1`); + } + if (textLength === undefined) { textLength = 0; for (const _character of innerText) { diff --git a/reconcile-js/src/index.test.ts b/reconcile-js/src/index.test.ts index e824724..e88f2ed 100644 --- a/reconcile-js/src/index.test.ts +++ b/reconcile-js/src/index.test.ts @@ -181,6 +181,50 @@ describe.each(backends)('WASM cleanup after exceptions [$name]', ({ api, binding }); describe.each(operations)('%s cursor validation', (operation) => { + it.each([ + -1, + -0.5, + 0.5, + 2 ** 32, + 2 ** 32 + 1, + Number.MAX_SAFE_INTEGER, + NaN, + Infinity, + -Infinity, + ])('rejects ID %s before it can be truncated', (id) => { + const input = { + text: 'Hello world', + cursors: [ + { id: 0, position: 1 }, + { id, position: 2 }, + ], + }; + const invoke = () => + operation === 'diff' + ? api.diff('Hello', input) + : api[operation]('Hello', left, input); + + expect(invoke).toThrow(RangeError); + expect(invoke).toThrow(`Cursor ID ${id} must be an integer between 0 and 2^32-1`); + }); + + it('accepts unsigned 32-bit IDs without changing them', () => { + const input = { + text: 'Hello world', + cursors: [ + { id: 0, position: 0 }, + { id: 2 ** 31, position: 1 }, + { id: 2 ** 32 - 1, position: 2 }, + ], + }; + if (operation === 'diff') { + expect(api.undiff('Hello', api.diff('Hello', input))).toBe(input.text); + } else { + expect(api[operation]('Hello', 'Hello', input).cursors).toEqual(input.cursors); + expect(api[operation]('Hello', input, 'Hello').cursors).toEqual(input.cursors); + } + }); + it.each([-1, 0.5, NaN, Infinity, 4])( 'rejects position %s before the WASM constructor can trap', (position) => { @@ -219,6 +263,16 @@ describe.each(backends)('WASM cleanup after exceptions [$name]', ({ api, binding }); }); + it.each(mergeOperations)('%s rejects an invalid ID in the left input', (operation) => { + expect(() => + api[operation]( + 'Hello', + { text: 'Hello world', cursors: [{ id: 2 ** 32, position: 0 }] }, + 'Hello' + ) + ).toThrow(RangeError); + }); + describe.each(mergeOperations)('%s result conversion', (operation) => { it.each(['id', 'characterIndex'] as const)( 'frees all result cursors if %s throws',