From ab698d00f32f0f625abd9f74fbf050d394517186 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 28 Sep 2026 01:24:58 +0400 Subject: [PATCH] fix(core): redact() Content-Disposition cut index; errorText never throws; design.md accuracy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M-1: redact()'s Content-Disposition cut computed its index against a lowercased copy but sliced the original string — some Unicode characters change length under toLowerCase(), which could let a fragment of "Content-Disposition" survive the cut. Now uses a case-insensitive regex matched directly against the original string. T3.5: errorText() could itself throw on a pathological thrown value (a null-prototype object with no toString/valueOf/Symbol.toPrimitive), defeating transcribe()'s "never throws" guarantee in an already-caught branch. Now wrapped defensively with a generic fallback message. Also corrects two stale descriptions in design.md (classify/redact signatures, and the post-promise rejection handling) so the archived design document matches the shipped code. Refs #5 Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa --- openspec/changes/openai-transcriber/design.md | 4 ++-- src/core/openai.ts | 12 +++++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/openspec/changes/openai-transcriber/design.md b/openspec/changes/openai-transcriber/design.md index 25e711d..830e548 100644 --- a/openspec/changes/openai-transcriber/design.md +++ b/openspec/changes/openai-transcriber/design.md @@ -32,7 +32,7 @@ Core (`src/core/`, issue #1) сохраняет запись и ведёт её |---|---| | `src/core/ports.ts` | + `HttpRequest`, `HttpResponse`, `HttpClient` | | `src/core/multipart.ts` | `buildMultipart(fields, file, boundary) → { body: ArrayBuffer, contentType }`, побайтно через `TextEncoder`/`Uint8Array` | -| `src/core/openai.ts` | `OpenAiSettings`, `DEFAULT_OPENAI`, `REQUEST_TIMEOUT_MS`, `readSettings` (trim всех четырёх полей + наложение на умолчания), `classify(status, text)`, `redact(message, apiKey)`, `OpenAiTranscriber` | +| `src/core/openai.ts` | `OpenAiSettings`, `DEFAULT_OPENAI`, `REQUEST_TIMEOUT_MS`, `readSettings` (trim всех четырёх полей + наложение на умолчания), `classify(res: HttpResponse)`, `redact(message, { apiKey, boundary })`, `OpenAiTranscriber` | | `src/core/naming.ts` | + `mimeFromExt(ext)` для команды проверки | | `src/obsidian/http.ts` | `HttpClient` на `requestUrl({ url, method: 'POST', headers, contentType, body, throw: false })` | | `src/obsidian/settings.ts` | `PluginSettingTab`: четыре поля, ключ — `inputEl.type = 'password'` | @@ -65,7 +65,7 @@ new OpenAiTranscriber(http: HttpClient, settings: () => OpenAiSettings, random: Настройки передаются геттером и читаются на каждом вызове: исправленный ключ применяется без пересоздания очереди. Boundary строится из `random` — детерминированно в тестах. -Поток `transcribe(audio, mime, signal)`: чтение и `trim` настроек → предпроверки (ключ, адрес, 0 байт, отменённый сигнал) → `buildMultipart` → `Promise.race(post, таймер, abort)` → `classify` → `redact` для любой причины. В `finally` снимаются таймер и слушатель `abort`; у промиса `post` висит пустой `.catch`, чтобы поздний reject не стал необработанным. +Поток `transcribe(audio, mime, signal)`: чтение и `trim` настроек → предпроверки (ключ, адрес, 0 байт, отменённый сигнал) → `buildMultipart` → `Promise.race(post, таймер, abort)` → `classify` → `redact` для любой причины. В `finally` снимаются таймер и слушатель `abort`; у промиса `post` есть обработчик отклонения (второй аргумент `.then`), который превращает любой сетевой сбой в обычный `TranscribeResult`, поэтому необработанного отклонения не возникает. Таймер — глобальный `setTimeout`/`clearTimeout` (есть и в браузере, и в Node), в тестах — `vi.useFakeTimers()`; отдельный порт не нужен. diff --git a/src/core/openai.ts b/src/core/openai.ts index 220d57f..b6a5433 100644 --- a/src/core/openai.ts +++ b/src/core/openai.ts @@ -92,8 +92,8 @@ export function classify(res: HttpResponse): TranscribeResult { // Причина попадает во frontmatter и в заметку: ни ключа, ни тела запроса. export function redact(message: string, secrets: { apiKey: string; boundary?: string }): string { let out = message; - const lower = out.toLowerCase(); - const cuts = [lower.indexOf('content-disposition')]; + const cdMatch = /content-disposition/i.exec(out); + const cuts = [cdMatch ? cdMatch.index : -1]; if (secrets.boundary) cuts.push(out.indexOf(`--${secrets.boundary}`), out.indexOf(secrets.boundary)); const cut = Math.min(...cuts.filter((i) => i >= 0), out.length); out = out.slice(0, cut); @@ -105,7 +105,13 @@ export function redact(message: string, secrets: { apiKey: string; boundary?: st const fail = (kind: 'temporary' | 'permanent', message: string): TranscribeResult => ({ ok: false, kind, message }); -const errorText = (e: unknown): string => (e instanceof Error ? e.message : String(e)); +const errorText = (e: unknown): string => { + try { + return e instanceof Error ? e.message : String(e); + } catch { + return 'неизвестная ошибка'; + } +}; function partType(mime: string): string { return mime.split(';')[0].trim().toLowerCase() || 'application/octet-stream';