Skip to content

Commit 4b774b9

Browse files
Eric Wheelerdaniel-lxs
authored andcommitted
fix: use safeWriteJson for all JSON file writes
This change refactors all direct JSON file writes to use the safeWriteJson utility, which implements atomic file writes to prevent data corruption during write operations. - Modified safeWriteJson to accept optional replacer and space arguments - Updated tests to verify correct behavior with the new implementation Fixes: #722 Signed-off-by: Eric Wheeler <[email protected]>
1 parent ac619f1 commit 4b774b9

File tree

9 files changed

+43
-28
lines changed

9 files changed

+43
-28
lines changed

src/core/config/__tests__/importExport.spec.ts

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { importSettings, exportSettings } from "../importExport"
1212
import { ProviderSettingsManager } from "../ProviderSettingsManager"
1313
import { ContextProxy } from "../ContextProxy"
1414
import { CustomModesManager } from "../CustomModesManager"
15+
import { safeWriteJson } from "../../../utils/safeWriteJson"
1516

1617
import type { Mock } from "vitest"
1718

@@ -43,6 +44,8 @@ vi.mock("os", () => ({
4344
homedir: vi.fn(() => "/mock/home"),
4445
}))
4546

47+
vi.mock("../../../utils/safeWriteJson")
48+
4649
describe("importExport", () => {
4750
let mockProviderSettingsManager: ReturnType<typeof vi.mocked<ProviderSettingsManager>>
4851
let mockContextProxy: ReturnType<typeof vi.mocked<ContextProxy>>
@@ -384,11 +387,10 @@ describe("importExport", () => {
384387
expect(mockContextProxy.export).toHaveBeenCalled()
385388
expect(fs.mkdir).toHaveBeenCalledWith("/mock/path", { recursive: true })
386389

387-
expect(fs.writeFile).toHaveBeenCalledWith(
388-
"/mock/path/roo-code-settings.json",
389-
JSON.stringify({ providerProfiles: mockProviderProfiles, globalSettings: mockGlobalSettings }, null, 2),
390-
"utf-8",
391-
)
390+
expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/roo-code-settings.json", {
391+
providerProfiles: mockProviderProfiles,
392+
globalSettings: mockGlobalSettings,
393+
})
392394
})
393395

394396
it("should include globalSettings when allowedMaxRequests is null", async () => {
@@ -417,11 +419,10 @@ describe("importExport", () => {
417419
contextProxy: mockContextProxy,
418420
})
419421

420-
expect(fs.writeFile).toHaveBeenCalledWith(
421-
"/mock/path/roo-code-settings.json",
422-
JSON.stringify({ providerProfiles: mockProviderProfiles, globalSettings: mockGlobalSettings }, null, 2),
423-
"utf-8",
424-
)
422+
expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/roo-code-settings.json", {
423+
providerProfiles: mockProviderProfiles,
424+
globalSettings: mockGlobalSettings,
425+
})
425426
})
426427

