This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user