Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .roo/rules/use-safeReadJson.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# JSON File Reading Must Be Safe and Atomic

- You MUST use `safeReadJson(filePath: string, jsonPath?: string | string[]): Promise<any>` from `src/utils/safeReadJson.ts` to read JSON files
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation mentions jsonPath?: string | string[] parameter but the actual implementation of safeReadJson doesn't support this parameter. Either update the documentation to match the implementation or add jsonPath support to the function.

- `safeReadJson` provides atomic file access to local files with proper locking to prevent race conditions and uses `stream-json` to read JSON files without buffering to a string
- Test files are exempt from this rule

## Correct Usage Example

This pattern replaces all manual `fs` or `vscode.workspace.fs` reads.

### ❌ Don't do this:

```typescript
// Anti-patterns: string buffering wastes memory
const data = JSON.parse(await fs.readFile(filePath, 'utf8'));
const data = JSON.parse(await vscode.workspace.fs.readFile(fileUri));

// Anti-pattern: Unsafe existence check
if (await fileExists.. ) { /* then read */ }
```

### ✅ Use this unified pattern:

```typescript
let data
try {
data = await safeReadJson(filePath)
} catch (error) {
if (error.code !== "ENOENT") {
// Handle at least ENOENT
}
}
```
12 changes: 9 additions & 3 deletions src/api/providers/fetchers/modelCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import * as path from "path"
import fs from "fs/promises"

import NodeCache from "node-cache"
import { safeReadJson } from "../../../utils/safeReadJson"
import { safeWriteJson } from "../../../utils/safeWriteJson"

import { ContextProxy } from "../../../core/config/ContextProxy"
import { getCacheDirectoryPath } from "../../../utils/storage"
import { RouterName, ModelRecord } from "../../../shared/api"
import { fileExistsAtPath } from "../../../utils/fs"

import { getOpenRouterModels } from "./openrouter"
import { getRequestyModels } from "./requesty"
Expand All @@ -30,8 +30,14 @@ async function readModels(router: RouterName): Promise<ModelRecord | undefined>
const filename = `${router}_models.json`
const cacheDir = await getCacheDirectoryPath(ContextProxy.instance.globalStorageUri.fsPath)
const filePath = path.join(cacheDir, filename)
const exists = await fileExistsAtPath(filePath)
return exists ? JSON.parse(await fs.readFile(filePath, "utf8")) : undefined
try {
return await safeReadJson(filePath)
} catch (error: any) {
if (error.code === "ENOENT") {
return undefined
}
throw error
}
}

