mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
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 <roo-code@z.ewheeler.org>
This commit is contained in:
parent
ac619f106b
commit
4b774b9961
9 changed files with 43 additions and 28 deletions
|
|
@ -12,6 +12,7 @@ import { importSettings, exportSettings } from "../importExport"
|
|||
import { ProviderSettingsManager } from "../ProviderSettingsManager"
|
||||
import { ContextProxy } from "../ContextProxy"
|
||||
import { CustomModesManager } from "../CustomModesManager"
|
||||
import { safeWriteJson } from "../../../utils/safeWriteJson"
|
||||
|
||||
import type { Mock } from "vitest"
|
||||
|
||||
|
|
@ -43,6 +44,8 @@ vi.mock("os", () => ({
|
|||
homedir: vi.fn(() => "/mock/home"),
|
||||
}))
|
||||
|
||||
vi.mock("../../../utils/safeWriteJson")
|
||||
|
||||
describe("importExport", () => {
|
||||
let mockProviderSettingsManager: ReturnType<typeof vi.mocked<ProviderSettingsManager>>
|
||||
let mockContextProxy: ReturnType<typeof vi.mocked<ContextProxy>>
|
||||
|
|
@ -384,11 +387,10 @@ describe("importExport", () => {
|
|||
expect(mockContextProxy.export).toHaveBeenCalled()
|
||||
expect(fs.mkdir).toHaveBeenCalledWith("/mock/path", { recursive: true })
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
"/mock/path/roo-code-settings.json",
|
||||
JSON.stringify({ providerProfiles: mockProviderProfiles, globalSettings: mockGlobalSettings }, null, 2),
|
||||
"utf-8",
|
||||
)
|
||||
expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/roo-code-settings.json", {
|
||||
providerProfiles: mockProviderProfiles,
|
||||
globalSettings: mockGlobalSettings,
|
||||
})
|
||||
})
|
||||
|
||||
it("should include globalSettings when allowedMaxRequests is null", async () => {
|
||||
|
|
@ -417,11 +419,10 @@ describe("importExport", () => {
|
|||
contextProxy: mockContextProxy,
|
||||
})
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
"/mock/path/roo-code-settings.json",
|
||||
JSON.stringify({ providerProfiles: mockProviderProfiles, globalSettings: mockGlobalSettings }, null, 2),
|
||||
"utf-8",
|
||||
)
|
||||
expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/roo-code-settings.json", {
|
||||
providerProfiles: mockProviderProfiles,
|
||||
globalSettings: mockGlobalSettings,
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle errors during the export process", async () => {
|
||||
|
|
@ -436,7 +437,8 @@ describe("importExport", () => {
|
|||
})
|
||||
|
||||
mockContextProxy.export.mockResolvedValue({ mode: "code" })
|
||||
;(fs.writeFile as Mock).mockRejectedValue(new Error("Write error"))
|
||||
// Simulate an error during the safeWriteJson operation
|
||||
;(safeWriteJson as Mock).mockRejectedValueOnce(new Error("Safe write error"))
|
||||
|
||||
await exportSettings({
|
||||
providerSettingsManager: mockProviderSettingsManager,
|
||||
|
|
@ -447,8 +449,10 @@ describe("importExport", () => {
|
|||
expect(mockProviderSettingsManager.export).toHaveBeenCalled()
|
||||
expect(mockContextProxy.export).toHaveBeenCalled()
|
||||
expect(fs.mkdir).toHaveBeenCalledWith("/mock/path", { recursive: true })
|
||||
expect(fs.writeFile).toHaveBeenCalled()
|
||||
expect(safeWriteJson).toHaveBeenCalled() // safeWriteJson is called, but it will throw
|
||||
// The error is caught and the function exits silently.
|
||||
// Optionally, ensure no error message was shown if that's part of "silent"
|
||||
// expect(vscode.window.showErrorMessage).not.toHaveBeenCalled();
|
||||
})
|
||||
|
||||
it("should handle errors during directory creation", async () => {
|
||||
|
|
@ -474,7 +478,7 @@ describe("importExport", () => {
|
|||
expect(mockProviderSettingsManager.export).toHaveBeenCalled()
|
||||
expect(mockContextProxy.export).toHaveBeenCalled()
|
||||
expect(fs.mkdir).toHaveBeenCalled()
|
||||
expect(fs.writeFile).not.toHaveBeenCalled() // Should not be called since mkdir failed.
|
||||
expect(safeWriteJson).not.toHaveBeenCalled() // Should not be called since mkdir failed.
|
||||
})
|
||||
|
||||
it("should use the correct default save location", async () => {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { safeWriteJson } from "../../utils/safeWriteJson"
|
||||
import os from "os"
|
||||
import * as path from "path"
|
||||
import fs from "fs/promises"
|
||||
|
|
@ -116,6 +117,6 @@ export const exportSettings = async ({ providerSettingsManager, contextProxy }:
|
|||
|
||||
const dirname = path.dirname(uri.fsPath)
|
||||
await fs.mkdir(dirname, { recursive: true })
|
||||
await fs.writeFile(uri.fsPath, JSON.stringify({ providerProfiles, globalSettings }, null, 2), "utf-8")
|
||||
await safeWriteJson(uri.fsPath, { providerProfiles, globalSettings })
|
||||
} catch (e) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { safeWriteJson } from "../../utils/safeWriteJson"
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import { getTaskDirectoryPath } from "../../utils/storage"
|
||||
|
|
@ -130,7 +131,7 @@ export class FileContextTracker {
|
|||
const globalStoragePath = this.getContextProxy()!.globalStorageUri.fsPath
|
||||
const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId)
|
||||
const filePath = path.join(taskDir, GlobalFileNames.taskMetadata)
|
||||
await fs.writeFile(filePath, JSON.stringify(metadata, null, 2))
|
||||
await safeWriteJson(filePath, metadata)
|
||||
} catch (error) {
|
||||
console.error("Failed to save task metadata:", error)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { safeWriteJson } from "../../utils/safeWriteJson"
|
||||
import * as path from "path"
|
||||
import * as fs from "fs/promises"
|
||||
|
||||
|
|
@ -78,5 +79,5 @@ export async function saveApiMessages({
|
|||
}) {
|
||||
const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId)
|
||||
const filePath = path.join(taskDir, GlobalFileNames.apiConversationHistory)
|
||||
await fs.writeFile(filePath, JSON.stringify(messages))
|
||||
await safeWriteJson(filePath, messages)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { safeWriteJson } from "../../utils/safeWriteJson"
|
||||
import * as path from "path"
|
||||
import * as fs from "fs/promises"
|
||||
|
||||
|
|
@ -37,5 +38,5 @@ export type SaveTaskMessagesOptions = {
|
|||
export async function saveTaskMessages({ messages, taskId, globalStoragePath }: SaveTaskMessagesOptions) {
|
||||
const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId)
|
||||
const filePath = path.join(taskDir, GlobalFileNames.uiMessages)
|
||||
await fs.writeFile(filePath, JSON.stringify(messages))
|
||||
await safeWriteJson(filePath, messages)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { experimentDefault } from "../../../shared/experiments"
|
|||
import { setTtsEnabled } from "../../../utils/tts"
|
||||
import { ContextProxy } from "../../config/ContextProxy"
|
||||
import { Task, TaskOptions } from "../../task/Task"
|
||||
import { safeWriteJson } from "../../../utils/safeWriteJson"
|
||||
|
||||
import { ClineProvider } from "../ClineProvider"
|
||||
|
||||
|
|
@ -43,6 +44,8 @@ vi.mock("axios", () => ({
|
|||
post: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("../../../utils/safeWriteJson")
|
||||
|
||||
vi.mock("@modelcontextprotocol/sdk/types.js", () => ({
|
||||
CallToolResultSchema: {},
|
||||
ListResourcesResultSchema: {},
|
||||
|
|
@ -1976,11 +1979,8 @@ describe("Project MCP Settings", () => {
|
|||
// Check that fs.mkdir was called with the correct path
|
||||
expect(mockedFs.mkdir).toHaveBeenCalledWith("/test/workspace/.roo", { recursive: true })
|
||||
|
||||
// Check that fs.writeFile was called with default content
|
||||
expect(mockedFs.writeFile).toHaveBeenCalledWith(
|
||||
"/test/workspace/.roo/mcp.json",
|
||||
JSON.stringify({ mcpServers: {} }, null, 2),
|
||||
)
|
||||
// Verify file was created with default content
|
||||
expect(safeWriteJson).toHaveBeenCalledWith("/test/workspace/.roo/mcp.json", { mcpServers: {} })
|
||||
|
||||
// Check that openFile was called
|
||||
expect(openFileSpy).toHaveBeenCalledWith("/test/workspace/.roo/mcp.json")
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { safeWriteJson } from "../../utils/safeWriteJson"
|
||||
import * as path from "path"
|
||||
import fs from "fs/promises"
|
||||
import pWaitFor from "p-wait-for"
|
||||
|
|
@ -594,7 +595,7 @@ export const webviewMessageHandler = async (
|
|||
const exists = await fileExistsAtPath(mcpPath)
|
||||
|
||||
if (!exists) {
|
||||
await fs.writeFile(mcpPath, JSON.stringify({ mcpServers: {} }, null, 2))
|
||||
await safeWriteJson(mcpPath, { mcpServers: {} })
|
||||
}
|
||||
|
||||
await openFile(mcpPath)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { safeWriteJson } from "../../utils/safeWriteJson"
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
import { StdioClientTransport, getDefaultEnvironment } from "@modelcontextprotocol/sdk/client/stdio.js"
|
||||
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
|
||||
|
|
@ -1344,7 +1345,7 @@ export class McpHub {
|
|||
mcpServers: config.mcpServers,
|
||||
}
|
||||
|
||||
await fs.writeFile(configPath, JSON.stringify(updatedConfig, null, 2))
|
||||
await safeWriteJson(configPath, updatedConfig)
|
||||
}
|
||||
|
||||
public async updateServerTimeout(
|
||||
|
|
@ -1422,7 +1423,7 @@ export class McpHub {
|
|||
mcpServers: config.mcpServers,
|
||||
}
|
||||
|
||||
await fs.writeFile(configPath, JSON.stringify(updatedConfig, null, 2))
|
||||
await safeWriteJson(configPath, updatedConfig)
|
||||
|
||||
// Update server connections with the correct source
|
||||
await this.updateServerConnections(config.mcpServers, serverSource)
|
||||
|
|
@ -1567,7 +1568,7 @@ export class McpHub {
|
|||
targetList.splice(toolIndex, 1)
|
||||
}
|
||||
|
||||
await fs.writeFile(normalizedPath, JSON.stringify(config, null, 2))
|
||||
await safeWriteJson(normalizedPath, config)
|
||||
|
||||
if (connection) {
|
||||
connection.server.tools = await this.fetchToolsList(serverName, source)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,12 @@ const activeLocks = new Set<string>()
|
|||
* @param {any} data - The data to serialize to JSON and write.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function safeWriteJson(filePath: string, data: any): Promise<void> {
|
||||
async function safeWriteJson(
|
||||
filePath: string,
|
||||
data: any,
|
||||
replacer?: (key: string, value: any) => any,
|
||||
space: string | number = 2,
|
||||
): Promise<void> {
|
||||
const absoluteFilePath = path.resolve(filePath)
|
||||
|
||||
if (activeLocks.has(absoluteFilePath)) {
|
||||
|
|
@ -33,7 +38,7 @@ async function safeWriteJson(filePath: string, data: any): Promise<void> {
|
|||
path.dirname(absoluteFilePath),
|
||||
`.${path.basename(absoluteFilePath)}.new_${Date.now()}_${Math.random().toString(36).substring(2)}.tmp`,
|
||||
)
|
||||
const jsonData = JSON.stringify(data, null, 2)
|
||||
const jsonData = JSON.stringify(data, replacer, space)
|
||||
await fs.writeFile(actualTempNewFilePath, jsonData, "utf8")
|
||||
|
||||
// Step 2: Check if the target file exists. If so, rename it to a backup path.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue