import { createHash, randomUUID } from 'node:crypto'; import { createReadStream, existsSync } from 'node:fs'; import { link, lstat, mkdir, realpath, rename, rm, stat, unlink, } from 'node:fs/promises'; import { basename, dirname, join, relative, resolve, sep } from 'node:path'; import ExcelJS from 'exceljs'; import { recordVersion } from './conflict-guard.js'; import type { FileProvenanceEvent } from './tool-context.js'; import { assertWritable, resolveAndGuard } from './core.js'; import type { ExcelViolationCode } from './excel-types.js'; export class ExcelFileSafetyError extends Error { constructor( public readonly code: ExcelViolationCode, message: string, ) { super(message); this.name = 'ExcelFileSafetyError'; } } export interface ExcelFileTransaction { sourcePath?: string; sourceSha256?: string; destinationPath: string; candidatePath: string; validationDir: string; assertSourceUnchanged(): Promise; commit(): Promise<{ outputPath: string; sha256: string }>; cleanup(): Promise; } export interface CreateExcelFileTransactionOptions { workspacePath: string; sourcePath?: string; outputPath: string; overwrite?: boolean; expectedSourceSha256?: string | null; conflictDir?: string; recordFileProvenance?: (event: FileProvenanceEvent) => void; validateCandidate?: (candidatePath: string, validationDir: string) => Promise; } async function sha256File(path: string): Promise { const hash = createHash('sha256'); await new Promise((resolve, reject) => { const stream = createReadStream(path); stream.on('data', (chunk: string | Buffer) => { hash.update(chunk); }); stream.once('error', reject); stream.once('end', resolve); }); return hash.digest('hex'); } async function isFile(path: string): Promise { try { return (await stat(path)).isFile(); } catch { return false; } } function workspaceRelative(workspacePath: string, path: string): string { return relative(resolve(workspacePath), path).split(sep).join('/'); } function isPathWithin(root: string, path: string): boolean { return path === root || path.startsWith(root + sep); } async function validateWithExcelJs(candidatePath: string): Promise { try { const workbook = new ExcelJS.Workbook(); await workbook.xlsx.readFile(candidatePath); } catch { throw new ExcelFileSafetyError('PACKAGE_INVALID', '候補Excelファイルを再読込できません'); } } function safetyPath( workspacePath: string, path: string, writable: boolean, ): string { try { const guarded = resolveAndGuard(workspacePath, path); if (writable) assertWritable(workspacePath, guarded); return guarded; } catch (error) { throw new ExcelFileSafetyError('INPUT_INVALID', (error as Error).message); } } export async function createExcelFileTransaction( options: CreateExcelFileTransactionOptions, ): Promise { const destinationPath = safetyPath(options.workspacePath, options.outputPath, true); const sourcePath = options.sourcePath ? safetyPath(options.workspacePath, options.sourcePath, false) : undefined; const overwrite = options.overwrite === true; let workspaceRealPath: string; try { workspaceRealPath = await realpath(resolve(options.workspacePath)); } catch { throw new ExcelFileSafetyError('INPUT_INVALID', 'ワークスペースを解決できません'); } if (sourcePath && !(await isFile(sourcePath))) { throw new ExcelFileSafetyError('INPUT_INVALID', '入力Excelファイルが存在しません'); } const sourceSha256 = sourcePath ? await sha256File(sourcePath) : undefined; const expectedSourceSha256 = options.expectedSourceSha256?.toLowerCase() ?? null; if (expectedSourceSha256 && !sourcePath) { throw new ExcelFileSafetyError( 'INPUT_INVALID', 'expected_source_sha256には入力Excelファイルが必要です', ); } if (expectedSourceSha256 && expectedSourceSha256 !== sourceSha256) { throw new ExcelFileSafetyError( 'SOURCE_CHANGED_DURING_EDIT', '入力ExcelファイルのSHA-256が期待値と一致しません', ); } if (overwrite) { if (!sourcePath || sourcePath !== destinationPath) { throw new ExcelFileSafetyError( 'OUTPUT_CONFLICT', '上書きには同一の入力パスと出力パスが必要です', ); } if (!expectedSourceSha256) { throw new ExcelFileSafetyError( 'INPUT_INVALID', '上書きにはexpected_source_sha256が必要です', ); } } else if (existsSync(destinationPath)) { throw new ExcelFileSafetyError('OUTPUT_CONFLICT', '出力先は既に存在します'); } await mkdir(dirname(destinationPath), { recursive: true }); safetyPath(options.workspacePath, destinationPath, true); const id = randomUUID(); const name = basename(destinationPath); const candidatePath = safetyPath( options.workspacePath, join(dirname(destinationPath), `.${name}.${id}.candidate.xlsx`), true, ); const validationDir = safetyPath( options.workspacePath, join(dirname(destinationPath), `.${name}.${id}.validation`), true, ); await mkdir(validationDir, { recursive: false }); const outputPath = workspaceRelative(options.workspacePath, destinationPath); const destinationExisted = existsSync(destinationPath); let committed = false; const resolveSafeTemporaryPath = async (path: string): Promise => { try { const realParent = await realpath(dirname(path)); if (!isPathWithin(workspaceRealPath, realParent)) return null; return join(realParent, basename(path)); } catch { return null; } }; const cleanup = async (): Promise => { const [safeCandidatePath, safeValidationDir] = await Promise.all([ resolveSafeTemporaryPath(candidatePath), resolveSafeTemporaryPath(validationDir), ]); await Promise.allSettled([ safeCandidatePath ? unlink(safeCandidatePath) : Promise.resolve(), safeValidationDir ? rm(safeValidationDir, { recursive: true, force: true }) : Promise.resolve(), ]); }; const resolveCommitPaths = async (): Promise<{ candidate: string; destination: string; }> => { try { const [realCandidateDir, realDestinationDir] = await Promise.all([ realpath(dirname(candidatePath)), realpath(dirname(destinationPath)), ]); if ( realCandidateDir !== realDestinationDir || !isPathWithin(workspaceRealPath, realDestinationDir) ) { throw new ExcelFileSafetyError( 'INPUT_INVALID', '出力先ディレクトリがワークスペース外へ変更されました', ); } const candidate = join(realCandidateDir, basename(candidatePath)); const destination = join(realDestinationDir, basename(destinationPath)); assertWritable(workspaceRealPath, destination); const candidateStat = await lstat(candidate); if (!candidateStat.isFile() || candidateStat.isSymbolicLink()) { throw new ExcelFileSafetyError('PACKAGE_INVALID', '候補Excelファイルが通常ファイルではありません'); } return { candidate, destination }; } catch (error) { if (error instanceof ExcelFileSafetyError) throw error; throw new ExcelFileSafetyError('INPUT_INVALID', '確定対象の実パスを検証できません'); } }; const assertSourceUnchanged = async (): Promise => { if (!sourcePath || !sourceSha256) return; let currentSha256: string; try { currentSha256 = await sha256File(sourcePath); } catch { throw new ExcelFileSafetyError( 'SOURCE_CHANGED_DURING_EDIT', '入力Excelファイルが編集中に変更されました', ); } if (currentSha256 !== sourceSha256) { throw new ExcelFileSafetyError( 'SOURCE_CHANGED_DURING_EDIT', '入力Excelファイルが編集中に変更されました', ); } }; const recordSuccess = (sha256: string): void => { if (options.conflictDir) { try { recordVersion(options.workspacePath, outputPath, options.conflictDir); } catch { // Conflict recording is best-effort and must not invalidate a commit. } } if (options.recordFileProvenance) { try { options.recordFileProvenance({ relPath: outputPath, event: destinationExisted ? 'modify' : 'create', sourceKind: 'agent_output', checksum: sha256, }); } catch { // Provenance is best-effort and must not invalidate a committed file. } } }; const commit = async (): Promise<{ outputPath: string; sha256: string }> => { if (committed) { throw new ExcelFileSafetyError('OUTPUT_CONFLICT', 'このExcelトランザクションは確定済みです'); } try { await assertSourceUnchanged(); const commitPaths = await resolveCommitPaths(); await validateWithExcelJs(commitPaths.candidate); if (options.validateCandidate) { await options.validateCandidate(commitPaths.candidate, validationDir); } const candidateSha256 = await sha256File(commitPaths.candidate); // Keep these checks immediately adjacent to the atomic filesystem operation. await assertSourceUnchanged(); const finalPaths = await resolveCommitPaths(); if (finalPaths.candidate !== commitPaths.candidate) { throw new ExcelFileSafetyError('INPUT_INVALID', '候補ファイルの実パスが検証後に変更されました'); } if (overwrite) { await rename(finalPaths.candidate, finalPaths.destination); } else { try { await link(finalPaths.candidate, finalPaths.destination); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'EEXIST') { throw new ExcelFileSafetyError('OUTPUT_CONFLICT', '出力先が確定前に作成されました'); } throw error; } await unlink(finalPaths.candidate).catch(() => undefined); } committed = true; const committedSha256 = await sha256File(finalPaths.destination) .catch(() => candidateSha256); recordSuccess(committedSha256); await cleanup(); return { outputPath, sha256: committedSha256 }; } catch (error) { if (!committed) await cleanup(); throw error; } }; return { sourcePath, sourceSha256, destinationPath, candidatePath, validationDir, assertSourceUnchanged, commit, cleanup, }; }