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 <dmitriv@microsoft.com>
This commit is contained in:
vs-code-engineering[bot]
2026-08-24 23:57:44 +00:00
committed by GitHub
co-authored by Copilot Dmitriy Vasyura
parent dbcab2ff18
commit 6a99cd4fe5
2 changed files with 49 additions and 3 deletions
@@ -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 };
}
}
@@ -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<IFileSystemService & ISearchService>() 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<MockFileEntry>();
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');