Files
reverse-engineering-anything/src/artifacts/ArtifactFileSource.ts
T

210 lines
6.6 KiB
TypeScript

import { lstat } from "node:fs/promises";
import { artifactIntegrityDetails } from "../domain/artifactIntegrity.js";
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js";
import {
RegularFileAdmissionFailure,
openRegularFile,
sameRegularFileState,
type StableRegularFileDescriptor,
} from "../filesystem/RegularFile.js";
import { assertArtifactReadActive } from "./ArtifactFileOperation.js";
import { artifactFileAdmissionFailure } from "./ArtifactFileAdmission.js";
import { hashReadable } from "./ArtifactHash.js";
import { ArtifactReaderFailure } from "./ArtifactReader.js";
import { ArtifactResourceScope } from "./ArtifactResourceScope.js";
/** Verify an admitted descriptor against the regular file still named by its path. */
export const assertArtifactFileSelectedSource = async (
path: string,
source: StableRegularFileDescriptor,
diagnosticLabel: string,
): Promise<void> => {
const [observed, selected] = await Promise.all([
source.handle.stat(),
lstat(path).catch((cause: unknown) => {
// Admission already established this selected name. Its disappearance
// or a replaced ancestor is a source change, not an unrelated I/O fault.
if (
cause instanceof Error &&
"code" in cause &&
(cause.code === "ENOENT" || cause.code === "ENOTDIR")
)
throw new ArtifactReaderFailure(
"integrity",
`${diagnosticLabel} source identity changed: ${path}`,
{ cause },
);
throw cause;
}),
]);
if (
!sameRegularFileState(source.initial, observed) ||
!sameRegularFileState(source.initial, selected)
)
throw new ArtifactReaderFailure(
"integrity",
`${diagnosticLabel} source identity changed: ${path}`,
);
};
/** One persistent descriptor and hash binding, borrowing caller-admitted sources. */
export class ArtifactFileSource {
readonly #scope = new ArtifactResourceScope();
#owner: OwnedFileHandle | undefined;
#opening: Promise<StableRegularFileDescriptor> | undefined;
#boundSha256: string | undefined;
constructor(
private readonly path: string,
private readonly diagnosticLabel: string,
private readonly admitted?: StableRegularFileDescriptor,
) {
this.#scope.addCloseHook(async () => {
if (this.#owner !== undefined) {
await this.#owner.close();
this.#owner = undefined;
}
this.#opening = undefined;
this.#boundSha256 = undefined;
});
}
/** Verify the admitted descriptor; the selected pathname is not reinterpreted. */
read(signal?: AbortSignal): Promise<StableRegularFileDescriptor> {
return this.#scope.run(() => this.#verify(signal));
}
/** Verify both the descriptor and the file currently at the caller-selected path. */
readSelectedPath(signal?: AbortSignal): Promise<StableRegularFileDescriptor> {
return this.#scope.run(() => this.#verifySelectedPath(signal));
}
/** Bind verified bytes to the inventory's expected whole-carrier identity. */
bindSha256(expectedSha256: string, signal?: AbortSignal): Promise<void> {
return this.#scope.run(() =>
this.#bind(
expectedSha256,
signal,
() => this.#verify(signal),
() => this.#verify(),
),
);
}
/** Bind carrier bytes while the selected pathname continues to name the same source. */
bindSelectedPathSha256(
expectedSha256: string,
signal?: AbortSignal,
): Promise<void> {
return this.#scope.run(() =>
this.#bind(
expectedSha256,
signal,
() => this.#verifySelectedPath(signal),
() => this.#verifySelectedPath(),
),
);
}
/** Join admitted operations before closing; failed descriptor ownership stays retained. */
close(): Promise<void> {
return this.#scope.close();
}
async #bind(
expectedSha256: string,
signal: AbortSignal | undefined,
verify: () => Promise<StableRegularFileDescriptor>,
verifyCompletion: () => Promise<StableRegularFileDescriptor>,
): Promise<void> {
const { handle } = await verify();
const observed =
this.#boundSha256 ??
(
await hashReadable(
readFileHandleChunks(handle, {
signal,
cancellationFailure: (reason) =>
new ArtifactReaderFailure(
"cancelled",
`${this.diagnosticLabel} verification cancelled`,
{ cause: reason },
),
}),
signal,
)
).sha256;
// A completed digest needs source validation before late cancellation can
// decide the outcome; unverified bytes must not establish a contradiction.
await verifyCompletion();
if (observed !== expectedSha256)
throw new ArtifactReaderFailure(
"integrity",
`${this.diagnosticLabel} carrier changed after inventory`,
undefined,
artifactIntegrityDetails(
".",
{ algorithm: "sha256", digest: expectedSha256 },
observed,
false,
),
);
assertArtifactReadActive(signal);
this.#boundSha256 = observed;
}
async #verify(signal?: AbortSignal): Promise<StableRegularFileDescriptor> {
assertArtifactReadActive(signal);
this.#opening ??= this.#admit(signal);
const source = await this.#opening;
const observed = await source.handle.stat();
if (!sameRegularFileState(source.initial, observed))
throw new ArtifactReaderFailure(
"integrity",
`${this.diagnosticLabel} source identity changed: ${this.path}`,
);
assertArtifactReadActive(signal);
return source;
}
async #verifySelectedPath(
signal?: AbortSignal,
): Promise<StableRegularFileDescriptor> {
assertArtifactReadActive(signal);
this.#opening ??= this.#admit(signal);
const source = await this.#opening;
await assertArtifactFileSelectedSource(
this.path,
source,
this.diagnosticLabel,
);
assertArtifactReadActive(signal);
return source;
}
async #admit(signal?: AbortSignal): Promise<StableRegularFileDescriptor> {
try {
if (this.admitted !== undefined)
return {
handle: this.admitted.handle,
initial: this.admitted.initial,
};
const source = await openRegularFile(this.path, {
symlinks: "reject",
signal,
});
this.#owner = new OwnedFileHandle(source.handle);
return source;
} catch (cause: unknown) {
if (cause instanceof RegularFileAdmissionFailure)
this.#owner = cause.owner;
throw artifactFileAdmissionFailure(
this.path,
this.diagnosticLabel,
cause,
);
}
}
}