Files
rea/src/filesystem/StableFileHash.ts
T
morluto e65c3955f9 fix(filesystem): preserve digest failures through cleanup cancellation
Record cancellation at file checkpoints and before owner handoff. Keep physical hash failures, exact reasons, completed facts and failed-close owners distinct through artifact and provider projections and cleanup retries.

Refs #4598. Validated with 566 passing affected tests and 22 platform skips; final Kuna cleanup reason rerun passed 43 tests. No Android device or real JADX/Kuna execution claimed.
2026-10-17 05:47:19 +08:00

95 lines
3.2 KiB
TypeScript

import { createHash } from "node:crypto";
import { stat } from "node:fs/promises";
import {
withOwnedRegularFile,
RegularFileCancelled,
RegularFileCleanupFailure,
} from "./OwnedRegularFile.js";
import {
RegularFileChangedError,
RegularFileOperationCancelled,
assertRegularFileActive,
sameRegularFileState,
} from "./RegularFile.js";
import type { OwnedFileHandle } from "./OwnedFileHandle.js";
export interface StableFileHashOptions {
readonly signal?: AbortSignal;
readonly retainFailedClose: (owner: OwnedFileHandle) => void;
}
/** Physical hash admission/read failure with no outstanding descriptor cleanup. */
export class StableFileHashFailure extends Error {
readonly _tag = "StableFileHashFailure";
constructor(
readonly path: string,
override readonly cause: unknown,
) {
super(
`Could not hash ${path}: ${cause instanceof Error ? cause.message : String(cause)}`,
{ cause },
);
this.name = this._tag;
}
}
/** Read the producing boundary's physical failure without guessing from signal state. */
export const stableFileHashCause = (cause: unknown): unknown =>
cause instanceof StableFileHashFailure ? cause.cause : cause;
/** Hash one regular file from its admitted descriptor and verify its path state. */
export const hashStableFile = async (
path: string,
options: StableFileHashOptions,
): Promise<{ readonly sha256: string; readonly bytes: number }> => {
try {
return await withOwnedRegularFile(path, options, async (handle) => {
const initial = await handle.stat();
if (!Number.isSafeInteger(initial.size) || initial.size < 0)
throw new RangeError(
`Regular file size cannot be represented safely: ${path}`,
);
const hash = createHash("sha256");
const chunk = Buffer.allocUnsafe(64 * 1024);
let position = 0;
while (position <= initial.size) {
assertRegularFileActive(options.signal);
const length = Math.min(chunk.length, initial.size - position + 1);
if (length <= 0) break;
const read = await handle.read(chunk, 0, length, position);
if (
position + read.bytesRead > initial.size ||
(read.bytesRead === 0 && position < initial.size)
)
throw new RegularFileChangedError(path);
assertRegularFileActive(options.signal);
if (read.bytesRead === 0) break;
hash.update(chunk.subarray(0, read.bytesRead));
position += read.bytesRead;
}
assertRegularFileActive(options.signal);
const [opened, currentPath] = await Promise.all([
handle.stat(),
stat(path),
]);
if (
position !== initial.size ||
!sameRegularFileState(initial, opened) ||
!sameRegularFileState(initial, currentPath)
)
throw new RegularFileChangedError(path);
assertRegularFileActive(options.signal);
return { sha256: hash.digest("hex"), bytes: position };
});
} catch (cause: unknown) {
if (
cause instanceof RegularFileCleanupFailure ||
cause instanceof RegularFileCancelled ||
cause instanceof RegularFileOperationCancelled
)
throw cause;
throw new StableFileHashFailure(path, cause);
}
};