|
| 1 | +import { createHash } from "node:crypto"; |
| 2 | + |
| 3 | +export const WORKER_EXECUTION_SCHEMA_VERSION = 1; |
| 4 | +export const WORKER_EXECUTION_INTERFACE_PROOF_VERSION = 1; |
| 5 | + |
| 6 | +export const WORKER_EXECUTION_LIMITS = Object.freeze({ |
| 7 | + workSpecificationBytes: 128 * 1024, |
| 8 | + contextExpansionRequestBytes: 128 * 1024, |
| 9 | + workerReportBytes: 256 * 1024, |
| 10 | + workerObservationBytes: 128 * 1024, |
| 11 | + executionRecordBytes: 1024 * 1024, |
| 12 | + textBytes: 8 * 1024, |
| 13 | + pathBytes: 1024, |
| 14 | + identifierBytes: 128, |
| 15 | + planRefs: 64, |
| 16 | + claimRefs: 256, |
| 17 | + paths: 512, |
| 18 | + expansionRequests: 64, |
| 19 | + artifactRefs: 256, |
| 20 | + reportItems: 256, |
| 21 | + relatedRefs: 64, |
| 22 | + events: 256 |
| 23 | +}); |
| 24 | + |
| 25 | +const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u; |
| 26 | +const IDENTIFIER_PATTERN = /^[a-zA-Z0-9](?:[a-zA-Z0-9._:-]*[a-zA-Z0-9])?$/u; |
| 27 | +function assertSchemaAndKind(value, kind, location) { |
| 28 | + if (value.schemaVersion !== WORKER_EXECUTION_SCHEMA_VERSION || value.kind !== kind) { |
| 29 | + throw protocolError( |
| 30 | + "WORKER_EXECUTION_SCHEMA_INVALID", |
| 31 | + `${location} must use schemaVersion 1 and kind ${kind}.` |
| 32 | + ); |
| 33 | + } |
| 34 | +} |
| 35 | + |
| 36 | +function normalizeIdentifier(value, location) { |
| 37 | + const text = normalizeText(value, location, { |
| 38 | + minimumBytes: 1, |
| 39 | + maximumBytes: WORKER_EXECUTION_LIMITS.identifierBytes |
| 40 | + }); |
| 41 | + if (!IDENTIFIER_PATTERN.test(text)) { |
| 42 | + throw protocolError("WORKER_EXECUTION_IDENTIFIER_INVALID", `${location} is not canonical.`); |
| 43 | + } |
| 44 | + return text; |
| 45 | +} |
| 46 | + |
| 47 | +function normalizeDigest(value, location) { |
| 48 | + if (typeof value !== "string" || !DIGEST_PATTERN.test(value)) { |
| 49 | + throw protocolError("WORKER_EXECUTION_DIGEST_INVALID", `${location} must be a sha256 digest.`); |
| 50 | + } |
| 51 | + return value; |
| 52 | +} |
| 53 | + |
| 54 | +function normalizePath(value, location) { |
| 55 | + const path = normalizeText(value, location, { |
| 56 | + minimumBytes: 1, |
| 57 | + maximumBytes: WORKER_EXECUTION_LIMITS.pathBytes |
| 58 | + }); |
| 59 | + if (path.startsWith("/") |
| 60 | + || path.includes("\\") |
| 61 | + || path.includes("\0") |
| 62 | + || path.split("/").some((segment) => !segment || segment === "." || segment === "..")) { |
| 63 | + throw protocolError("WORKER_EXECUTION_PATH_INVALID", `${location} is not repository-relative.`); |
| 64 | + } |
| 65 | + return path; |
| 66 | +} |
| 67 | + |
| 68 | +function normalizeText(value, location, { |
| 69 | + minimumBytes = 0, |
| 70 | + maximumBytes = WORKER_EXECUTION_LIMITS.textBytes |
| 71 | +} = {}) { |
| 72 | + if (typeof value !== "string" || value !== value.trim() || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(value)) { |
| 73 | + throw protocolError("WORKER_EXECUTION_TEXT_INVALID", `${location} must be canonical text.`); |
| 74 | + } |
| 75 | + const bytes = Buffer.byteLength(value, "utf8"); |
| 76 | + if (bytes < minimumBytes || bytes > maximumBytes) { |
| 77 | + throw protocolError( |
| 78 | + "WORKER_EXECUTION_LIMIT_EXCEEDED", |
| 79 | + `${location} must contain between ${minimumBytes} and ${maximumBytes} UTF-8 bytes.` |
| 80 | + ); |
| 81 | + } |
| 82 | + return value; |
| 83 | +} |
| 84 | + |
| 85 | +function normalizeInteger(value, location, { minimum, maximum }) { |
| 86 | + if (!Number.isSafeInteger(value) || value < minimum || value > maximum) { |
| 87 | + throw protocolError( |
| 88 | + "WORKER_EXECUTION_NUMBER_INVALID", |
| 89 | + `${location} must be an integer between ${minimum} and ${maximum}.` |
| 90 | + ); |
| 91 | + } |
| 92 | + return value; |
| 93 | +} |
| 94 | + |
| 95 | +function normalizeStringSet(value, location, { |
| 96 | + minimum = 0, |
| 97 | + maximum, |
| 98 | + item |
| 99 | +}) { |
| 100 | + const entries = normalizeDenseArray(value, location, { minimum, maximum }) |
| 101 | + .map((entry, index) => item(entry, `${location}[${index}]`)) |
| 102 | + .sort(compareUtf8); |
| 103 | + assertUnique(entries, location); |
| 104 | + return entries; |
| 105 | +} |
| 106 | + |
| 107 | +function normalizeDenseArray(value, location, { minimum = 0, maximum }) { |
| 108 | + if (!Array.isArray(value)) { |
| 109 | + throw protocolError("WORKER_EXECUTION_INPUT_INVALID", `${location} must be an array.`); |
| 110 | + } |
| 111 | + const ownKeys = Reflect.ownKeys(value); |
| 112 | + if (ownKeys.some((key) => typeof key === "symbol") |
| 113 | + || ownKeys.some((key) => key !== "length" && !/^\d+$/u.test(key)) |
| 114 | + || value.length < minimum |
| 115 | + || value.length > maximum |
| 116 | + || !Array.from({ length: value.length }, (_, index) => Object.hasOwn(value, index)).every(Boolean)) { |
| 117 | + throw protocolError("WORKER_EXECUTION_INPUT_INVALID", `${location} must be dense and bounded.`); |
| 118 | + } |
| 119 | + return value; |
| 120 | +} |
| 121 | + |
| 122 | +function assertPlainObject(value, location) { |
| 123 | + if (!value || typeof value !== "object" || Array.isArray(value)) { |
| 124 | + throw protocolError("WORKER_EXECUTION_INPUT_INVALID", `${location} must be an object.`); |
| 125 | + } |
| 126 | + let prototype; |
| 127 | + let descriptors; |
| 128 | + try { |
| 129 | + prototype = Object.getPrototypeOf(value); |
| 130 | + descriptors = Object.getOwnPropertyDescriptors(value); |
| 131 | + } catch { |
| 132 | + throw protocolError("WORKER_EXECUTION_INPUT_INVALID", `${location} cannot be inspected safely.`); |
| 133 | + } |
| 134 | + if (prototype !== Object.prototype && prototype !== null) { |
| 135 | + throw protocolError("WORKER_EXECUTION_INPUT_INVALID", `${location} must be a plain object.`); |
| 136 | + } |
| 137 | + for (const [key, descriptor] of Object.entries(descriptors)) { |
| 138 | + if (!("value" in descriptor) || descriptor.enumerable !== true) { |
| 139 | + throw protocolError( |
| 140 | + "WORKER_EXECUTION_INPUT_INVALID", |
| 141 | + `${location}.${key} must be an enumerable data property.` |
| 142 | + ); |
| 143 | + } |
| 144 | + } |
| 145 | + if (Reflect.ownKeys(value).some((key) => typeof key === "symbol")) { |
| 146 | + throw protocolError("WORKER_EXECUTION_INPUT_INVALID", `${location} cannot contain symbol keys.`); |
| 147 | + } |
| 148 | +} |
| 149 | + |
| 150 | +function assertExactKeys(value, expected, location) { |
| 151 | + const actual = Object.keys(value).sort(compareUtf8); |
| 152 | + const canonicalExpected = [...expected].sort(compareUtf8); |
| 153 | + if (canonicalStringify(actual) !== canonicalStringify(canonicalExpected)) { |
| 154 | + const expectedSet = new Set(canonicalExpected); |
| 155 | + const actualSet = new Set(actual); |
| 156 | + throw protocolError( |
| 157 | + "WORKER_EXECUTION_FIELDS_INVALID", |
| 158 | + `${location} has missing or unknown fields.`, |
| 159 | + { |
| 160 | + missing: canonicalExpected.filter((key) => !actualSet.has(key)), |
| 161 | + unknown: actual.filter((key) => !expectedSet.has(key)) |
| 162 | + } |
| 163 | + ); |
| 164 | + } |
| 165 | +} |
| 166 | + |
| 167 | +function assertUnique(values, location) { |
| 168 | + const seen = new Set(); |
| 169 | + for (const value of values) { |
| 170 | + if (seen.has(value)) { |
| 171 | + throw protocolError("WORKER_EXECUTION_DUPLICATE", `${location} contains duplicate values.`); |
| 172 | + } |
| 173 | + seen.add(value); |
| 174 | + } |
| 175 | +} |
| 176 | + |
| 177 | +function withoutKeys(value, keys) { |
| 178 | + assertPlainObject(value, "object"); |
| 179 | + const omitted = new Set(keys); |
| 180 | + return Object.fromEntries(Object.entries(value).filter(([candidate]) => !omitted.has(candidate))); |
| 181 | +} |
| 182 | + |
| 183 | +function sealDocument(content, digestField) { |
| 184 | + return { ...content, [digestField]: canonicalDigest(content) }; |
| 185 | +} |
| 186 | + |
| 187 | +function assertDocumentSeal(value, content, digestField) { |
| 188 | + const expected = sealDocument(content, digestField); |
| 189 | + if (value[digestField] !== expected[digestField] |
| 190 | + || canonicalStringify(value) !== canonicalStringify(expected)) { |
| 191 | + throw protocolError( |
| 192 | + "WORKER_EXECUTION_DIGEST_INVALID", |
| 193 | + `${digestField} does not bind the canonical document content.` |
| 194 | + ); |
| 195 | + } |
| 196 | + return expected; |
| 197 | +} |
| 198 | + |
| 199 | +function canonicalDigest(value) { |
| 200 | + return `sha256:${createHash("sha256").update(canonicalStringify(value), "utf8").digest("hex")}`; |
| 201 | +} |
| 202 | + |
| 203 | +function canonicalStringify(value) { |
| 204 | + if (value === null || typeof value === "boolean" || typeof value === "string") { |
| 205 | + return JSON.stringify(value); |
| 206 | + } |
| 207 | + if (typeof value === "number") { |
| 208 | + if (!Number.isSafeInteger(value)) { |
| 209 | + throw protocolError("WORKER_EXECUTION_NUMBER_INVALID", "Canonical values require safe integers."); |
| 210 | + } |
| 211 | + return String(value); |
| 212 | + } |
| 213 | + if (Array.isArray(value)) return `[${value.map(canonicalStringify).join(",")}]`; |
| 214 | + assertPlainObject(value, "canonicalValue"); |
| 215 | + return `{${Object.keys(value).sort(compareUtf8).map((key) => ( |
| 216 | + `${JSON.stringify(key)}:${canonicalStringify(value[key])}` |
| 217 | + )).join(",")}}`; |
| 218 | +} |
| 219 | + |
| 220 | +function assertDocumentBytes(value, maximum, label) { |
| 221 | + const observed = Buffer.byteLength(canonicalStringify(value), "utf8"); |
| 222 | + if (observed > maximum) { |
| 223 | + throw protocolError( |
| 224 | + "WORKER_EXECUTION_LIMIT_EXCEEDED", |
| 225 | + `${label} exceeds its ${maximum}-byte canonical limit.`, |
| 226 | + { maximum, observed } |
| 227 | + ); |
| 228 | + } |
| 229 | +} |
| 230 | + |
| 231 | +function compareUtf8(left, right) { |
| 232 | + return Buffer.compare(Buffer.from(String(left), "utf8"), Buffer.from(String(right), "utf8")); |
| 233 | +} |
| 234 | + |
| 235 | +function deepFreeze(value) { |
| 236 | + if (!value || typeof value !== "object" || Object.isFrozen(value)) return value; |
| 237 | + for (const item of Object.values(value)) deepFreeze(item); |
| 238 | + return Object.freeze(value); |
| 239 | +} |
| 240 | + |
| 241 | +function protocolError(code, message, details = undefined) { |
| 242 | + const error = new Error(message); |
| 243 | + error.code = code; |
| 244 | + if (details !== undefined) error.details = details; |
| 245 | + return error; |
| 246 | +} |
| 247 | + |
| 248 | +export { |
| 249 | + assertDocumentBytes, |
| 250 | + assertDocumentSeal, |
| 251 | + assertExactKeys, |
| 252 | + assertPlainObject, |
| 253 | + assertSchemaAndKind, |
| 254 | + assertUnique, |
| 255 | + canonicalDigest, |
| 256 | + canonicalStringify, |
| 257 | + compareUtf8, |
| 258 | + deepFreeze, |
| 259 | + normalizeDenseArray, |
| 260 | + normalizeDigest, |
| 261 | + normalizeIdentifier, |
| 262 | + normalizeInteger, |
| 263 | + normalizePath, |
| 264 | + normalizeStringSet, |
| 265 | + normalizeText, |
| 266 | + protocolError, |
| 267 | + sealDocument, |
| 268 | + withoutKeys |
| 269 | +}; |
0 commit comments