diff --git a/src/indexer/index.ts b/src/indexer/index.ts index 136045b..990f764 100644 --- a/src/indexer/index.ts +++ b/src/indexer/index.ts @@ -3816,7 +3816,22 @@ export class Indexer { for (const file of files) { const storedPath = this.toStoredFilePath(file.path); - const currentHash = hashFile(file.path); + let currentHash: string; + try { + currentHash = hashFile(file.path); + } catch (error) { + // A file that is unreadable at the OS level (e.g., an LSM denial that + // returns EPERM despite readable mode bits, or a permissions error) + // must not abort the whole index. The hash step opens the file; a bare + // throw here previously tore down the entire run. Skip the file and + // continue so the remaining files index. + stats.skippedFiles.push({ path: this.toCanonicalFilePath(file.path), reason: "unreadable" }); + this.logger.warn("Skipped unreadable file during indexing", { + path: file.path, + error: getErrorMessage(error), + }); + continue; + } currentFileHashes.set(storedPath, currentHash); const cachedHashMatches = this.fileHashCache.get(storedPath) === currentHash; @@ -5071,7 +5086,20 @@ export class Indexer { ); const currentFileHashes = new Map(); for (const file of files) { - currentFileHashes.set(this.toStoredFilePath(file.path), hashFile(file.path)); + let hash: string; + try { + hash = hashFile(file.path); + } catch (error) { + // An unreadable file (OS-level EPERM/EACCES) makes freshness unknowable; + // treat the index as not-current so the next index_codebase run rebuilds + // (which skips unreadable files) instead of aborting here. + this.logger.warn("Skipped unreadable file during freshness check", { + path: file.path, + error: getErrorMessage(error), + }); + return { readable: false, current: false, reason: "unreadable" }; + } + currentFileHashes.set(this.toStoredFilePath(file.path), hash); } const scopedRoots = this.config.scope === "global" ? this.getScopedRoots() : null; diff --git a/src/utils/files.ts b/src/utils/files.ts index c925227..c8583c9 100644 --- a/src/utils/files.ts +++ b/src/utils/files.ts @@ -31,7 +31,7 @@ export function hasProjectMarker(projectRoot: string): boolean { export interface SkippedFile { path: string; - reason: "too_large" | "excluded" | "gitignore" | "no_match"; + reason: "too_large" | "excluded" | "gitignore" | "no_match" | "unreadable"; } export interface CollectFilesResult { diff --git a/tests/skip-unreadable-file.test.ts b/tests/skip-unreadable-file.test.ts new file mode 100644 index 0000000..29e932c --- /dev/null +++ b/tests/skip-unreadable-file.test.ts @@ -0,0 +1,121 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { parseConfig } from "../src/config/schema.js"; +import { Indexer } from "../src/indexer/index.js"; + +// A file that is unreadable at the OS level (chmod 000 -> open() returns EACCES; +// an LSM denial returns EPERM) must be skipped, not abort the whole index. The +// hash step (native/src/hasher.rs fs::File::open) throws on such a file; the +// indexer wraps hashFile in try/catch and records the file with reason +// "unreadable" so the remaining files index. chmod 000 exercises the same catch +// path as an EPERM-by-LSM file (e.g. Matlab2's lib/@medusa7/calc_spreads.m). +describe("indexer skips unreadable files instead of aborting", () => { + let tempDir: string; + let tempHome: string; + let fetchSpy: ReturnType; + const embeddingDimensions = 8; + let _indexers: Indexer[] = []; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, "fetch"); + fetchSpy.mockImplementation(async (_url: Parameters[0], init?: Parameters[1]) => { + const body = JSON.parse(String(init?.body ?? "{}")) as { input?: string[] }; + const texts = Array.isArray(body.input) ? body.input : []; + const data = texts.map((text) => { + let seed = 0; + for (const ch of text) { + seed = (seed * 31 + ch.charCodeAt(0)) % 1000; + } + return { + embedding: Array.from( + { length: embeddingDimensions }, + (_, idx) => ((seed + idx * 17) % 997) / 997, + ), + }; + }); + return new Response( + JSON.stringify({ data, usage: { total_tokens: Math.max(1, texts.length * embeddingDimensions) } }), + { status: 200 }, + ); + }); + + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "skip-unreadable-")); + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "skip-unreadable-home-")); + fs.mkdirSync(path.join(tempDir, "src"), { recursive: true }); + // A readable source file that should index normally. + fs.writeFileSync( + path.join(tempDir, "src", "readable.ts"), + ["export function alpha() {", " return 'alpha';", "}", ""].join("\n"), + "utf-8", + ); + // An unreadable source file (chmod 000): passes the walk (stat is allowed for + // the owner) but open() in hashFile fails. The indexer must skip it. + fs.writeFileSync( + path.join(tempDir, "src", "unreadable.ts"), + ["export function beta() {", " return 'beta';", "}", ""].join("\n"), + "utf-8", + ); + fs.chmodSync(path.join(tempDir, "src", "unreadable.ts"), 0o000); + }); + + afterEach(async () => { + await Promise.all(_indexers.map((i) => i.close())); + _indexers = []; + fetchSpy.mockRestore(); + // Restore perms so cleanup is robust, then remove. + try { + fs.chmodSync(path.join(tempDir, "src", "unreadable.ts"), 0o644); + } catch { + // file may be gone + } + fs.rmSync(tempDir, { recursive: true, force: true }); + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + function createIndexer(projectRoot: string): Indexer { + const config = parseConfig({ + embeddingProvider: "custom", + customProvider: { + baseUrl: "http://localhost:11434/v1", + model: `mock-${embeddingDimensions}d`, + dimensions: embeddingDimensions, + }, + debug: { enabled: true, logLevel: "warn", logSearch: false, logEmbedding: false, logCache: false, logGc: false, logBranch: false, metrics: false }, + indexing: { watchFiles: false, retries: 0, retryDelayMs: 1 }, + }); + const indexer = new Indexer(projectRoot, config, "opencode"); + _indexers.push(indexer); + return indexer; + } + + it("indexes the readable file and records the unreadable file as skipped", async () => { + const indexer = createIndexer(tempDir); + const stats = await indexer.index(); + + expect(stats.failedChunks).toBe(0); + expect(stats.indexedChunks).toBeGreaterThan(0); + const unreadableSkip = stats.skippedFiles.find( + (entry) => entry.reason === "unreadable" && entry.path.endsWith("unreadable.ts"), + ); + expect(unreadableSkip).toBeDefined(); + }); + + // The same unreadable-file throw also runs through getIndexFreshness, which + // hashes every collected file to compare against the cached hashes. Before + // the guard, a single unreadable file rejected the freshness promise and + // aborted the check; the guard returns reason "unreadable" instead. The + // index must exist first, or getIndexFreshness returns "missing" before it + // reaches the hash loop, so index once, then check freshness. + it("reports the index as unreadable during the freshness check", async () => { + const indexer = createIndexer(tempDir); + await indexer.index(); + + const freshness = await indexer.getIndexFreshness(); + + expect(freshness).toEqual({ readable: false, current: false, reason: "unreadable" }); + }); +}); \ No newline at end of file