327 lines
10 KiB
TypeScript
327 lines
10 KiB
TypeScript
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<void>;
|
|
commit(): Promise<{ outputPath: string; sha256: string }>;
|
|
cleanup(): Promise<void>;
|
|
}
|
|
|
|
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<void>;
|
|
}
|
|
|
|
async function sha256File(path: string): Promise<string> {
|
|
const hash = createHash('sha256');
|
|
await new Promise<void>((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<boolean> {
|
|
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<void> {
|
|
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<ExcelFileTransaction> {
|
|
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<string | null> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
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,
|
|
};
|
|
}
|