| 1 | import fs from 'fs' |
| 2 | import path from 'path' |
| 3 | import { createHash } from 'crypto' |
| 4 | import type { GenerationContext } from './context' |
| 5 | import type { GenerateMode } from './types' |
| 6 | |
| 7 | /** |
| 8 | * Resolve only the durable session reference document. Transient attachments |
| 9 | * deliberately stay out of this contract because they must not enable the |
| 10 | * page-level Reference Range Content Boundary mode. |
| 11 | */ |
| 12 | export const resolveSessionReferenceDocumentPath = ( |
| 13 | projectDir: string, |
| 14 | sessionRecord: Record<string, unknown> |
| 15 | ): string | null => { |
| 16 | const rawReferenceDocumentPath = |
| 17 | sessionRecord.referenceDocumentPath ?? sessionRecord.reference_document_path |
| 18 | const referenceDocumentPath = |
| 19 | typeof rawReferenceDocumentPath === 'string' ? rawReferenceDocumentPath.trim() : '' |
| 20 | if (!referenceDocumentPath) return null |
| 21 | |
| 22 | const docsDir = path.resolve(projectDir, 'docs') |
| 23 | const filePath = referenceDocumentPath.startsWith('/docs/') |
| 24 | ? path.resolve(projectDir, referenceDocumentPath.replace(/^\/+/, '')) |
| 25 | : path.isAbsolute(referenceDocumentPath) |
| 26 | ? path.resolve(referenceDocumentPath) |
| 27 | : path.resolve(docsDir, referenceDocumentPath) |
| 28 | const relativeToDocs = path.relative(docsDir, filePath) |
| 29 | if (!relativeToDocs || relativeToDocs.startsWith('..') || path.isAbsolute(relativeToDocs)) { |
| 30 | return null |
| 31 | } |
| 32 | |
| 33 | try { |
| 34 | if (!fs.statSync(filePath).isFile()) return null |
| 35 | } catch { |
| 36 | return null |
| 37 | } |
| 38 | |
| 39 | return `/docs/${relativeToDocs.split(path.sep).join('/')}` |
| 40 | } |
| 41 | |
| 42 | const sanitizeAttachmentFileName = (sourcePath: string): { stem: string; extension: string } => { |
| 43 | const originalName = path.basename(sourcePath) |
| 44 | const extension = path.extname(originalName).replace(/[\\/:"*?<>|]+/g, '-') |
| 45 | const rawStem = originalName.slice(0, Math.max(0, originalName.length - extension.length)) |
| 46 | const stem = rawStem.replace(/[\\/:"*?<>|]+/g, '-').trim() || 'attachment' |
| 47 | return { stem, extension } |
| 48 | } |
| 49 | |
| 50 | const sha256 = (content: Buffer): string => createHash('sha256').update(content).digest('hex') |
| 51 | |
| 52 | const copyAttachmentWithContentHash = async (args: { |
| 53 | sourcePath: string |
| 54 | sessionDocsDir: string |
| 55 | }): Promise<string> => { |
| 56 | const content = await fs.promises.readFile(args.sourcePath) |
| 57 | const contentHash = sha256(content) |
| 58 | const { stem, extension } = sanitizeAttachmentFileName(args.sourcePath) |
| 59 | const targetStem = `${stem}--${contentHash.slice(0, 16)}` |
| 60 | |
| 61 | for (let collisionIndex = 0; collisionIndex < 10_000; collisionIndex += 1) { |
| 62 | const collisionSuffix = collisionIndex === 0 ? '' : `--${collisionIndex + 1}` |
| 63 | const targetName = `${targetStem}${collisionSuffix}${extension}` |
| 64 | const targetPath = path.join(args.sessionDocsDir, targetName) |
| 65 | if (path.resolve(args.sourcePath) === path.resolve(targetPath)) return `/docs/${targetName}` |
| 66 | |
| 67 | try { |
| 68 | const existingHash = sha256(await fs.promises.readFile(targetPath)) |
| 69 | if (existingHash === contentHash) return `/docs/${targetName}` |
| 70 | continue |
| 71 | } catch (error) { |
| 72 | if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error |
| 73 | } |
| 74 | |
| 75 | try { |
| 76 | await fs.promises.copyFile(args.sourcePath, targetPath, fs.constants.COPYFILE_EXCL) |
| 77 | return `/docs/${targetName}` |
| 78 | } catch (error) { |
| 79 | if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | throw new Error(`Unable to create a collision-safe attachment path for ${path.basename(args.sourcePath)}`) |
| 84 | } |
| 85 | |
| 86 | export async function resolveSourceDocuments( |
| 87 | ctx: Pick<GenerationContext, 'localFiles'>, |
| 88 | args: { |
| 89 | sessionId: string |
| 90 | projectDir: string |
| 91 | rawDocPaths: string[] |
| 92 | // Kept as an explicit entry-mode contract for callers. The current resolver |
| 93 | // uses the same session reference/raw-doc behavior for every mode. |
| 94 | mode: GenerateMode |
| 95 | sessionRecord: Record<string, unknown> |
| 96 | } |
| 97 | ): Promise<string[]> { |
| 98 | const { sessionId, projectDir, rawDocPaths, sessionRecord } = args |
| 99 | const { assertPathInAllowedRoots } = ctx.localFiles |
| 100 | const referenceDocumentPath = resolveSessionReferenceDocumentPath(projectDir, sessionRecord) |
| 101 | |
| 102 | const sessionDocsDir = path.join(projectDir, 'docs') |
| 103 | const sourceDocumentPaths: string[] = [] |
| 104 | const appendSourceDocumentPath = (docPath: string | null): void => { |
| 105 | if (!docPath || sourceDocumentPaths.includes(docPath)) return |
| 106 | sourceDocumentPaths.push(docPath) |
| 107 | } |
| 108 | appendSourceDocumentPath(referenceDocumentPath) |
| 109 | |
| 110 | if (rawDocPaths.length > 0) { |
| 111 | await fs.promises.mkdir(sessionDocsDir, { recursive: true }) |
| 112 | for (const candidate of rawDocPaths) { |
| 113 | const sourcePath = await assertPathInAllowedRoots({ |
| 114 | filePath: candidate, |
| 115 | mode: 'read', |
| 116 | sessionId |
| 117 | }) |
| 118 | appendSourceDocumentPath( |
| 119 | await copyAttachmentWithContentHash({ sourcePath, sessionDocsDir }) |
| 120 | ) |
| 121 | } |
| 122 | return sourceDocumentPaths |
| 123 | } |
| 124 | |
| 125 | if (sourceDocumentPaths.length > 0) await fs.promises.mkdir(sessionDocsDir, { recursive: true }) |
| 126 | return sourceDocumentPaths |
| 127 | } |
| 128 |