222 lines
6.5 KiB
TypeScript
222 lines
6.5 KiB
TypeScript
import {
|
|
link,
|
|
lstat,
|
|
mkdtemp,
|
|
open,
|
|
realpath,
|
|
rename,
|
|
rm,
|
|
type FileHandle,
|
|
} from "node:fs/promises";
|
|
import { basename, dirname, resolve } from "node:path";
|
|
|
|
import { EvidenceFileError } from "../domain/evidenceErrors.js";
|
|
import {
|
|
AnalysisCancelledError,
|
|
AnalysisResourceConstraintError,
|
|
} from "../domain/analysisErrorCore.js";
|
|
import { err, ok, type Result } from "../domain/result.js";
|
|
import { readJsonInputFile } from "./JsonInputFile.js";
|
|
import { NonRegularFileReadError } from "../filesystem/RegularFile.js";
|
|
|
|
/** Request control and its owning operation for an interruptible atomic write. */
|
|
export interface TextWriteCancellation {
|
|
readonly signal: AbortSignal;
|
|
readonly operation: string;
|
|
}
|
|
|
|
type TextWriteResult<Failure = EvidenceFileError | AnalysisCancelledError> =
|
|
Result<{ readonly path: string; readonly bytes: number }, Failure>;
|
|
|
|
/** Read JSON data from a regular file at the caller-supplied path. */
|
|
export const readJsonFile = async (
|
|
path: string,
|
|
): Promise<
|
|
Result<unknown, EvidenceFileError | AnalysisResourceConstraintError>
|
|
> => {
|
|
const requestedPath = resolve(path);
|
|
try {
|
|
const decoded = await readJsonInputFile(
|
|
requestedPath,
|
|
"read_evidence_file",
|
|
);
|
|
if (!decoded.ok) {
|
|
if (decoded.error instanceof AnalysisResourceConstraintError)
|
|
return err(decoded.error);
|
|
return err(
|
|
new EvidenceFileError("read", "invalid-json", {
|
|
cause:
|
|
decoded.error.cause ??
|
|
new SyntaxError(
|
|
decoded.error.issues[0]?.message ?? decoded.error.message,
|
|
),
|
|
path: requestedPath,
|
|
}),
|
|
);
|
|
}
|
|
return ok(decoded.value);
|
|
} catch (cause: unknown) {
|
|
if (cause instanceof AnalysisResourceConstraintError) return err(cause);
|
|
return err(
|
|
new EvidenceFileError(
|
|
"read",
|
|
cause instanceof NonRegularFileReadError
|
|
? "not-file"
|
|
: missingOrIo(cause),
|
|
{
|
|
cause,
|
|
path: requestedPath,
|
|
},
|
|
),
|
|
);
|
|
}
|
|
};
|
|
|
|
/** Atomically write text to the caller-supplied path. */
|
|
export const writeTextFile = async (
|
|
encoded: string,
|
|
path: string,
|
|
overwrite: boolean,
|
|
): Promise<TextWriteResult<EvidenceFileError>> =>
|
|
writeTextParts([encoded], path, overwrite);
|
|
|
|
/** Publish streamed text atomically unless cancellation wins before publication. */
|
|
export function writeTextParts(
|
|
parts: Iterable<string>,
|
|
path: string,
|
|
overwrite: boolean,
|
|
): Promise<TextWriteResult<EvidenceFileError>>;
|
|
/** Pass request control to stop streamed generation before atomic publication. */
|
|
export function writeTextParts(
|
|
parts: Iterable<string>,
|
|
path: string,
|
|
overwrite: boolean,
|
|
cancellation: TextWriteCancellation | undefined,
|
|
): Promise<TextWriteResult>;
|
|
export async function writeTextParts(
|
|
parts: Iterable<string>,
|
|
path: string,
|
|
overwrite: boolean,
|
|
cancellation?: TextWriteCancellation,
|
|
): Promise<TextWriteResult> {
|
|
const requestedPath = resolve(path);
|
|
try {
|
|
assertWriteActive(cancellation);
|
|
const canonicalParent = await realpath(dirname(requestedPath));
|
|
const destination = resolve(canonicalParent, basename(requestedPath));
|
|
const existing = await lstat(destination).catch((cause: unknown) => {
|
|
if (fileErrorCode(cause) === "ENOENT") return undefined;
|
|
throw cause;
|
|
});
|
|
if (existing !== undefined) {
|
|
if (!overwrite)
|
|
return err(
|
|
new EvidenceFileError("write", "exists", { path: requestedPath }),
|
|
);
|
|
if (!existing.isFile() || existing.isSymbolicLink())
|
|
return err(
|
|
new EvidenceFileError("write", "not-file", { path: requestedPath }),
|
|
);
|
|
}
|
|
assertWriteActive(cancellation);
|
|
const bytes = await publishFile(
|
|
destination,
|
|
parts,
|
|
overwrite,
|
|
cancellation,
|
|
);
|
|
return ok({ path: requestedPath, bytes });
|
|
} catch (cause: unknown) {
|
|
if (
|
|
cancellation?.signal.aborted === true &&
|
|
cause instanceof AnalysisCancelledError &&
|
|
cause.operation === cancellation.operation
|
|
)
|
|
return err(cause);
|
|
if (!overwrite && fileErrorCode(cause) === "EEXIST")
|
|
return err(
|
|
new EvidenceFileError("write", "exists", {
|
|
cause,
|
|
path: requestedPath,
|
|
}),
|
|
);
|
|
return err(
|
|
new EvidenceFileError("write", missingOrIo(cause), {
|
|
cause,
|
|
path: requestedPath,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
/** A missing path or parent is a selection error, not a permission failure. */
|
|
const missingOrIo = (cause: unknown): "missing" | "io" => {
|
|
const code = fileErrorCode(cause);
|
|
return code === "ENOENT" || code === "ENOTDIR" ? "missing" : "io";
|
|
};
|
|
|
|
const publishFile = async (
|
|
destination: string,
|
|
parts: Iterable<string>,
|
|
overwrite: boolean,
|
|
cancellation?: TextWriteCancellation,
|
|
): Promise<number> => {
|
|
const stagingDirectory = await mkdtemp(
|
|
resolve(dirname(destination), ".rea-write-"),
|
|
);
|
|
try {
|
|
const staged = resolve(stagingDirectory, "content");
|
|
const file = await open(staged, "wx", 0o600);
|
|
let bytes = 0;
|
|
try {
|
|
bytes = await writeStagedParts(file, parts, cancellation);
|
|
assertWriteActive(cancellation);
|
|
await file.sync();
|
|
} finally {
|
|
await file.close();
|
|
}
|
|
// Once submitted, the atomic publication can win a later cancellation race.
|
|
assertWriteActive(cancellation);
|
|
if (overwrite) await rename(staged, destination);
|
|
// A hard link publishes complete bytes atomically and cannot replace an existing name.
|
|
else await link(staged, destination);
|
|
return bytes;
|
|
} finally {
|
|
await rm(stagingDirectory, { recursive: true, force: true });
|
|
}
|
|
};
|
|
|
|
const writeStagedParts = async (
|
|
file: FileHandle,
|
|
parts: Iterable<string>,
|
|
cancellation?: TextWriteCancellation,
|
|
): Promise<number> => {
|
|
assertWriteActive(cancellation);
|
|
const iterator = parts[Symbol.iterator]();
|
|
let exhausted = false;
|
|
let bytes = 0;
|
|
try {
|
|
for (;;) {
|
|
assertWriteActive(cancellation);
|
|
const part = iterator.next();
|
|
if (part.done) exhausted = true;
|
|
assertWriteActive(cancellation);
|
|
if (part.done) return bytes;
|
|
await file.writeFile(part.value, { encoding: "utf8" });
|
|
bytes += Buffer.byteLength(part.value, "utf8");
|
|
}
|
|
} finally {
|
|
if (!exhausted) iterator.return?.();
|
|
}
|
|
};
|
|
|
|
const assertWriteActive = (cancellation?: TextWriteCancellation): void => {
|
|
if (cancellation?.signal.aborted === true)
|
|
throw new AnalysisCancelledError(cancellation.operation);
|
|
};
|
|
|
|
const fileErrorCode = (cause: unknown): unknown =>
|
|
typeof cause === "object" && cause !== null && "code" in cause
|
|
? cause.code
|
|
: undefined;
|