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.
95 lines
3.2 KiB
TypeScript
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);
|
|
}
|
|
};
|