import fs from 'node:fs'; import path from 'node:path'; import { AiApiError, AiErrorCodes } from '../ai-shared/AiError'; import { AI_LIMITS } from '../ai-shared/AiLimits'; import type { AiRuntimeConfig } from '../ai-shared/AiRuntimeConfig'; export interface KnowledgeFileInfo { path: string; size: number; mtime: string; } export interface KnowledgeRepositoryHooks { beforeOpen?: (logicalPath: string, realPath: string) => void; } interface OpenedKnowledgeFile { content: Buffer; logicalPath: string; size: number; } interface KnowledgeRoot { name: string; absolutePath: string; realPath: string; } export class KnowledgeRepository { private readonly roots: KnowledgeRoot[]; private readonly allowedExtensions: ReadonlySet; private readonly maxFileBytes: number; private readonly beforeOpenForTest?: (logicalPath: string, realPath: string) => void; constructor( config: AiRuntimeConfig, hooks?: KnowledgeRepositoryHooks, ) { this.beforeOpenForTest = hooks?.beforeOpen; this.allowedExtensions = new Set(config.assistant.knowledge.allowedExtensions); this.maxFileBytes = config.assistant.knowledge.maxFileBytes; const hostPath = path.resolve(config.hostDir); let hostRealPath: string; try { hostRealPath = fs.realpathSync(hostPath); if (!fs.statSync(hostRealPath).isDirectory()) { throw new Error('invalid Host root'); } } catch { throw invalidPath('Host 根目录不存在或不是目录'); } this.roots = config.assistant.knowledge.roots.map((root) => { if (!root || path.isAbsolute(root)) { throw invalidPath('知识库根目录必须是 Host 相对路径'); } const absolutePath = path.resolve(hostPath, root); if (!isContained(hostPath, absolutePath)) { throw invalidPath('知识库根目录越出 Host'); } try { const realPath = fs.realpathSync(absolutePath); if (!fs.statSync(realPath).isDirectory()) { throw new Error('invalid root'); } if (!isContained(hostRealPath, realPath)) { throw invalidPath(`知识库根目录越出 Host:${root}`); } return { name: normalizeRelative(root), absolutePath, realPath }; } catch (error) { if (error instanceof AiApiError) { throw error; } throw notFound(`知识库根目录不存在:${root}`); } }); } rootNames(): string[] { return this.roots.map((root) => root.name); } listFiles(dir?: string, signal?: AbortSignal): { files: KnowledgeFileInfo[] } { const selection = this.resolveDirectorySelection(dir); const files: KnowledgeFileInfo[] = []; for (const root of selection.roots) { this.walk(root, selection.relativeDir, 0, files, signal); if (files.length >= AI_LIMITS.knowledgeFiles) { break; } } files.sort((left, right) => left.path.localeCompare(right.path)); return { files: files.slice(0, AI_LIMITS.knowledgeFiles) }; } readFile( logicalPath: string, offset = 0, signal?: AbortSignal, ): { path: string; content: string; truncated: boolean; nextOffset?: number } { signal?.throwIfAborted(); if (!Number.isSafeInteger(offset) || offset < 0) { throw invalidPath('offset 必须是非负整数'); } const opened = this.openFile(logicalPath); if (offset > opened.size) { throw invalidPath('offset 超出文件范围'); } if (offset < opened.size && isUtf8ContinuationByte(opened.content[offset])) { throw invalidPath('offset 必须位于 UTF-8 字符边界'); } let end = Math.min(opened.size, offset + AI_LIMITS.knowledgeReadBytes); while (end < opened.size && end > offset && isUtf8ContinuationByte(opened.content[end])) { end -= 1; } const truncated = end < opened.size; return { path: opened.logicalPath, content: opened.content.subarray(offset, end).toString('utf8'), truncated, ...(truncated ? { nextOffset: end } : {}), }; } search( keyword: string, signal?: AbortSignal, ): { hits: Array<{ path: string; line: number; snippet: string }> } { const normalizedKeyword = keyword.trim(); if ( normalizedKeyword.length < AI_LIMITS.knowledgeKeywordMin || normalizedKeyword.length > AI_LIMITS.knowledgeKeywordMax ) { throw invalidPath('关键词长度必须为 2–64 个字符'); } const files = this.listFiles(undefined, signal).files; const hits: Array<{ path: string; line: number; snippet: string }> = []; const needle = normalizedKeyword.toLocaleLowerCase(); for (const file of files) { signal?.throwIfAborted(); const opened = this.openFile(file.path); const lines = opened.content.toString('utf8').split(/\r?\n/); for (let index = 0; index < lines.length; index += 1) { if (!lines[index].toLocaleLowerCase().includes(needle)) { continue; } hits.push({ path: file.path, line: index + 1, snippet: lines[index].slice(0, AI_LIMITS.knowledgeSnippetCharacters), }); if (hits.length >= AI_LIMITS.knowledgeSearchHits) { return { hits }; } } } return { hits }; } private walk( root: { name: string; absolutePath: string; realPath: string }, relativeDir: string, depth: number, files: KnowledgeFileInfo[], signal?: AbortSignal, ): void { signal?.throwIfAborted(); if (depth > AI_LIMITS.knowledgeDepth || files.length >= AI_LIMITS.knowledgeFiles) { return; } const target = path.resolve(root.absolutePath, relativeDir); if (!isContained(root.absolutePath, target)) { throw invalidPath('知识库目录路径逃逸'); } let realTarget: string; try { realTarget = fs.realpathSync(target); if (!isContained(root.realPath, realTarget) || !fs.statSync(realTarget).isDirectory()) { throw new Error('invalid directory'); } } catch (error) { if (error instanceof AiApiError) { throw error; } throw notFound('知识库目录不存在'); } for (const name of fs.readdirSync(realTarget).sort((left, right) => left.localeCompare(right))) { signal?.throwIfAborted(); const child = path.join(realTarget, name); let realChild: string; let stat: ReturnType; try { realChild = fs.realpathSync(child); stat = fs.statSync(realChild); } catch { continue; } if (!isContained(root.realPath, realChild)) { continue; } const childRelative = normalizeRelative(path.join(relativeDir, name)); if (stat.isDirectory()) { this.walk(root, childRelative, depth + 1, files, signal); } else if ( stat.isFile() && stat.size <= this.maxFileBytes && this.allowedExtensions.has(path.extname(name).toLowerCase()) ) { files.push({ path: normalizeRelative(path.join(root.name, childRelative)), size: stat.size, mtime: stat.mtime.toISOString(), }); } if (files.length >= AI_LIMITS.knowledgeFiles) { return; } } } private resolveDirectorySelection(dir?: string): { roots: KnowledgeRoot[]; relativeDir: string; } { const normalized = normalizeUserPath(dir ?? ''); if (!normalized) { return { roots: this.roots, relativeDir: '' }; } const matchingRoot = this.matchExplicitRoot(normalized); if (matchingRoot) { return { roots: [matchingRoot], relativeDir: relativeToRoot(normalized, matchingRoot.name), }; } return { roots: this.roots, relativeDir: normalized }; } private openFile(logicalPath: string): OpenedKnowledgeFile { const normalized = normalizeUserPath(logicalPath); if (!normalized) { throw invalidPath('知识库文件路径不能为空'); } const matchingRoot = this.matchExplicitRoot(normalized); const candidates = matchingRoot ? [{ root: matchingRoot, relative: relativeToRoot(normalized, matchingRoot.name) }] : this.roots.map((root) => ({ root, relative: normalized })); for (const { root, relative } of candidates.filter(({ relative }) => Boolean(relative))) { const absolutePath = path.resolve(root.absolutePath, relative); if (!isContained(root.absolutePath, absolutePath)) { continue; } let fd: number | undefined; try { const realPath = fs.realpathSync(absolutePath); if (!isContained(root.realPath, realPath)) { continue; } const expectedStat = fs.statSync(realPath); if ( !expectedStat.isFile() || expectedStat.size > this.maxFileBytes || !this.allowedExtensions.has(path.extname(realPath).toLowerCase()) ) { continue; } this.beforeOpenForTest?.(normalized, realPath); fd = fs.openSync(realPath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); const stat = fs.fstatSync(fd); const postRealPath = fs.realpathSync(absolutePath); const pathStat = fs.statSync(postRealPath); if ( !stat.isFile() || stat.size > this.maxFileBytes || !sameFile(stat, expectedStat) || !isContained(root.realPath, postRealPath) || !sameFile(stat, pathStat) ) { fs.closeSync(fd); fd = undefined; continue; } const content = Buffer.alloc(stat.size); let bytesRead = 0; while (bytesRead < stat.size) { const read = fs.readSync(fd, content, bytesRead, stat.size - bytesRead, bytesRead); if (read === 0) { break; } bytesRead += read; } const finalStat = fs.fstatSync(fd); fs.closeSync(fd); fd = undefined; if (bytesRead !== stat.size || finalStat.size !== stat.size || finalStat.size > this.maxFileBytes) { continue; } return { content, logicalPath: normalizeRelative(path.join(root.name, relative)), size: stat.size, }; } catch { if (fd !== undefined) { fs.closeSync(fd); } continue; } } if (!this.allowedExtensions.has(path.extname(normalized).toLowerCase())) { throw invalidPath('知识库文件扩展名不允许'); } throw notFound('知识库文件不存在'); } private matchExplicitRoot(normalizedPath: string): KnowledgeRoot | undefined { return this.roots .filter((root) => ( normalizedPath === root.name || normalizedPath.startsWith(`${root.name}/`) )) .sort((left, right) => right.name.length - left.name.length)[0]; } } function sameFile( left: { dev: number; ino: number }, right: { dev: number; ino: number }, ): boolean { return left.dev === right.dev && left.ino === right.ino; } function relativeToRoot(normalizedPath: string, rootName: string): string { return normalizedPath === rootName ? '' : normalizedPath.slice(rootName.length + 1); } function isUtf8ContinuationByte(value: number | undefined): boolean { return value !== undefined && (value & 0b1100_0000) === 0b1000_0000; } function normalizeUserPath(value: string): string { if (typeof value !== 'string' || value.includes('\0') || path.isAbsolute(value)) { throw invalidPath('知识库路径不合法'); } const normalized = normalizeRelative(value); if (normalized === '..' || normalized.startsWith('../')) { throw invalidPath('知识库路径逃逸'); } return normalized === '.' ? '' : normalized; } function normalizeRelative(value: string): string { return value.replaceAll('\\', '/').replace(/^\.\//, '').replace(/\/+/g, '/').replace(/\/$/, ''); } function isContained(root: string, target: string): boolean { const relative = path.relative(path.resolve(root), path.resolve(target)); return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); } function invalidPath(message: string): AiApiError { return new AiApiError(AiErrorCodes.AI_KB_PATH_INVALID, message); } function notFound(message: string): AiApiError { return new AiApiError(AiErrorCodes.AI_KB_NOT_FOUND, message); }