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 }); }