Require a caller-owned artifact resource scope for source traversal and Git metadata reads. Preserve cancellation, filesystem reasons, partial entries and bytes, and cleanup observations through import and the CLI. Transfer exact failed file owners at regular-file admission and close boundaries, including multiple failures encountered by Git reference resolution. Validation: 169 focused tests passed across 26 files; two Windows-only cases skipped. Build, typecheck, lint, formatting, knip, architecture guards, discovery and docs checks passed. BREAKING CHANGE: Reference source readers and imports require an ArtifactResourceScope. VCS metadata reads return Result, and CLI import failures include observed details and partial evidence.
305 lines
7.8 KiB
TypeScript
305 lines
7.8 KiB
TypeScript
import type { BigIntStats } from "node:fs";
|
|
import type { FileHandle } from "node:fs/promises";
|
|
import { dirname } from "node:path";
|
|
|
|
import {
|
|
NonRegularFileReadError,
|
|
RegularFileAdmissionFailure,
|
|
openRegularFile,
|
|
} from "../filesystem/RegularFile.js";
|
|
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
|
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
|
import type { AnalysisCleanupObservation } from "../domain/analysisErrorBase.js";
|
|
|
|
import {
|
|
entryFailure,
|
|
filesystemFailureDetail,
|
|
safeSize,
|
|
} from "./ReferenceSourceReaderErrors.js";
|
|
import {
|
|
isAborted,
|
|
sameFile,
|
|
validateDirectory,
|
|
} from "./ReferenceSourceReaderValidate.js";
|
|
import {
|
|
type ReferenceSourceEntry,
|
|
type StableFileRequest,
|
|
} from "./ReferenceSourceReaderTypes.js";
|
|
|
|
const READ_CHUNK_BYTES = 64 * 1024;
|
|
|
|
type PreparedFileRead =
|
|
| {
|
|
readonly status: "ready";
|
|
readonly before: BigIntStats;
|
|
}
|
|
| { readonly status: "failed"; readonly entry: ReferenceSourceEntry };
|
|
|
|
type FileContentsRead =
|
|
| { readonly status: "ok"; readonly chunks: Buffer[]; readonly total: number }
|
|
| { readonly status: "failed"; readonly entry: ReferenceSourceEntry };
|
|
|
|
type FinalizeFileReadRequest = {
|
|
readonly root: string;
|
|
readonly rootIdentity: BigIntStats;
|
|
readonly absolute: string;
|
|
readonly path: string;
|
|
readonly handle: FileHandle;
|
|
readonly before: BigIntStats;
|
|
readonly parentBefore: { readonly ok: true; readonly stats: BigIntStats };
|
|
readonly chunks: Buffer[];
|
|
readonly total: number;
|
|
readonly signal?: AbortSignal;
|
|
};
|
|
|
|
const prepareFileRead = async (
|
|
request: StableFileRequest,
|
|
handle: FileHandle,
|
|
): Promise<PreparedFileRead> => {
|
|
const { path, expected, signal } = request;
|
|
if (isAborted(signal)) {
|
|
return {
|
|
status: "failed",
|
|
entry: entryFailure(
|
|
path,
|
|
"file",
|
|
"cancelled",
|
|
"File read cancelled",
|
|
safeSize(expected.size),
|
|
),
|
|
};
|
|
}
|
|
const before = await handle.stat({ bigint: true });
|
|
if (!sameFile(expected, before)) {
|
|
return {
|
|
status: "failed",
|
|
entry: entryFailure(
|
|
path,
|
|
"file",
|
|
"changed",
|
|
"File changed before it was read",
|
|
safeSize(before.size),
|
|
),
|
|
};
|
|
}
|
|
return { status: "ready", before };
|
|
};
|
|
|
|
/** Read a stable-size file through its borrowed descriptor; growth is a change. */
|
|
export const readFileContents = async (request: {
|
|
readonly handle: {
|
|
read(
|
|
buffer: Buffer,
|
|
offset: number,
|
|
length: number,
|
|
position: null,
|
|
): Promise<{ readonly bytesRead: number }>;
|
|
};
|
|
readonly path: string;
|
|
readonly expectedSize: bigint;
|
|
readonly signal?: AbortSignal;
|
|
}): Promise<FileContentsRead> => {
|
|
const { handle, path, signal } = request;
|
|
const chunks: Buffer[] = [];
|
|
let total = 0;
|
|
for (;;) {
|
|
if (isAborted(signal))
|
|
return {
|
|
status: "failed",
|
|
entry: entryFailure(
|
|
path,
|
|
"file",
|
|
"cancelled",
|
|
"File read cancelled",
|
|
total,
|
|
),
|
|
};
|
|
// One extra byte detects growth without allocating a full chunk at EOF.
|
|
const remaining = request.expectedSize - BigInt(total);
|
|
const chunkBytes =
|
|
remaining >= BigInt(READ_CHUNK_BYTES)
|
|
? READ_CHUNK_BYTES
|
|
: Number(remaining) + 1;
|
|
const chunk = Buffer.allocUnsafe(chunkBytes);
|
|
const read = await handle.read(chunk, 0, chunk.byteLength, null);
|
|
if (isAborted(signal))
|
|
return {
|
|
status: "failed",
|
|
entry: entryFailure(
|
|
path,
|
|
"file",
|
|
"cancelled",
|
|
"File read cancelled",
|
|
total,
|
|
),
|
|
};
|
|
if (read.bytesRead === 0) break;
|
|
total += read.bytesRead;
|
|
if (BigInt(total) > request.expectedSize)
|
|
return {
|
|
status: "failed",
|
|
entry: entryFailure(
|
|
path,
|
|
"file",
|
|
"changed",
|
|
"File grew while it was read",
|
|
total,
|
|
),
|
|
};
|
|
chunks.push(chunk.subarray(0, read.bytesRead));
|
|
}
|
|
return { status: "ok", chunks, total };
|
|
};
|
|
|
|
const finalizeFileRead = async (
|
|
request: FinalizeFileReadRequest,
|
|
): Promise<ReferenceSourceEntry> => {
|
|
const {
|
|
root,
|
|
rootIdentity,
|
|
absolute,
|
|
path,
|
|
handle,
|
|
before,
|
|
parentBefore,
|
|
chunks,
|
|
total,
|
|
} = request;
|
|
const after = await handle.stat({ bigint: true });
|
|
const parentAfter = await validateDirectory(
|
|
root,
|
|
rootIdentity,
|
|
dirname(absolute),
|
|
request.signal,
|
|
);
|
|
if (!parentAfter.ok || !sameFile(parentBefore.stats, parentAfter.stats))
|
|
return entryFailure(
|
|
path,
|
|
"file",
|
|
"changed",
|
|
"Parent directory changed while file was read",
|
|
total,
|
|
);
|
|
if (!sameFile(before, after) || BigInt(total) !== after.size)
|
|
return entryFailure(
|
|
path,
|
|
"file",
|
|
"changed",
|
|
"File changed while it was read",
|
|
total,
|
|
);
|
|
return {
|
|
status: "read",
|
|
kind: "file",
|
|
path,
|
|
bytes: Buffer.concat(chunks, total),
|
|
size: total,
|
|
};
|
|
};
|
|
|
|
export const readStableFile = async (
|
|
request: StableFileRequest,
|
|
): Promise<{
|
|
readonly entry: ReferenceSourceEntry;
|
|
readonly cleanup?: AnalysisCleanupObservation;
|
|
}> => {
|
|
let owner: OwnedFileHandle | undefined;
|
|
let cleanup: AnalysisCleanupObservation | undefined;
|
|
let entry: ReferenceSourceEntry;
|
|
try {
|
|
entry = await readStableFileEntry(request, (admitted) => {
|
|
owner = admitted;
|
|
});
|
|
} finally {
|
|
if (owner !== undefined) {
|
|
const result = await request.resources.release({
|
|
kind: "file-handle",
|
|
handle: owner,
|
|
resource: request.absolute,
|
|
});
|
|
if (result.kind === "failed")
|
|
cleanup = ArtifactReaderFailure.cleanupObservation(
|
|
result.cause,
|
|
request.absolute,
|
|
);
|
|
}
|
|
}
|
|
return { entry, ...(cleanup === undefined ? {} : { cleanup }) };
|
|
};
|
|
|
|
const readStableFileEntry = async (
|
|
request: StableFileRequest,
|
|
retain: (owner: OwnedFileHandle) => void,
|
|
): Promise<ReferenceSourceEntry> => {
|
|
const { root, rootIdentity, absolute, path, expected, signal } = request;
|
|
try {
|
|
const parentBefore = await validateDirectory(
|
|
root,
|
|
rootIdentity,
|
|
dirname(absolute),
|
|
signal,
|
|
);
|
|
if (!parentBefore.ok)
|
|
return entryFailure(
|
|
path,
|
|
"file",
|
|
parentBefore.code,
|
|
parentBefore.message,
|
|
safeSize(expected.size),
|
|
);
|
|
const handle = await openRegularFile(absolute, {
|
|
symlinks: "reject",
|
|
signal,
|
|
});
|
|
retain(new OwnedFileHandle(handle));
|
|
const prepared = await prepareFileRead(request, handle);
|
|
if (prepared.status === "failed") return prepared.entry;
|
|
const contents = await readFileContents({
|
|
handle,
|
|
path,
|
|
expectedSize: prepared.before.size,
|
|
...(signal === undefined ? {} : { signal }),
|
|
});
|
|
if (contents.status === "failed") return contents.entry;
|
|
return await finalizeFileRead({
|
|
root,
|
|
rootIdentity,
|
|
absolute,
|
|
path,
|
|
handle,
|
|
before: prepared.before,
|
|
parentBefore,
|
|
chunks: contents.chunks,
|
|
total: contents.total,
|
|
...(signal === undefined ? {} : { signal }),
|
|
});
|
|
} catch (cause: unknown) {
|
|
if (cause instanceof RegularFileAdmissionFailure) {
|
|
retain(cause.owner);
|
|
cause = cause.cause;
|
|
}
|
|
if (isAborted(signal))
|
|
return entryFailure(
|
|
path,
|
|
"file",
|
|
"cancelled",
|
|
"File read cancelled",
|
|
safeSize(expected.size),
|
|
);
|
|
if (cause instanceof NonRegularFileReadError)
|
|
return entryFailure(
|
|
path,
|
|
"file",
|
|
"changed",
|
|
"File is no longer a regular file",
|
|
safeSize(expected.size),
|
|
);
|
|
const message = filesystemFailureDetail(
|
|
cause,
|
|
"File could not be read safely",
|
|
);
|
|
if (message === undefined) throw cause;
|
|
return entryFailure(path, "file", "io", message, safeSize(expected.size));
|
|
}
|
|
};
|