From 6a99cd4fe51dd2cc4fc34199d4e771de24a85c50 Mon Sep 17 00:00:00 2001 From: "vs-code-engineering[bot]" <122617954+vs-code-engineering[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 23:57:44 +0000 Subject: [PATCH] fix: validate file stat size/mtime before SQLite bind in external ingest index (fixes #321794) (#321799) * fix: reject non-numeric file stats before SQLite bind in external ingest index Virtual/custom file system providers can return a FileStat whose `size` or `mtime` is not a number, violating the declared `number` type of safeStat(). These values were passed straight into a node:sqlite parameter bind, which rejects any non-number value and throws the unhandled error "Provided value cannot be bound to SQLite parameter 2". Validate the numeric contract at the file-system boundary in safeStat() and treat a stat with a non-numeric size or mtime as unusable (return undefined), matching the function's existing behaviour for directories and stat failures. Fixes #321794 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: reject non-finite external ingest file stats Use finite-number validation at the filesystem boundary so NaN and infinity cannot reach SQLite. Cover malformed provider metadata while ensuring later valid files are still indexed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: vs-code-engineering[bot] <122617954+vs-code-engineering[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Dmitriy Vasyura --- .../node/codeSearch/externalIngestIndex.ts | 6 ++- .../test/node/externalIngest.spec.ts | 46 ++++++++++++++++++- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/externalIngestIndex.ts b/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/externalIngestIndex.ts index acc320a46f4..c26559f46bc 100644 --- a/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/externalIngestIndex.ts +++ b/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/externalIngestIndex.ts @@ -1042,6 +1042,10 @@ export class ExternalIngestIndex extends Disposable { if (stat.type !== 1) { // FileType.File = 1 return undefined; } + // File system providers may violate FileStat's numeric contract at runtime. + if (!Number.isFinite(stat.size) || !Number.isFinite(stat.mtime)) { + return undefined; + } return { size: stat.size, mtime: stat.mtime }; } catch { return undefined; @@ -1098,5 +1102,3 @@ export class ExternalIngestIndex extends Disposable { return { fileCount: files.length, files }; } } - - diff --git a/extensions/copilot/src/platform/workspaceChunkSearch/test/node/externalIngest.spec.ts b/extensions/copilot/src/platform/workspaceChunkSearch/test/node/externalIngest.spec.ts index c403c7596b5..a80ca51c3d5 100644 --- a/extensions/copilot/src/platform/workspaceChunkSearch/test/node/externalIngest.spec.ts +++ b/extensions/copilot/src/platform/workspaceChunkSearch/test/node/externalIngest.spec.ts @@ -84,6 +84,10 @@ interface MockFileEntry { readonly content: Uint8Array; readonly size: number; readonly mtime: number; + readonly statOverride?: { + readonly field: 'size' | 'mtime'; + readonly value?: number | string; + }; } function createFileFromString(content: string, mtime = Date.now()): MockFileEntry { @@ -126,13 +130,18 @@ class MockFileSystem extends mock() impleme if (!entry) { throw new Error(`File not found: ${uri.toString()}`); } - return { + const stat = { type: FileType.File, ctime: 0, mtime: entry.mtime, size: entry.size, permissions: undefined, }; + if (entry.statOverride) { + // Simulate a provider violating FileStat's runtime contract. + Object.defineProperty(stat, entry.statOverride.field, { value: entry.statOverride.value }); + } + return stat; } override async readFile(uri: URI) { @@ -359,6 +368,41 @@ suite('ExternalIngestIndex', () => { assert.strictEqual(await index.shouldTrackFile(file2, CancellationToken.None), true); }); + test('initialize skips malformed file stats and continues indexing', async () => { + const workspaceRoot = URI.file('/workspace'); + const malformedStats: readonly { + readonly name: string; + readonly field: 'size' | 'mtime'; + readonly value?: number | string; + }[] = [ + { name: 'missing-size.ts', field: 'size' }, + { name: 'string-mtime.ts', field: 'mtime', value: 'invalid' }, + { name: 'nan-size.ts', field: 'size', value: Number.NaN }, + { name: 'infinite-mtime.ts', field: 'mtime', value: Number.POSITIVE_INFINITY }, + ]; + const files = new ResourceMap(); + for (const { name, field, value } of malformedStats) { + files.set(URI.joinPath(workspaceRoot, name), { + ...createFileFromString('const invalid = true;'), + statOverride: { field, value }, + }); + } + const validFile = URI.joinPath(workspaceRoot, 'valid.ts'); + files.set(validFile, createFileFromString('const valid = true;')); + + const { mockClient, index } = setupTestContext(workspaceRoot, files); + await index.initialize(); + const ingestResult = await index.doIngest(testTelemetryInfo, emptyProgressCb, CancellationToken.None); + + assert.deepStrictEqual({ + ingestSucceeded: ingestResult.isOk(), + ingestedFiles: mockClient.ingestedFiles.map(file => file.uri.toString()), + }, { + ingestSucceeded: true, + ingestedFiles: [validFile.toString()], + }); + }); + test('files that fail canIngestPathAndSize are tracked but not ingested', async () => { const workspaceRoot = URI.file('/workspace'); const file1 = URI.joinPath(workspaceRoot, 'small.ts');