From 061652ed31dbaf9b1410ce76c793d495f2d3cc5a Mon Sep 17 00:00:00 2001 From: Cole Leavitt Date: Wed, 14 Jan 2026 17:49:32 -0700 Subject: [PATCH] fix: prevent crashes from corrupted JSON files in storage - Add null byte/control character detection in storage read/update - Wrap JSON.parse in try-catch with descriptive error messages - Make stats command resilient to corrupted files by catching errors - Fixes 'JSON Parse error: Unterminated string' crashes in stats command --- packages/opencode/src/cli/cmd/stats.ts | 4 ++-- packages/opencode/src/storage/storage.ts | 30 +++++++++++++++++++----- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index d78c4f0abd1..9d0141c432f 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -86,13 +86,13 @@ async function getAllSessions(): Promise { const sessions: Session.Info[] = [] const projectKeys = await Storage.list(["project"]) - const projects = await Promise.all(projectKeys.map((key) => Storage.read(key))) + const projects = await Promise.all(projectKeys.map((key) => Storage.read(key).catch(() => undefined))) for (const project of projects) { if (!project) continue const sessionKeys = await Storage.list(["session", project.id]) - const projectSessions = await Promise.all(sessionKeys.map((key) => Storage.read(key))) + const projectSessions = await Promise.all(sessionKeys.map((key) => Storage.read(key).catch(() => undefined))) for (const session of projectSessions) { if (session) { diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts index 611b6928971..4de65462304 100644 --- a/packages/opencode/src/storage/storage.ts +++ b/packages/opencode/src/storage/storage.ts @@ -174,8 +174,17 @@ export namespace Storage { if (!content.trim()) { throw new NotFoundError({ message: `Empty file: ${target}` }) } - const result = JSON.parse(content) - return result as T + const hasControlCharacters = /[\x00-\x08\x0B\x0C\x0E-\x1F]/.test(content) + if (hasControlCharacters) { + throw new NotFoundError({ message: `Corrupted file detected: ${target}` }) + } + try { + const result = JSON.parse(content) + return result as T + } catch (e) { + const message = e instanceof Error ? e.message : String(e) + throw new NotFoundError({ message: `Failed to parse JSON from ${target}: ${message}` }) + } }) } @@ -188,10 +197,19 @@ export namespace Storage { if (!content.trim()) { throw new NotFoundError({ message: `Empty file: ${target}` }) } - const parsed = JSON.parse(content) - fn(parsed) - await Bun.write(target, JSON.stringify(parsed, null, 2)) - return parsed as T + const hasControlCharacters = /[\x00-\x08\x0B\x0C\x0E-\x1F]/.test(content) + if (hasControlCharacters) { + throw new NotFoundError({ message: `Corrupted file detected: ${target}` }) + } + try { + const parsed = JSON.parse(content) + fn(parsed) + await Bun.write(target, JSON.stringify(parsed, null, 2)) + return parsed as T + } catch (e) { + const message = e instanceof Error ? e.message : String(e) + throw new NotFoundError({ message: `Failed to parse JSON from ${target}: ${message}` }) + } }) }