/**
Expand Down
9 changes: 6 additions & 3 deletions src/api/providers/fetchers/modelEndpointCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ import * as path from "path"
import fs from "fs/promises"

import NodeCache from "node-cache"
import { safeReadJson } from "../../../utils/safeReadJson"
import { safeWriteJson } from "../../../utils/safeWriteJson"
import sanitize from "sanitize-filename"

import { ContextProxy } from "../../../core/config/ContextProxy"
import { getCacheDirectoryPath } from "../../../utils/storage"
import { RouterName, ModelRecord } from "../../../shared/api"
import { fileExistsAtPath } from "../../../utils/fs"

import { getOpenRouterModelEndpoints } from "./openrouter"

Expand All @@ -26,8 +26,11 @@ async function readModelEndpoints(key: string): Promise<ModelRecord | undefined>
const filename = `${key}_endpoints.json`
const cacheDir = await getCacheDirectoryPath(ContextProxy.instance.globalStorageUri.fsPath)
const filePath = path.join(cacheDir, filename)
const exists = await fileExistsAtPath(filePath)
return exists ? JSON.parse(await fs.readFile(filePath, "utf8")) : undefined
try {
return await safeReadJson(filePath)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it intentional to silently return undefined for all errors, not just ENOENT? This could hide actual issues like permission errors or corrupted JSON. Consider:

Suggested change
return await safeReadJson(filePath)
try {
return await safeReadJson(filePath)
} catch (error: any) {
if (error.code === "ENOENT") {
return undefined
}
throw error
}

} catch (error) {
return undefined
}
}

export const getModelEndpoints = async ({
Expand Down
87 changes: 48 additions & 39 deletions src/core/config/__tests__/importExport.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// npx vitest src/core/config/__tests__/importExport.spec.ts

import { describe, it, expect, vi, beforeEach } from "vitest"
import fs from "fs/promises"
import * as path from "path"

Expand All @@ -12,6 +13,7 @@ import { importSettings, importSettingsFromFile, importSettingsWithFeedback, exp
import { ProviderSettingsManager } from "../ProviderSettingsManager"
import { ContextProxy } from "../ContextProxy"
import { CustomModesManager } from "../CustomModesManager"
import { safeReadJson } from "../../../utils/safeReadJson"
import { safeWriteJson } from "../../../utils/safeWriteJson"

import type { Mock } from "vitest"
Expand Down Expand Up @@ -56,7 +58,12 @@ vi.mock("os", () => ({
homedir: vi.fn(() => "/mock/home"),
}))

vi.mock("../../../utils/safeWriteJson")
vi.mock("../../../utils/safeReadJson", () => ({
safeReadJson: vi.fn(),
}))
vi.mock("../../../utils/safeWriteJson", () => ({
safeWriteJson: vi.fn(),
}))

describe("importExport", () => {
let mockProviderSettingsManager: ReturnType<typeof vi.mocked<ProviderSettingsManager>>
Expand Down Expand Up @@ -115,7 +122,7 @@ describe("importExport", () => {
canSelectMany: false,
})

expect(fs.readFile).not.toHaveBeenCalled()
expect(safeReadJson).not.toHaveBeenCalled()
expect(mockProviderSettingsManager.import).not.toHaveBeenCalled()
expect(mockContextProxy.setValues).not.toHaveBeenCalled()
})
Expand All @@ -131,7 +138,7 @@ describe("importExport", () => {
globalSettings: { mode: "code", autoApprovalEnabled: true },
})

;(fs.readFile as Mock).mockResolvedValue(mockFileContent)
;(safeReadJson as Mock).mockResolvedValue(JSON.parse(mockFileContent))

const previousProviderProfiles = {
currentApiConfigName: "default",
Expand All @@ -154,7 +161,7 @@ describe("importExport", () => {
})

expect(result.success).toBe(true)
expect(fs.readFile).toHaveBeenCalledWith("/mock/path/settings.json", "utf-8")
expect(safeReadJson).toHaveBeenCalledWith("/mock/path/settings.json")
expect(mockProviderSettingsManager.export).toHaveBeenCalled()

expect(mockProviderSettingsManager.import).toHaveBeenCalledWith({
Expand Down Expand Up @@ -184,7 +191,7 @@ describe("importExport", () => {
globalSettings: {},
})

;(fs.readFile as Mock).mockResolvedValue(mockInvalidContent)
;(safeReadJson as Mock).mockResolvedValue(JSON.parse(mockInvalidContent))

const result = await importSettings({
providerSettingsManager: mockProviderSettingsManager,
Expand All @@ -193,7 +200,7 @@ describe("importExport", () => {
})

expect(result).toEqual({ success: false, error: "[providerProfiles.currentApiConfigName]: Required" })
expect(fs.readFile).toHaveBeenCalledWith("/mock/path/settings.json", "utf-8")
expect(safeReadJson).toHaveBeenCalledWith("/mock/path/settings.json")
expect(mockProviderSettingsManager.import).not.toHaveBeenCalled()
expect(mockContextProxy.setValues).not.toHaveBeenCalled()
})
Expand All @@ -208,7 +215,7 @@ describe("importExport", () => {
},
})

;(fs.readFile as Mock).mockResolvedValue(mockFileContent)
;(safeReadJson as Mock).mockResolvedValue(JSON.parse(mockFileContent))

const previousProviderProfiles = {
currentApiConfigName: "default",
Expand All @@ -231,7 +238,7 @@ describe("importExport", () => {
})

expect(result.success).toBe(true)
expect(fs.readFile).toHaveBeenCalledWith("/mock/path/settings.json", "utf-8")
expect(safeReadJson).toHaveBeenCalledWith("/mock/path/settings.json")
expect(mockProviderSettingsManager.export).toHaveBeenCalled()
expect(mockProviderSettingsManager.import).toHaveBeenCalledWith({
currentApiConfigName: "test",
Expand All @@ -253,8 +260,8 @@ describe("importExport", () => {

it("should return success: false when file content is not valid JSON", async () => {
;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/mock/path/settings.json" }])
const mockInvalidJson = "{ this is not valid JSON }"
;(fs.readFile as Mock).mockResolvedValue(mockInvalidJson)
const jsonError = new SyntaxError("Unexpected token t in JSON at position 2")
;(safeReadJson as Mock).mockRejectedValue(jsonError)

const result = await importSettings({
providerSettingsManager: mockProviderSettingsManager,
Expand All @@ -263,15 +270,15 @@ describe("importExport", () => {
})

expect(result.success).toBe(false)
expect(result.error).toMatch(/^Expected property name or '}' in JSON at position 2/)
expect(fs.readFile).toHaveBeenCalledWith("/mock/path/settings.json", "utf-8")
expect(result.error).toMatch(/^Unexpected token t in JSON at position 2/)
expect(safeReadJson).toHaveBeenCalledWith("/mock/path/settings.json")
expect(mockProviderSettingsManager.import).not.toHaveBeenCalled()
expect(mockContextProxy.setValues).not.toHaveBeenCalled()
})

it("should return success: false when reading file fails", async () => {
;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/mock/path/settings.json" }])
;(fs.readFile as Mock).mockRejectedValue(new Error("File read error"))
;(safeReadJson as Mock).mockRejectedValue(new Error("File read error"))

const result = await importSettings({
providerSettingsManager: mockProviderSettingsManager,
Expand All @@ -280,7 +287,7 @@ describe("importExport", () => {
})

expect(result).toEqual({ success: false, error: "File read error" })
expect(fs.readFile).toHaveBeenCalledWith("/mock/path/settings.json", "utf-8")
expect(safeReadJson).toHaveBeenCalledWith("/mock/path/settings.json")
expect(mockProviderSettingsManager.import).not.toHaveBeenCalled()
expect(mockContextProxy.setValues).not.toHaveBeenCalled()
})
Expand All @@ -302,7 +309,7 @@ describe("importExport", () => {
},
})

;(fs.readFile as Mock).mockResolvedValue(mockFileContent)
;(safeReadJson as Mock).mockResolvedValue(JSON.parse(mockFileContent))

mockContextProxy.export.mockResolvedValue({ mode: "code" })

Expand Down Expand Up @@ -333,7 +340,7 @@ describe("importExport", () => {
globalSettings: { mode: "code", customModes },
})

;(fs.readFile as Mock).mockResolvedValue(mockFileContent)
;(safeReadJson as Mock).mockResolvedValue(JSON.parse(mockFileContent))

mockProviderSettingsManager.export.mockResolvedValue({
currentApiConfigName: "test",
Expand All @@ -358,15 +365,15 @@ describe("importExport", () => {

it("should import settings from provided file path without showing dialog", async () => {
const filePath = "/mock/path/settings.json"
const mockFileContent = JSON.stringify({
const mockFileData = {
providerProfiles: {
currentApiConfigName: "test",
apiConfigs: { test: { apiProvider: "openai" as ProviderName, apiKey: "test-key", id: "test-id" } },
},
globalSettings: { mode: "code", autoApprovalEnabled: true },
})
}

;(fs.readFile as Mock).mockResolvedValue(mockFileContent)
;(safeReadJson as Mock).mockResolvedValue(mockFileData)
;(fs.access as Mock).mockResolvedValue(undefined) // File exists and is readable

const previousProviderProfiles = {
Expand All @@ -391,24 +398,28 @@ describe("importExport", () => {
)

expect(vscode.window.showOpenDialog).not.toHaveBeenCalled()
expect(fs.readFile).toHaveBeenCalledWith(filePath, "utf-8")
expect(safeReadJson).toHaveBeenCalledWith(filePath)
expect(result.success).toBe(true)
expect(mockProviderSettingsManager.import).toHaveBeenCalledWith({
currentApiConfigName: "test",
apiConfigs: {
default: { apiProvider: "anthropic" as ProviderName, id: "default-id" },
test: { apiProvider: "openai" as ProviderName, apiKey: "test-key", id: "test-id" },
},
modeApiConfigs: {},
})

// Verify that import was called, but don't be strict about the exact object structure
expect(mockProviderSettingsManager.import).toHaveBeenCalled()

// Verify the key properties were included
const importCall = mockProviderSettingsManager.import.mock.calls[0][0]
expect(importCall.currentApiConfigName).toBe("test")
expect(importCall.apiConfigs).toBeDefined()
expect(importCall.apiConfigs.default).toBeDefined()
expect(importCall.apiConfigs.test).toBeDefined()
expect(importCall.apiConfigs.test.apiProvider).toBe("openai")
expect(importCall.apiConfigs.test.apiKey).toBe("test-key")
expect(mockContextProxy.setValues).toHaveBeenCalledWith({ mode: "code", autoApprovalEnabled: true })
})

it("should return error when provided file path does not exist", async () => {
const filePath = "/nonexistent/path/settings.json"
const accessError = new Error("ENOENT: no such file or directory")

;(fs.access as Mock).mockRejectedValue(accessError)
;(safeReadJson as Mock).mockRejectedValue(accessError)

// Create a mock provider for the test
const mockProvider = {
Expand All @@ -430,8 +441,6 @@ describe("importExport", () => {
)

expect(vscode.window.showOpenDialog).not.toHaveBeenCalled()
expect(fs.access).toHaveBeenCalledWith(filePath, fs.constants.F_OK | fs.constants.R_OK)
expect(fs.readFile).not.toHaveBeenCalled()
expect(showErrorMessageSpy).toHaveBeenCalledWith(expect.stringContaining("errors.settings_import_failed"))

showErrorMessageSpy.mockRestore()
Expand Down Expand Up @@ -921,7 +930,7 @@ describe("importExport", () => {
},
})

;(fs.readFile as Mock).mockResolvedValue(mockFileContent)
;(safeReadJson as Mock).mockResolvedValue(JSON.parse(mockFileContent))

const previousProviderProfiles = {
currentApiConfigName: "default",
Expand Down Expand Up @@ -990,7 +999,7 @@ describe("importExport", () => {
},
})

;(fs.readFile as Mock).mockResolvedValue(mockFileContent)
;(safeReadJson as Mock).mockResolvedValue(JSON.parse(mockFileContent))

const previousProviderProfiles = {
currentApiConfigName: "default",
Expand Down Expand Up @@ -1042,7 +1051,7 @@ describe("importExport", () => {
},
})

;(fs.readFile as Mock).mockResolvedValue(mockFileContent)
;(safeReadJson as Mock).mockResolvedValue(JSON.parse(mockFileContent))

const previousProviderProfiles = {
currentApiConfigName: "default",
Expand Down Expand Up @@ -1130,7 +1139,7 @@ describe("importExport", () => {

// Step 6: Mock import operation
;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/mock/path/test-settings.json" }])
;(fs.readFile as Mock).mockResolvedValue(exportedFileContent)
;(safeReadJson as Mock).mockResolvedValue(JSON.parse(exportedFileContent))

// Reset mocks for import
vi.clearAllMocks()
Expand Down Expand Up @@ -1218,7 +1227,7 @@ describe("importExport", () => {
// Test import roundtrip
const exportedFileContent = JSON.stringify(exportedData)
;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/mock/path/test-settings.json" }])
;(fs.readFile as Mock).mockResolvedValue(exportedFileContent)
;(safeReadJson as Mock).mockResolvedValue(JSON.parse(exportedFileContent))

// Reset mocks for import
vi.clearAllMocks()
Expand Down Expand Up @@ -1346,7 +1355,7 @@ describe("importExport", () => {

// Step 3: Mock import operation
;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/mock/path/settings.json" }])
;(fs.readFile as Mock).mockResolvedValue(JSON.stringify(exportedSettings))
;(safeReadJson as Mock).mockResolvedValue(exportedSettings)

mockProviderSettingsManager.export.mockResolvedValue(currentProviderProfiles)
mockProviderSettingsManager.listConfig.mockResolvedValue([
Expand Down Expand Up @@ -1425,7 +1434,7 @@ describe("importExport", () => {
}

;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/mock/path/settings.json" }])
;(fs.readFile as Mock).mockResolvedValue(JSON.stringify(exportedSettings))
;(safeReadJson as Mock).mockResolvedValue(exportedSettings)

mockProviderSettingsManager.export.mockResolvedValue(currentProviderProfiles)
mockProviderSettingsManager.listConfig.mockResolvedValue([
Expand Down Expand Up @@ -1510,7 +1519,7 @@ describe("importExport", () => {
}

;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/mock/path/settings.json" }])
;(fs.readFile as Mock).mockResolvedValue(JSON.stringify(exportedSettings))
;(safeReadJson as Mock).mockResolvedValue(exportedSettings)

mockProviderSettingsManager.export.mockResolvedValue(currentProviderProfiles)
mockProviderSettingsManager.listConfig.mockResolvedValue([
Expand Down
3 changes: 2 additions & 1 deletion src/core/config/importExport.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { safeReadJson } from "../../utils/safeReadJson"
import { safeWriteJson } from "../../utils/safeWriteJson"
import os from "os"
import * as path from "path"
Expand Down Expand Up @@ -49,7 +50,7 @@ export async function importSettingsFromPath(
const previousProviderProfiles = await providerSettingsManager.export()

const { providerProfiles: newProviderProfiles, globalSettings = {} } = schema.parse(
JSON.parse(await fs.readFile(filePath, "utf-8")),
await safeReadJson(filePath),
)

const providerProfiles = {
Expand Down
Loading