Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
29 changes: 28 additions & 1 deletion src/api/providers/fetchers/__tests__/modelCache.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ vi.mock("../../../core/config/ContextProxy", () => ({

// Then imports
import type { Mock, Mocked } from "vitest"
import { providerIdentifiers } from "@roo-code/types"
import { providerIdentifiers, modelInfoSchema } from "@roo-code/types"
import { z } from "zod"
import * as fsSync from "fs"
import NodeCache from "node-cache"
import { TelemetryService } from "@roo-code/telemetry"
Expand Down Expand Up @@ -356,6 +357,32 @@ describe("getModelsFromCache disk fallback", () => {
})
})

describe("validateModelRecord schema validation", () => {
// Mirrors the modelRecordSchema used by the private validateModelRecord helper.
const modelRecordSchema = z.record(z.string(), modelInfoSchema)

it("accepts a valid ModelRecord", () => {
const validModels = {
"cached-model": {
maxTokens: 8192,
contextWindow: 200000,
supportsPromptCache: true,
},
}
const result = modelRecordSchema.safeParse(validModels)

expect(result.success).toBe(true)
expect(result.data).toEqual(validModels)
})

it("rejects data that does not conform to ModelRecord", () => {
const invalidData = [{ notAModelRecord: true }]
const result = modelRecordSchema.safeParse(invalidData)

expect(result.success).toBe(false)
})
})

describe("empty cache protection", () => {
let mockCache: Mocked<NodeCache>
let mockGet: Mocked<NodeCache>["get"]
Expand Down
32 changes: 21 additions & 11 deletions src/api/providers/fetchers/modelCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,12 +204,28 @@ async function writeModels(cacheKey: string, data: ModelRecord) {
await safeWriteJson(path.join(cacheDir, filename), data)
}

/**
* Validate parsed JSON data against the ModelRecord Zod schema.
* Returns the validated data on success, or undefined (with an error log) on failure.
*/
function validateModelRecord(data: unknown, cacheKey: string): ModelRecord | undefined {
const validation = modelRecordSchema.safeParse(data)
if (!validation.success) {
console.error(`[MODEL_CACHE] Invalid disk cache for ${cacheKey}:`, validation.error.format())
return undefined
}
return validation.data
}

async function readModels(cacheKey: string): Promise<ModelRecord | undefined> {
const filename = `${cacheKeyToFilename(cacheKey)}_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
if (!exists) return undefined

const data = JSON.parse(await fs.readFile(filePath, "utf8"))
return validateModelRecord(data, cacheKey)
Comment on lines 220 to +228

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Handle syntax errors in asynchronous cache reads.

JSON.parse at Line 227 throws for malformed or truncated _models.json files. The exception rejects readModels before validation runs. The synchronous path already catches this failure. Return undefined from both paths for corrupted disk data, as required by the PR objective.

Suggested error boundary
 async function readModels(cacheKey: string): Promise<ModelRecord | undefined> {
+	try {
 		const filename = `${cacheKeyToFilename(cacheKey)}_models.json`
 		const cacheDir = await getCacheDirectoryPath(ContextProxy.instance.globalStorageUri.fsPath)
 		const filePath = path.join(cacheDir, filename)
 		const exists = await fileExistsAtPath(filePath)
 		if (!exists) return undefined

 		const data = JSON.parse(await fs.readFile(filePath, "utf8"))
 		return validateModelRecord(data, cacheKey)
+	} catch (error) {
+		console.error(`[MODEL_CACHE] Error loading ${cacheKey} models from disk:`, error)
+		return undefined
+	}
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async function readModels(cacheKey: string): Promise<ModelRecord | undefined> {
const filename = `${cacheKeyToFilename(cacheKey)}_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
if (!exists) return undefined
const data = JSON.parse(await fs.readFile(filePath, "utf8"))
return validateModelRecord(data, cacheKey)
async function readModels(cacheKey: string): Promise<ModelRecord | undefined> {
try {
const filename = `${cacheKeyToFilename(cacheKey)}_models.json`
const cacheDir = await getCacheDirectoryPath(ContextProxy.instance.globalStorageUri.fsPath)
const filePath = path.join(cacheDir, filename)
const exists = await fileExistsAtPath(filePath)
if (!exists) return undefined
const data = JSON.parse(await fs.readFile(filePath, "utf8"))
return validateModelRecord(data, cacheKey)
} catch (error) {
console.error(`[MODEL_CACHE] Error loading ${cacheKey} models from disk:`, error)
return undefined
}
}
🧰 Tools
🪛 ast-grep (0.45.0)

[warning] 226-226: Filesystem path is not a string literal; a request-/variable-derived path can enable path traversal. Validate and normalize the path before use.
Context: fs.readFile(filePath, "utf8")
Note: [CWE-22] Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').

(detect-non-literal-fs-filename-typescript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/providers/fetchers/modelCache.ts` around lines 220 - 228, Update
readModels to catch JSON.parse or file-read syntax failures for malformed or
truncated cache data and return undefined instead of rejecting before
validateModelRecord runs. Match the existing synchronous cache-read behavior,
while preserving validation for successfully parsed data.

}

/**
Expand Down Expand Up @@ -533,21 +549,15 @@ export function getModelsFromCache(options: GetModelsOptions | ProviderName): Mo
const data = fsSync.readFileSync(filePath, "utf8")
const models = JSON.parse(data)

// Validate the disk cache data structure using Zod schema
// This ensures the data conforms to ModelRecord = Record<string, ModelInfo>
const validation = modelRecordSchema.safeParse(models)
if (!validation.success) {
console.error(
`[MODEL_CACHE] Invalid disk cache data structure for ${cacheKey}:`,
validation.error.format(),
)
const validated = validateModelRecord(models, cacheKey)
if (!validated) {
return undefined
}

// Populate memory cache for future fast access
memoryCache.set(cacheKey, validation.data)
memoryCache.set(cacheKey, validated)

return validation.data
return validated
}
} catch (error) {
console.error(`[MODEL_CACHE] Error loading ${cacheKey} models from disk:`, error)
Expand Down
Loading