diff --git a/packages/datasource-customizer/src/decorators/binary/collection.ts b/packages/datasource-customizer/src/decorators/binary/collection.ts index 2f8652acbc..b1e17dedb5 100644 --- a/packages/datasource-customizer/src/decorators/binary/collection.ts +++ b/packages/datasource-customizer/src/decorators/binary/collection.ts @@ -16,7 +16,13 @@ import type { RecordData, } from '@forestadmin/datasource-toolkit'; -import { CollectionDecorator, SchemaUtils, TypeGetter } from '@forestadmin/datasource-toolkit'; +import { + CollectionDecorator, + SchemaUtils, + TypeGetter, + ValidationError, + parseDataUri, +} from '@forestadmin/datasource-toolkit'; import { filetypemime } from 'magic-bytes.js'; /** @@ -237,15 +243,31 @@ export default class BinaryCollectionDecorator extends CollectionDecorator { return value; } + private parseHex(value: string): Buffer { + if (!/^([0-9a-f]{2})*$/i.test(value)) { + throw new ValidationError( + `Expected an even-length hex string for a binary field, got "${value}"`, + ); + } + + return Buffer.from(value, 'hex'); + } + private async convertScalar( toBackend: boolean, useHex: boolean, value: unknown, ): Promise { if (toBackend) { - const string = value as string; + if (typeof value !== 'string') { + throw new ValidationError( + `Expected a string for a binary field, got ${typeof value}: ${JSON.stringify(value)}`, + ); + } + + if (useHex) return this.parseHex(value); - return useHex ? Buffer.from(string, 'hex') : Buffer.from(string.split(',')[1], 'base64'); + return parseDataUri(value).buffer; } const buffer = value as Buffer; diff --git a/packages/datasource-customizer/test/decorators/binary/collection.test.ts b/packages/datasource-customizer/test/decorators/binary/collection.test.ts index 5c773ec2a3..99afb9cb84 100644 --- a/packages/datasource-customizer/test/decorators/binary/collection.test.ts +++ b/packages/datasource-customizer/test/decorators/binary/collection.test.ts @@ -8,6 +8,7 @@ import { Filter, PaginatedFilter, Projection, + ValidationError, } from '@forestadmin/datasource-toolkit'; import * as factories from '@forestadmin/datasource-toolkit/dist/test/__factories__'; @@ -238,6 +239,66 @@ describe('BinaryCollectionDecorator', () => { }); }); + describe('list filtering a binary column with a value that is not a data uri', () => { + it('should reject the filter instead of crashing on an undefined base64 payload', async () => { + const caller = factories.caller.build(); + const filter = new PaginatedFilter({ + conditionTree: new ConditionTreeLeaf('cover', 'Equal', 'Anthony'), + }); + + await expect(decoratedBook.list(caller, filter, new Projection('id'))).rejects.toThrow( + ValidationError, + ); + expect(books.list).not.toHaveBeenCalled(); + }); + + it('should reject a data uri that is not base64, rather than decode it to garbage', async () => { + const caller = factories.caller.build(); + const filter = new PaginatedFilter({ + conditionTree: new ConditionTreeLeaf('cover', 'Equal', 'data:text/plain,hello'), + }); + + await expect(decoratedBook.list(caller, filter, new Projection('id'))).rejects.toThrow( + /must be a data uri/, + ); + expect(books.list).not.toHaveBeenCalled(); + }); + }); + + describe('list filtering a hex binary column with a value that is not hex', () => { + it('should reject it rather than query for an empty buffer', async () => { + const caller = factories.caller.build(); + const filter = new PaginatedFilter({ + conditionTree: new ConditionTreeLeaf('id', 'Equal', 'Anthony'), + }); + + await expect(decoratedBook.list(caller, filter, new Projection('id'))).rejects.toThrow( + /even-length hex string/, + ); + expect(books.list).not.toHaveBeenCalled(); + }); + + it('should reject an odd-length hex string, which silently drops its last digit', async () => { + const caller = factories.caller.build(); + const filter = new PaginatedFilter({ + conditionTree: new ConditionTreeLeaf('id', 'Equal', '303'), + }); + + await expect(decoratedBook.list(caller, filter, new Projection('id'))).rejects.toThrow( + /even-length hex string/, + ); + }); + }); + + describe('writing a non-string value into a binary column', () => { + it('should reject it with a validation error rather than an opaque TypeError', async () => { + const caller = factories.caller.build(); + + await expect(decoratedBook.create(caller, [{ cover: 42 }])).rejects.toThrow(ValidationError); + expect(books.create).not.toHaveBeenCalled(); + }); + }); + describe('list with a more complex filter', () => { // Build condition tree (30303030 is the hex representation of 0000) const conditionTree = new ConditionTreeBranch('Or', [