refactor: validate generated evidence artifacts
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
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 type ValidatedJsonArtifactFileSystem = Readonly<{
|
||||
open: (path: string, flags: "wx") => Promise<{
|
||||
writeFile(data: string, encoding: "utf8"): 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) => openFile(target, flags),
|
||||
rename: async (source, destination) => renameFile(source, destination),
|
||||
rm: async (target, options) => removeFile(target, options),
|
||||
});
|
||||
|
||||
/**
|
||||
* 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 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");
|
||||
}
|
||||
|
||||
const temporaryPath = path.join(
|
||||
path.dirname(input.path),
|
||||
`.${path.basename(input.path)}.${createNonce()}.tmp`,
|
||||
);
|
||||
let ownsTemporaryFile = false;
|
||||
try {
|
||||
const handle = await fileSystem.open(temporaryPath, "wx");
|
||||
ownsTemporaryFile = true;
|
||||
try {
|
||||
await handle.writeFile(`${serialized}\n`, "utf8");
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
await fileSystem.rename(temporaryPath, input.path);
|
||||
} 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();
|
||||
Reference in New Issue
Block a user