145 lines
4.3 KiB
TypeScript
145 lines
4.3 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { constants } from "node:fs";
|
|
import {
|
|
open as openFile,
|
|
rename as renameFile,
|
|
rm as removeFile,
|
|
} from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
import type { z } from "zod";
|
|
|
|
export type ValidatedJsonArtifactInput = Readonly<{
|
|
path: string;
|
|
schema: z.ZodType;
|
|
value: unknown;
|
|
}>;
|
|
|
|
export function serializeValidatedJsonArtifact(
|
|
input: ValidatedJsonArtifactInput,
|
|
): Buffer {
|
|
const parsed = input.schema.parse(input.value);
|
|
const serialized = JSON.stringify(parsed, null, 2);
|
|
if (serialized === undefined) {
|
|
throw new TypeError("Validated JSON artifact is not serializable");
|
|
}
|
|
return Buffer.from(`${serialized}\n`, "utf8");
|
|
}
|
|
|
|
export type ValidatedJsonArtifactFileSystem = Readonly<{
|
|
open: (path: string, flags: number, mode: number) => Promise<{
|
|
writeFile(data: string, encoding: "utf8"): Promise<unknown>;
|
|
sync(): Promise<unknown>;
|
|
close(): Promise<unknown>;
|
|
}>;
|
|
openDirectory: (path: string) => Promise<{
|
|
sync(): Promise<unknown>;
|
|
close(): Promise<unknown>;
|
|
}>;
|
|
rename: (source: string, destination: string) => Promise<unknown>;
|
|
rm: (path: string, options: Readonly<{ force: true }>) => Promise<unknown>;
|
|
}>;
|
|
|
|
type ValidatedJsonArtifactWriterDependencies = Readonly<{
|
|
createNonce?: () => string;
|
|
fileSystem?: ValidatedJsonArtifactFileSystem;
|
|
}>;
|
|
|
|
const defaultFileSystem: ValidatedJsonArtifactFileSystem = Object.freeze({
|
|
open: async (target, flags, mode) => openFile(target, flags, mode),
|
|
openDirectory: async (target) => openFile(target, constants.O_RDONLY),
|
|
rename: async (source, destination) => renameFile(source, destination),
|
|
rm: async (target, options) => removeFile(target, options),
|
|
});
|
|
|
|
function hasErrorCode(error: unknown, code: string): boolean {
|
|
return (
|
|
typeof error === "object" &&
|
|
error !== null &&
|
|
"code" in error &&
|
|
error.code === code
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Builds a writer whose only publish operation is an atomic sibling rename.
|
|
* Dependency injection is limited to the file-system boundary so failure
|
|
* ownership can be verified without exposing a caller-selected cleanup path.
|
|
*/
|
|
export function createValidatedJsonArtifactWriter(
|
|
dependencies: ValidatedJsonArtifactWriterDependencies = {},
|
|
) {
|
|
const createNonce = dependencies.createNonce ?? randomUUID;
|
|
const fileSystem = dependencies.fileSystem ?? defaultFileSystem;
|
|
|
|
return async function writeArtifact(
|
|
input: ValidatedJsonArtifactInput,
|
|
): Promise<void> {
|
|
const serialized = serializeValidatedJsonArtifact(input);
|
|
|
|
const temporaryPath = path.join(
|
|
path.dirname(input.path),
|
|
`.${path.basename(input.path)}.${createNonce()}.tmp`,
|
|
);
|
|
let ownsTemporaryFile = false;
|
|
try {
|
|
const handle = await fileSystem.open(
|
|
temporaryPath,
|
|
constants.O_WRONLY |
|
|
constants.O_CREAT |
|
|
constants.O_EXCL |
|
|
constants.O_NOFOLLOW,
|
|
0o600,
|
|
);
|
|
ownsTemporaryFile = true;
|
|
let writeFailed = false;
|
|
let writeFailure: unknown;
|
|
try {
|
|
await handle.writeFile(serialized.toString("utf8"), "utf8");
|
|
await handle.sync();
|
|
} catch (error) {
|
|
writeFailed = true;
|
|
writeFailure = error;
|
|
}
|
|
let closeFailed = false;
|
|
let closeFailure: unknown;
|
|
try {
|
|
await handle.close();
|
|
} catch (error) {
|
|
closeFailed = true;
|
|
closeFailure = error;
|
|
}
|
|
if (writeFailed) throw writeFailure;
|
|
if (closeFailed) throw closeFailure;
|
|
await fileSystem.rename(temporaryPath, input.path);
|
|
ownsTemporaryFile = false;
|
|
const directoryHandle = await fileSystem.openDirectory(
|
|
path.dirname(input.path),
|
|
);
|
|
try {
|
|
try {
|
|
await directoryHandle.sync();
|
|
} catch (error) {
|
|
if (!hasErrorCode(error, "EINVAL") && !hasErrorCode(error, "ENOTSUP")) {
|
|
throw error;
|
|
}
|
|
}
|
|
} finally {
|
|
await directoryHandle.close();
|
|
}
|
|
} catch (error) {
|
|
if (ownsTemporaryFile) {
|
|
try {
|
|
await fileSystem.rm(temporaryPath, { force: true });
|
|
} catch {
|
|
// Preserve the publishing failure; cleanup is confined to our nonce.
|
|
}
|
|
}
|
|
throw error;
|
|
}
|
|
};
|
|
}
|
|
|
|
export const writeValidatedJsonArtifact =
|
|
createValidatedJsonArtifactWriter();
|