diff --git a/packages/core/src/ai-model/shared/model-locate-result/parse.ts b/packages/core/src/ai-model/shared/model-locate-result/parse.ts index dd8f8d7a26..35311ad319 100644 --- a/packages/core/src/ai-model/shared/model-locate-result/parse.ts +++ b/packages/core/src/ai-model/shared/model-locate-result/parse.ts @@ -43,9 +43,18 @@ export function parseCoordinateList(input: unknown, label: string): number[] { throw new Error(`invalid ${label} data: ${JSON.stringify(input)} `); } - const numericValues = values.map((value) => - typeof value === 'number' ? value : Number(value), - ); + const numericValues = values.map((value) => { + if (typeof value === 'number') { + return value; + } + if ( + typeof value !== 'string' || + !/^[+-]?(?:\d+\.?\d*|\.\d+)$/.test(value.trim()) + ) { + return Number.NaN; + } + return Number(value); + }); if (!numericValues.every((value) => Number.isFinite(value))) { throw new Error(`invalid ${label} data: ${JSON.stringify(input)} `); @@ -90,7 +99,7 @@ export function parseNumericLocateResult( ): LocateResultValue { if (resolvedCoordinates.shape === 'point') { const point = parseCoordinateList(input, 'point'); - if (point.length < 2) { + if (point.length !== 2) { throw new Error(`invalid point data: ${JSON.stringify(input)} `); } return createLocateResultValue(resolvedCoordinates, point); diff --git a/packages/core/tests/unit-test/locate-result-codec.test.ts b/packages/core/tests/unit-test/locate-result-codec.test.ts index 21d07de863..d4064a2da0 100644 --- a/packages/core/tests/unit-test/locate-result-codec.test.ts +++ b/packages/core/tests/unit-test/locate-result-codec.test.ts @@ -122,6 +122,35 @@ describe('createLocateResultCodec', () => { ).toThrow(/invalid bbox data/); }); + it.each([null, true, false, '', ' '])( + 'rejects coercible non-coordinate bbox value: %j', + (invalidValue) => { + const codec = createLocateResultCodec({ + coordinates: { shape: 'bbox', order: 'xy', normalizedBy: 1000 }, + }); + + expect(() => + codec.toPixelBbox( + [invalidValue, 100, 300, 400] as never, + locateCtx(640, 360), + ), + ).toThrow(/invalid bbox data/); + }, + ); + + it('accepts decimal coordinate strings without general JavaScript coercion', () => { + const codec = createLocateResultCodec({ + coordinates: { shape: 'bbox', order: 'xy', normalizedBy: 1000 }, + }); + + expect( + codec.toPixelBbox( + ['100', '200.5', '300.25', '400'] as never, + locateCtx(200, 100), + ), + ).toEqual([20, 20, 60, 40]); + }); + it('rejects invalid parsed adapter results before coordinate range checks', () => { const codec = createLocateResultCodec({ coordinates: { shape: 'bbox', order: 'xy', normalizedBy: 1000 }, @@ -158,6 +187,16 @@ describe('createLocateResultCodec', () => { ); }); + it('rejects point coordinate values with more than two entries', () => { + const codec = createLocateResultCodec({ + coordinates: { shape: 'point', order: 'xy', normalizedBy: 1000 }, + }); + + expect(() => + codec.toPixelBbox([500, 500, 500], locateCtx(640, 360)), + ).toThrow(/invalid point data/); + }); + it('rejects non-positive normalizedBy values', () => { expect(() => createLocateResultCodec({