78 lines
4.9 KiB
JavaScript
78 lines
4.9 KiB
JavaScript
import crypto from "node:crypto";
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
export const SERVER_JOB_RESULT_SCHEMA = 1;
|
|
const DIGEST = /^[a-f0-9]{64}$/;
|
|
|
|
function invalid(message) { throw new Error(`SERVER_JOB_RESULT_INVALID: ${message}`); }
|
|
function digest(value, field) {
|
|
if (typeof value !== "string" || !DIGEST.test(value)) invalid(`${field} hash is invalid`);
|
|
return value;
|
|
}
|
|
function revision(value, field) {
|
|
if (!Number.isSafeInteger(value) || value < 0) invalid(`${field} is invalid`);
|
|
return value;
|
|
}
|
|
function hashBytes(bytes) { return crypto.createHash("sha256").update(bytes).digest("hex"); }
|
|
async function hashFile(file) { return hashBytes(await fs.readFile(file)); }
|
|
|
|
export function createServerJobResultIdentity(value) {
|
|
if (!value || typeof value !== "object") invalid("identity is invalid");
|
|
const requestId = typeof value.requestId === "string" && /^[A-Za-z0-9:_-]{1,128}$/.test(value.requestId) ? value.requestId : invalid("requestId is invalid");
|
|
const projectId = typeof value.projectId === "string" && /^[A-Za-z0-9:_-]{1,128}$/.test(value.projectId) ? value.projectId : invalid("projectId is invalid");
|
|
return Object.freeze({
|
|
schemaVersion: SERVER_JOB_RESULT_SCHEMA,
|
|
requestId,
|
|
projectId,
|
|
baseRevision: revision(value.baseRevision, "baseRevision"),
|
|
sourceSha256: digest(value.sourceSha256, "source"),
|
|
settingsSha256: digest(value.settingsSha256, "settings"),
|
|
buildSha256: digest(value.buildSha256, "build"),
|
|
});
|
|
}
|
|
|
|
export async function commitServerJobResult(identityValue, outputBytes, options = {}) {
|
|
const identity = createServerJobResultIdentity(identityValue);
|
|
if (options.expectedIdentity !== undefined) {
|
|
const expected = createServerJobResultIdentity(options.expectedIdentity);
|
|
for (const field of ["requestId", "projectId", "baseRevision", "sourceSha256", "settingsSha256", "buildSha256"]) {
|
|
if (identity[field] !== expected[field]) throw new Error(`SERVER_JOB_RESULT_IDENTITY_MISMATCH: ${field} does not match the request`);
|
|
}
|
|
}
|
|
if (!(outputBytes instanceof Uint8Array) || outputBytes.byteLength < 1) invalid("output bytes are empty");
|
|
const outputSha256 = hashBytes(outputBytes);
|
|
const outputByteLength = outputBytes.byteLength;
|
|
const outputDirectory = options.outputDirectory;
|
|
if (typeof outputDirectory !== "string" || !path.isAbsolute(outputDirectory)) invalid("output directory is invalid");
|
|
await fs.mkdir(outputDirectory, { recursive: true, mode: 0o700 });
|
|
const fileName = `${identity.requestId}.result`;
|
|
const target = path.join(outputDirectory, fileName);
|
|
const temporary = path.join(outputDirectory, `.${fileName}.${crypto.randomUUID()}.tmp`);
|
|
try {
|
|
await fs.writeFile(temporary, outputBytes, { flag: "wx", mode: 0o600 });
|
|
const staged = await fs.readFile(temporary);
|
|
if (staged.byteLength !== outputByteLength || hashBytes(staged) !== outputSha256) invalid("staged output readback mismatch");
|
|
if (options.faultAt === "AFTER_STAGE") throw new Error("SERVER_JOB_RESULT_STORAGE: injected failure after stage");
|
|
if (options.faultAt === "QUOTA") throw new Error("SERVER_JOB_RESULT_STORAGE_QUOTA: output quota exceeded");
|
|
await fs.rename(temporary, target);
|
|
const persisted = await fs.readFile(target);
|
|
if (persisted.byteLength !== outputByteLength || hashBytes(persisted) !== outputSha256) invalid("committed output readback mismatch");
|
|
return Object.freeze({ schemaVersion: SERVER_JOB_RESULT_SCHEMA, status: "COMMITTED", requestId: identity.requestId, projectId: identity.projectId, baseRevision: identity.baseRevision, sourceSha256: identity.sourceSha256, settingsSha256: identity.settingsSha256, buildSha256: identity.buildSha256, outputSha256, outputByteLength, outputPath: target, publish: true, execution: "DISABLED" });
|
|
} catch (error) {
|
|
await fs.rm(temporary, { force: true });
|
|
if (error instanceof Error && (error.message.startsWith("SERVER_JOB_RESULT_") || error.message.startsWith("SERVER_JOB_RESULT_STORAGE"))) throw error;
|
|
throw new Error(`SERVER_JOB_RESULT_STORAGE: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
|
|
export async function verifyServerJobResultReceipt(receipt, expected, outputDirectory) {
|
|
if (!receipt || receipt.schemaVersion !== SERVER_JOB_RESULT_SCHEMA || receipt.status !== "COMMITTED" || receipt.publish !== true) invalid("receipt is not committed");
|
|
const identity = createServerJobResultIdentity(expected);
|
|
for (const field of ["requestId", "projectId", "baseRevision", "sourceSha256", "settingsSha256", "buildSha256"]) if (receipt[field] !== identity[field]) invalid(`${field} identity mismatch`);
|
|
const target = path.join(outputDirectory, `${identity.requestId}.result`);
|
|
const bytes = await fs.readFile(target);
|
|
if (bytes.byteLength !== receipt.outputByteLength || hashBytes(bytes) !== receipt.outputSha256) throw new Error("SERVER_JOB_RESULT_HASH_MISMATCH: committed output changed");
|
|
return Object.freeze({ ...receipt, verified: true });
|
|
}
|