427428
it("should handle errors during the export process", async () => {
@@ -436,7 +437,8 @@ describe("importExport", () => {
436437
})
437438

438439
mockContextProxy.export.mockResolvedValue({ mode: "code" })
439-
;(fs.writeFile as Mock).mockRejectedValue(new Error("Write error"))
440+
// Simulate an error during the safeWriteJson operation
441+
;(safeWriteJson as Mock).mockRejectedValueOnce(new Error("Safe write error"))
440442

441443
await exportSettings({
442444
providerSettingsManager: mockProviderSettingsManager,
@@ -447,8 +449,10 @@ describe("importExport", () => {
447449
expect(mockProviderSettingsManager.export).toHaveBeenCalled()
448450
expect(mockContextProxy.export).toHaveBeenCalled()
449451
expect(fs.mkdir).toHaveBeenCalledWith("/mock/path", { recursive: true })
450-
expect(fs.writeFile).toHaveBeenCalled()
452+
expect(safeWriteJson).toHaveBeenCalled() // safeWriteJson is called, but it will throw
451453
// The error is caught and the function exits silently.
454+
// Optionally, ensure no error message was shown if that's part of "silent"
455+
// expect(vscode.window.showErrorMessage).not.toHaveBeenCalled();
452456
})
453457

454458
it("should handle errors during directory creation", async () => {
@@ -474,7 +478,7 @@ describe("importExport", () => {
474478
expect(mockProviderSettingsManager.export).toHaveBeenCalled()
475479
expect(mockContextProxy.export).toHaveBeenCalled()
476480
expect(fs.mkdir).toHaveBeenCalled()
477-
expect(fs.writeFile).not.toHaveBeenCalled() // Should not be called since mkdir failed.
481+
expect(safeWriteJson).not.toHaveBeenCalled() // Should not be called since mkdir failed.
478482
})
479483

480484
it("should use the correct default save location", async () => {

src/core/config/importExport.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { safeWriteJson } from "../../utils/safeWriteJson"
12
import os from "os"
23
import * as path from "path"
34
import fs from "fs/promises"
@@ -116,6 +117,6 @@ export const exportSettings = async ({ providerSettingsManager, contextProxy }:
116117

117118
const dirname = path.dirname(uri.fsPath)
118119
await fs.mkdir(dirname, { recursive: true })
119-
await fs.writeFile(uri.fsPath, JSON.stringify({ providerProfiles, globalSettings }, null, 2), "utf-8")
120+
await safeWriteJson(uri.fsPath, { providerProfiles, globalSettings })
120121
} catch (e) {}
121122
}

src/core/context-tracking/FileContextTracker.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { safeWriteJson } from "../../utils/safeWriteJson"
12
import * as path from "path"
23
import * as vscode from "vscode"
34
import { getTaskDirectoryPath } from "../../utils/storage"
@@ -130,7 +131,7 @@ export class FileContextTracker {
130131
const globalStoragePath = this.getContextProxy()!.globalStorageUri.fsPath
131132
const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId)
132133
const filePath = path.join(taskDir, GlobalFileNames.taskMetadata)
133-
await fs.writeFile(filePath, JSON.stringify(metadata, null, 2))
134+
await safeWriteJson(filePath, metadata)
134135
} catch (error) {
135136
console.error("Failed to save task metadata:", error)
136137
}

src/core/task-persistence/apiMessages.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { safeWriteJson } from "../../utils/safeWriteJson"
12
import * as path from "path"
23
import * as fs from "fs/promises"
34

@@ -78,5 +79,5 @@ export async function saveApiMessages({
7879
}) {
7980
const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId)
8081
const filePath = path.join(taskDir, GlobalFileNames.apiConversationHistory)
81-
await fs.writeFile(filePath, JSON.stringify(messages))
82+
await safeWriteJson(filePath, messages)
8283
}

src/core/task-persistence/taskMessages.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { safeWriteJson } from "../../utils/safeWriteJson"
12
import * as path from "path"
23
import * as fs from "fs/promises"
34

@@ -37,5 +38,5 @@ export type SaveTaskMessagesOptions = {
3738
export async function saveTaskMessages({ messages, taskId, globalStoragePath }: SaveTaskMessagesOptions) {
3839
const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId)
3940
const filePath = path.join(taskDir, GlobalFileNames.uiMessages)
40-
await fs.writeFile(filePath, JSON.stringify(messages))
41+
await safeWriteJson(filePath, messages)
4142
}

src/core/webview/__tests__/ClineProvider.spec.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { experimentDefault } from "../../../shared/experiments"
1313
import { setTtsEnabled } from "../../../utils/tts"
1414
import { ContextProxy } from "../../config/ContextProxy"
1515
import { Task, TaskOptions } from "../../task/Task"
16+
import { safeWriteJson } from "../../../utils/safeWriteJson"
1617

1718
import { ClineProvider } from "../ClineProvider"
1819

@@ -43,6 +44,8 @@ vi.mock("axios", () => ({
4344
post: vi.fn(),
4445
}))
4546

47+
vi.mock("../../../utils/safeWriteJson")
48+
4649
vi.mock("@modelcontextprotocol/sdk/types.js", () => ({
4750
CallToolResultSchema: {},
4851
ListResourcesResultSchema: {},
@@ -1976,11 +1979,8 @@ describe("Project MCP Settings", () => {
19761979
// Check that fs.mkdir was called with the correct path
19771980
expect(mockedFs.mkdir).toHaveBeenCalledWith("/test/workspace/.roo", { recursive: true })
19781981

1979-
// Check that fs.writeFile was called with default content
1980-
expect(mockedFs.writeFile).toHaveBeenCalledWith(
1981-
"/test/workspace/.roo/mcp.json",
1982-
JSON.stringify({ mcpServers: {} }, null, 2),
1983-
)
1982+
// Verify file was created with default content
1983+
expect(safeWriteJson).toHaveBeenCalledWith("/test/workspace/.roo/mcp.json", { mcpServers: {} })
19841984

19851985
// Check that openFile was called
19861986
expect(openFileSpy).toHaveBeenCalledWith("/test/workspace/.roo/mcp.json")

src/core/webview/webviewMessageHandler.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { safeWriteJson } from "../../utils/safeWriteJson"
12
import * as path from "path"
23
import fs from "fs/promises"
34
import pWaitFor from "p-wait-for"
@@ -594,7 +595,7 @@ export const webviewMessageHandler = async (
594595
const exists = await fileExistsAtPath(mcpPath)
595596

596597
if (!exists) {
597-
await fs.writeFile(mcpPath, JSON.stringify({ mcpServers: {} }, null, 2))
598+
await safeWriteJson(mcpPath, { mcpServers: {} })
598599
}
599600

600601
await openFile(mcpPath)

src/services/mcp/McpHub.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { safeWriteJson } from "../../utils/safeWriteJson"
12
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
23
import { StdioClientTransport, getDefaultEnvironment } from "@modelcontextprotocol/sdk/client/stdio.js"
34
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
@@ -1344,7 +1345,7 @@ export class McpHub {
13441345
mcpServers: config.mcpServers,
13451346
}
13461347

1347-
await fs.writeFile(configPath, JSON.stringify(updatedConfig, null, 2))
1348+
await safeWriteJson(configPath, updatedConfig)
13481349
}
13491350

13501351
public async updateServerTimeout(
@@ -1422,7 +1423,7 @@ export class McpHub {
14221423
mcpServers: config.mcpServers,
14231424
}
14241425

1425-
await fs.writeFile(configPath, JSON.stringify(updatedConfig, null, 2))
1426+
await safeWriteJson(configPath, updatedConfig)
14261427

14271428
// Update server connections with the correct source
14281429
await this.updateServerConnections(config.mcpServers, serverSource)
@@ -1567,7 +1568,7 @@ export class McpHub {
15671568
targetList.splice(toolIndex, 1)
15681569
}
15691570

1570-
await fs.writeFile(normalizedPath, JSON.stringify(config, null, 2))
1571+
await safeWriteJson(normalizedPath, config)
15711572

15721573
if (connection) {
15731574
connection.server.tools = await this.fetchToolsList(serverName, source)

src/utils/safeWriteJson.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,12 @@ const activeLocks = new Set<string>()
1414
* @param {any} data - The data to serialize to JSON and write.
1515
* @returns {Promise<void>}
1616
*/
17-
async function safeWriteJson(filePath: string, data: any): Promise<void> {
17+
async function safeWriteJson(
18+
filePath: string,
19+
data: any,
20+
replacer?: (key: string, value: any) => any,
21+
space: string | number = 2,
22+
): Promise<void> {
1823
const absoluteFilePath = path.resolve(filePath)
1924

2025
if (activeLocks.has(absoluteFilePath)) {
@@ -33,7 +38,7 @@ async function safeWriteJson(filePath: string, data: any): Promise<void> {
3338
path.dirname(absoluteFilePath),
3439
`.${path.basename(absoluteFilePath)}.new_${Date.now()}_${Math.random().toString(36).substring(2)}.tmp`,
3540
)
36-
const jsonData = JSON.stringify(data, null, 2)
41+
const jsonData = JSON.stringify(data, replacer, space)
3742
await fs.writeFile(actualTempNewFilePath, jsonData, "utf8")
3843

3944
// Step 2: Check if the target file exists. If so, rename it to a backup path.

0 commit comments

Comments
 (0)