|
| 1 | +/** |
| 2 | + * Deterministic application bundle manifest validation (spec |
| 3 | + * `044-application-bundle-manifest`) and artifact digest verification. |
| 4 | + * Rejection never falls back to a sidecar (spec 068 NFR-001). |
| 5 | + */ |
| 6 | +import { SUPPORTED_BUNDLE_SCHEMA_VERSIONS, embedderError } from "./types.js"; |
| 7 | +/** Thrown when a bundle is rejected at the embedder boundary. */ |
| 8 | +export class BundleRejectedError extends Error { |
| 9 | + embedderError; |
| 10 | + constructor(error) { |
| 11 | + super(`${error.code}: ${error.message}`); |
| 12 | + this.name = "BundleRejectedError"; |
| 13 | + this.embedderError = error; |
| 14 | + } |
| 15 | +} |
| 16 | +export function asRecord(value) { |
| 17 | + if (typeof value === "object" && value !== null && !Array.isArray(value)) { |
| 18 | + return value; |
| 19 | + } |
| 20 | + return null; |
| 21 | +} |
| 22 | +export function requiredString(record, key, context) { |
| 23 | + const value = record[key]; |
| 24 | + if (typeof value !== "string" || value.trim() === "") { |
| 25 | + throw new BundleRejectedError(embedderError("bundle_load_failed", `${context} requires a non-empty string '${key}'`)); |
| 26 | + } |
| 27 | + return value; |
| 28 | +} |
| 29 | +export function optionalString(record, key) { |
| 30 | + const value = record[key]; |
| 31 | + return typeof value === "string" ? value : null; |
| 32 | +} |
| 33 | +export const SHA256_DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/; |
| 34 | +/** |
| 35 | + * Parses and deterministically validates an application bundle manifest |
| 36 | + * (spec `044-application-bundle-manifest`) for embedder compatibility: |
| 37 | + * schema version support, component identity, and sha-256 digest metadata. |
| 38 | + * Rejection never falls back to a sidecar (spec 068 NFR-001). |
| 39 | + * |
| 40 | + * @throws {BundleRejectedError} with a stable `EmbedderErrorCode`. |
| 41 | + */ |
| 42 | +export function validateBundleCompatibility(appManifest) { |
| 43 | + let parsed; |
| 44 | + if (typeof appManifest === "string") { |
| 45 | + try { |
| 46 | + parsed = JSON.parse(appManifest); |
| 47 | + } |
| 48 | + catch (error) { |
| 49 | + throw new BundleRejectedError(embedderError("bundle_load_failed", `application bundle manifest is not valid JSON: ${String(error)}`)); |
| 50 | + } |
| 51 | + } |
| 52 | + else { |
| 53 | + parsed = appManifest; |
| 54 | + } |
| 55 | + const manifest = asRecord(parsed); |
| 56 | + if (manifest === null) { |
| 57 | + throw new BundleRejectedError(embedderError("bundle_load_failed", "application bundle manifest must be a JSON object")); |
| 58 | + } |
| 59 | + const appId = requiredString(manifest, "app_id", "application bundle manifest"); |
| 60 | + const appVersion = requiredString(manifest, "version", "application bundle manifest"); |
| 61 | + const schemaVersion = requiredString(manifest, "schema_version", "application bundle manifest"); |
| 62 | + if (!SUPPORTED_BUNDLE_SCHEMA_VERSIONS.includes(schemaVersion)) { |
| 63 | + throw new BundleRejectedError(embedderError("unsupported_bundle_schema", `bundle declares schema_version '${schemaVersion}' but this package supports ` + |
| 64 | + `[${SUPPORTED_BUNDLE_SCHEMA_VERSIONS.join(", ")}]; no sidecar fallback is attempted`)); |
| 65 | + } |
| 66 | + const componentsValue = manifest["components"]; |
| 67 | + if (!Array.isArray(componentsValue)) { |
| 68 | + throw new BundleRejectedError(embedderError("bundle_load_failed", "application bundle manifest requires a 'components' array")); |
| 69 | + } |
| 70 | + const components = componentsValue.map((entry, index) => { |
| 71 | + const component = asRecord(entry); |
| 72 | + if (component === null) { |
| 73 | + throw new BundleRejectedError(embedderError("bundle_load_failed", `components[${index}] must be a JSON object`)); |
| 74 | + } |
| 75 | + const context = `components[${index}]`; |
| 76 | + const digest = requiredString(component, "digest", context); |
| 77 | + if (!SHA256_DIGEST_PATTERN.test(digest)) { |
| 78 | + throw new BundleRejectedError(embedderError("bundle_load_failed", `${context} declares invalid digest metadata '${digest}'; ` + |
| 79 | + "expected sha256:<64 hex characters>")); |
| 80 | + } |
| 81 | + return { |
| 82 | + componentId: requiredString(component, "component_id", context), |
| 83 | + version: requiredString(component, "version", context), |
| 84 | + digest, |
| 85 | + manifestPath: requiredString(component, "manifest_path", context), |
| 86 | + }; |
| 87 | + }); |
| 88 | + const workflowsValue = manifest["workflows"]; |
| 89 | + const workflowIds = []; |
| 90 | + const workflows = []; |
| 91 | + if (Array.isArray(workflowsValue)) { |
| 92 | + for (const [index, entry] of workflowsValue.entries()) { |
| 93 | + const workflow = asRecord(entry); |
| 94 | + if (workflow === null) { |
| 95 | + throw new BundleRejectedError(embedderError("bundle_load_failed", `workflows[${index}] must be a JSON object`)); |
| 96 | + } |
| 97 | + const context = `workflows[${index}]`; |
| 98 | + const workflowId = requiredString(workflow, "workflow_id", context); |
| 99 | + workflowIds.push(workflowId); |
| 100 | + workflows.push({ |
| 101 | + workflowId, |
| 102 | + workflowVersion: requiredString(workflow, "workflow_version", context), |
| 103 | + path: requiredString(workflow, "path", context), |
| 104 | + }); |
| 105 | + } |
| 106 | + } |
| 107 | + return { appId, appVersion, schemaVersion, components, workflowIds, workflows }; |
| 108 | +} |
| 109 | +/** |
| 110 | + * Verifies bundled artifact bytes against declared sha-256 digest metadata |
| 111 | + * using WebCrypto (browser) or the Node.js webcrypto implementation. |
| 112 | + * |
| 113 | + * @throws {BundleRejectedError} with `bundle_load_failed` on mismatch. |
| 114 | + */ |
| 115 | +export async function verifyArtifactDigest(bytes, declaredDigest, artifactLabel) { |
| 116 | + if (!SHA256_DIGEST_PATTERN.test(declaredDigest)) { |
| 117 | + throw new BundleRejectedError(embedderError("bundle_load_failed", `${artifactLabel} declares invalid digest metadata '${declaredDigest}'`)); |
| 118 | + } |
| 119 | + const digestBytes = await crypto.subtle.digest("SHA-256", bytes.slice().buffer); |
| 120 | + const actual = [...new Uint8Array(digestBytes)] |
| 121 | + .map((byte) => byte.toString(16).padStart(2, "0")) |
| 122 | + .join(""); |
| 123 | + const expected = declaredDigest.slice("sha256:".length); |
| 124 | + if (actual !== expected) { |
| 125 | + throw new BundleRejectedError(embedderError("bundle_load_failed", `${artifactLabel} digest mismatch: manifest declares sha256:${expected} ` + |
| 126 | + `but the bundled artifact hashes to sha256:${actual}; ` + |
| 127 | + "no sidecar fallback is attempted")); |
| 128 | + } |
| 129 | +} |
0 commit comments