Files
maestro/src/engine/tools/excel-file-safety.ts
T
oss-sync 57702b4f34
CI / build-and-test (push) Canceled after 0s
sync: update from private repo (2336949a)
2026-09-17 11:29:31 +00:00

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,
};
}