diff --git a/src/backend/drivers/ai-tts/providers/gemini/GeminiTTSProvider.test.ts b/src/backend/drivers/ai-tts/providers/gemini/GeminiTTSProvider.test.ts index c3c6857e8..395104730 100644 --- a/src/backend/drivers/ai-tts/providers/gemini/GeminiTTSProvider.test.ts +++ b/src/backend/drivers/ai-tts/providers/gemini/GeminiTTSProvider.test.ts @@ -28,6 +28,7 @@ */ import { Readable } from 'node:stream'; +import v8 from 'node:v8'; import { afterAll, afterEach, @@ -660,6 +661,28 @@ describe('GeminiTTSProvider.synthesize audio output', () => { expect(wav.subarray(44)).toEqual(pcm); }); + // V8 stops optimizing regexps once a process holds enough compiled code, + // as long-running servers do. + it('streams audio over 4 MB of base64 with regexp optimization off', async () => { + const pcm = Buffer.alloc(4 * 1024 * 1024, 7); + replyWith( + interaction({ content: [audioBlock(pcm, 'audio/L16;rate=24000')] }), + ); + + v8.setFlagsFromString('--no-regexp-optimization'); + let wav: Buffer; + try { + const result = (await withTestActor(() => + makeProvider().synthesize({ text: 'hi' }), + )) as { stream: Readable }; + wav = await readAll(result.stream); + } finally { + v8.setFlagsFromString('--regexp-optimization'); + } + expect(wav.length).toBe(44 + pcm.length); + expect(wav.subarray(44).equals(pcm)).toBe(true); + }); + it('wraps PCM without a declared rate at 24 kHz', async () => { replyWith( interaction({ diff --git a/src/backend/drivers/ai-tts/providers/gemini/GeminiTTSProvider.ts b/src/backend/drivers/ai-tts/providers/gemini/GeminiTTSProvider.ts index 1d7295847..ed70d5dad 100644 --- a/src/backend/drivers/ai-tts/providers/gemini/GeminiTTSProvider.ts +++ b/src/backend/drivers/ai-tts/providers/gemini/GeminiTTSProvider.ts @@ -37,6 +37,7 @@ import { postGeminiJson, geminiTokenCount as tokenCount, } from '../../../util/geminiRest.js'; +import { decodeStrictBase64 } from '../../../../util/mediaSniff.js'; import { isUpstreamTimeoutError } from '../../../util/upstreamErrors.js'; import { SAMPLE_AUDIO_URL } from '../../../util/testMode.js'; import type { @@ -154,7 +155,6 @@ interface Interaction { const WAV_MIME_TYPES = new Set(['audio/wav', 'audio/x-wav', 'audio/wave']); const PCM_MIME_TYPES = new Set(['audio/l16', 'audio/pcm']); -const BASE64_PATTERN = /^[A-Za-z0-9+/_-]+={0,2}$/; const SPLIT_TEXT_HINT = 'Split the text into shorter requests.'; @@ -524,14 +524,11 @@ export class GeminiTTSProvider implements ITTSProvider { contentType: string; } { const { data, mime_type: mimeType } = audio; - if ( - typeof data !== 'string' || - !BASE64_PATTERN.test(data) || - typeof mimeType !== 'string' - ) { + const bytes = + typeof data === 'string' ? decodeStrictBase64(data) : null; + if (!bytes || typeof mimeType !== 'string') { throw upstreamFailure('Gemini TTS returned invalid audio data'); } - const bytes = Buffer.from(data, 'base64'); const [essence, ...params] = mimeType .split(';') .map((part) => part.trim().toLowerCase()); diff --git a/src/backend/util/appIcon.test.ts b/src/backend/util/appIcon.test.ts index 86715c5c4..aa9b87797 100644 Binary files a/src/backend/util/appIcon.test.ts and b/src/backend/util/appIcon.test.ts differ diff --git a/src/backend/util/appIcon.ts b/src/backend/util/appIcon.ts index 1b288db7f..4af68fa60 100644 --- a/src/backend/util/appIcon.ts +++ b/src/backend/util/appIcon.ts @@ -71,7 +71,6 @@ export interface AppIconHostConfig extends TrustedIconHostConfig { pub_port?: number; } -const RAW_BASE64_REGEX = /^[A-Za-z0-9+/]+={0,2}$/; // `data:/[;param[=value]]…,`. The parameter list is // matched as a group of its own so it can be checked exhaustively — the // previous prefix-scan only looked at the bytes before the first `;` or `,` @@ -185,7 +184,6 @@ function sniffRawBase64Image( if (typeof value !== 'string') return null; const trimmed = value.trim(); if (trimmed.length < 16) return null; - if (!RAW_BASE64_REGEX.test(trimmed)) return null; const bytes = decodeStrictBase64(trimmed); if (!bytes) return null; const mime = sniffImageMime(bytes); diff --git a/src/backend/util/mediaSniff.test.ts b/src/backend/util/mediaSniff.test.ts new file mode 100644 index 000000000..8e95f7dd7 --- /dev/null +++ b/src/backend/util/mediaSniff.test.ts @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { describe, expect, it } from 'vitest'; +import { decodeStrictBase64 } from './mediaSniff.ts'; + +describe('decodeStrictBase64', () => { + it.each([ + ['no padding', Buffer.from('abc')], + ['one pad character', Buffer.from('ab')], + ['two pad characters', Buffer.from('a')], + ])('decodes base64 with %s', (_label, bytes) => { + expect(decodeStrictBase64(bytes.toString('base64'))).toEqual(bytes); + }); + + it.each([ + ['empty input', ''], + ['unpadded input', 'YWI'], + ['the URL-safe alphabet', 'a-b_'], + ['whitespace', 'YW Jj'], + ['a character outside the alphabet', 'YW*j'], + ['a non-ASCII character whose low byte is in the alphabet', 'YWJī'], + ['padding mid-string', 'YQ==YWJj'], + ['three pad characters', 'Y==='], + ])('rejects %s', (_label, value) => { + expect(decodeStrictBase64(value)).toBeNull(); + }); + + it('rejects one bad character anywhere in a multi-MB payload', () => { + const valid = Buffer.alloc(6 * 1024 * 1024, 7).toString('base64'); + for (const at of [0, valid.length / 2, valid.length - 1]) { + const corrupt = `${valid.slice(0, at)}*${valid.slice(at + 1)}`; + expect(decodeStrictBase64(corrupt)).toBeNull(); + } + }); +}); diff --git a/src/backend/util/mediaSniff.ts b/src/backend/util/mediaSniff.ts index 24b53d71a..d0587eeab 100644 --- a/src/backend/util/mediaSniff.ts +++ b/src/backend/util/mediaSniff.ts @@ -20,8 +20,6 @@ // Identify uploaded media by its bytes. A caller-supplied MIME type or file // extension is never trusted. -const BASE64_CHARS_REGEX = /^[A-Za-z0-9+/]*={0,2}$/; - /** * How far into a payload to look for an `