Files
rea/src/filesystem/StableFileHash.ts
T
morluto 04ae07ab8c fix(kuna): bind version probes to stable executable identity
Compare executable fingerprints before and after the owned version probe, and capture the script prefix from the final hash stream. Preserve partial facts and failed-close ownership without claiming process interruption from a snapshot.

Closes #5611
2026-10-21 00:52:53 +08:00

144 lines
4.6 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;
}
export interface StableFileObservation {
readonly sha256: string;
readonly bytes: number;
/** The first two bytes, read from the same descriptor and stream as the hash. */
readonly prefix: Buffer;
}
const STABLE_FILE_PREFIX_BYTES = 2;
interface StableFileFingerprint {
readonly sha256: string;
readonly bytes: number;
}
/** 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 }> => {
return await readStableFile(path, options, false);
};
/** Hash a stable file and capture exactly its first two bytes from that read. */
export const observeStableFile = (
path: string,
options: StableFileHashOptions,
): Promise<StableFileObservation> => readStableFile(path, options, true);
function readStableFile(
path: string,
options: StableFileHashOptions,
includePrefix: false,
): Promise<StableFileFingerprint>;
function readStableFile(
path: string,
options: StableFileHashOptions,
includePrefix: true,
): Promise<StableFileObservation>;
async function readStableFile(
path: string,
options: StableFileHashOptions,
includePrefix: boolean,
): Promise<StableFileFingerprint | StableFileObservation> {
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);
const prefix = Buffer.alloc(
includePrefix ? Math.min(STABLE_FILE_PREFIX_BYTES, initial.size) : 0,
);
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;
if (position < prefix.length) {
const prefixBytes = Math.min(
read.bytesRead,
prefix.length - position,
);
chunk.copy(prefix, position, 0, prefixBytes);
}
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);
const fingerprint = { sha256: hash.digest("hex"), bytes: position };
return includePrefix ? { ...fingerprint, prefix } : fingerprint;
});
} catch (cause: unknown) {
if (
cause instanceof RegularFileCleanupFailure ||
cause instanceof RegularFileCancelled ||
cause instanceof RegularFileOperationCancelled
)
throw cause;
throw new StableFileHashFailure(path, cause);
}
}