| 1 | import { execFileSync } from "node:child_process"; |
| 2 | import { createHash } from "node:crypto"; |
| 3 | import { readdir, readFile } from "node:fs/promises"; |
| 4 | import { resolve } from "node:path"; |
| 5 | import { fileURLToPath } from "node:url"; |
| 6 | |
| 7 | const vendorDir = new URL("./", import.meta.url); |
| 8 | const pinnedCommit = (await readFile(new URL("COMMIT", vendorDir), "utf8")).trim(); |
| 9 | const wasm = await readFile(new URL("ghostty-vt.wasm", vendorDir)); |
| 10 | const expectedHash = (await readFile(new URL("SHA256", vendorDir), "utf8")).trim(); |
| 11 | const actualHash = createHash("sha256").update(wasm).digest("hex"); |
| 12 | if (actualHash !== expectedHash) { |
| 13 | throw new Error(`ghostty-vt.wasm SHA-256 mismatch: expected ${expectedHash}, got ${actualHash}`); |
| 14 | } |
| 15 | |
| 16 | const instance = await WebAssembly.instantiate(wasm, { env: { log() {} } }); |
| 17 | const exports = instance.instance.exports; |
| 18 | if (!(exports.memory instanceof WebAssembly.Memory)) throw new Error("wasm memory export missing"); |
| 19 | if (!(exports.__indirect_function_table instanceof WebAssembly.Table)) { |
| 20 | throw new Error("wasm function table export missing"); |
| 21 | } |
| 22 | |
| 23 | const runtimeSources = await Promise.all( |
| 24 | ["module.ts", "terminal.ts"].map((file) => |
| 25 | readFile(new URL(`../src/ghostty/${file}`, vendorDir), "utf8"), |
| 26 | ), |
| 27 | ); |
| 28 | const requiredExports = new Set(["ghostty_type_json"]); |
| 29 | const requiredLayouts = new Map(); |
| 30 | const requireLayout = (name, field) => { |
| 31 | let fields = requiredLayouts.get(name); |
| 32 | if (fields === undefined) { |
| 33 | fields = new Set(); |
| 34 | requiredLayouts.set(name, fields); |
| 35 | } |
| 36 | if (field !== undefined) fields.add(field); |
| 37 | }; |
| 38 | for (const source of runtimeSources) { |
| 39 | for (const match of source.matchAll(/\.(?:fn|create)\("([^"]+)"\)/g)) { |
| 40 | requiredExports.add(match[1]); |
| 41 | } |
| 42 | for (const match of source.matchAll(/\.allocStruct\("([^"]+)"\)/g)) { |
| 43 | requireLayout(match[1]); |
| 44 | } |
| 45 | for (const match of source.matchAll(/\.layout\["([^"]+)"\]/g)) { |
| 46 | requireLayout(match[1]); |
| 47 | } |
| 48 | for (const match of source.matchAll(/\.field\("([^"]+)",\s*"([^"]+)"\)/g)) { |
| 49 | requireLayout(match[1], match[2]); |
| 50 | } |
| 51 | } |
| 52 | for (const [name, fields] of Object.entries({ |
| 53 | GhosttyClipboardWrite: ["location", "contents", "contents_len"], |
| 54 | GhosttyFormatterScreenExtra: ["size"], |
| 55 | GhosttyFormatterTerminalExtra: ["size", "screen"], |
| 56 | GhosttyMouseEncoderSize: [ |
| 57 | "screen_width", |
| 58 | "screen_height", |
| 59 | "cell_width", |
| 60 | "cell_height", |
| 61 | "padding_top", |
| 62 | "padding_bottom", |
| 63 | "padding_left", |
| 64 | "padding_right", |
| 65 | ], |
| 66 | GhosttyPoint: ["tag", "value"], |
| 67 | GhosttyTerminalOptions: ["cols", "rows", "max_scrollback"], |
| 68 | GhosttyTerminalScrollbar: ["total", "offset", "len"], |
| 69 | })) { |
| 70 | for (const field of fields) requireLayout(name, field); |
| 71 | } |
| 72 | for (const name of requiredExports) { |
| 73 | if (typeof exports[name] !== "function") throw new Error(`required wasm export missing: ${name}`); |
| 74 | } |
| 75 | const jsonPtr = exports.ghostty_type_json(); |
| 76 | const bytes = new Uint8Array(exports.memory.buffer, jsonPtr); |
| 77 | const end = bytes.indexOf(0); |
| 78 | if (end < 0) throw new Error("type metadata is not null-terminated"); |
| 79 | const layout = JSON.parse(new TextDecoder().decode(bytes.subarray(0, end))); |
| 80 | for (const [name, requiredFields] of requiredLayouts) { |
| 81 | const info = layout[name]; |
| 82 | if ( |
| 83 | !info || |
| 84 | !Number.isInteger(info.size) || |
| 85 | info.size <= 0 || |
| 86 | !Number.isInteger(info.align) || |
| 87 | info.align <= 0 |
| 88 | ) { |
| 89 | throw new Error(`required type layout missing or invalid: ${name}`); |
| 90 | } |
| 91 | for (const field of requiredFields) { |
| 92 | const value = info.fields?.[field]; |
| 93 | if ( |
| 94 | !value || |
| 95 | !Number.isInteger(value.offset) || |
| 96 | value.offset < 0 || |
| 97 | !Number.isInteger(value.size) || |
| 98 | value.size <= 0 || |
| 99 | value.offset + value.size > info.size |
| 100 | ) { |
| 101 | throw new Error(`required field layout missing or invalid: ${name}.${field}`); |
| 102 | } |
| 103 | } |
| 104 | } |
| 105 | const ascii = layout.GhosttyRenderStateAsciiCell; |
| 106 | if ( |
| 107 | ascii?.size !== 8 || |
| 108 | ascii.align !== 4 || |
| 109 | ascii.fields?.codepoint?.type !== "u32" || |
| 110 | ascii.fields.codepoint.offset !== 0 || |
| 111 | ascii.fields.codepoint.size !== 4 || |
| 112 | ascii.fields?.foreground?.type !== "u32" || |
| 113 | ascii.fields.foreground.offset !== 4 || |
| 114 | ascii.fields.foreground.size !== 4 |
| 115 | ) { |
| 116 | throw new Error("invalid GhosttyRenderStateAsciiCell layout"); |
| 117 | } |
| 118 | |
| 119 | try { |
| 120 | execFileSync( |
| 121 | process.execPath, |
| 122 | [fileURLToPath(new URL("gen-constants.mjs", vendorDir)), "--check"], |
| 123 | { stdio: "pipe" }, |
| 124 | ); |
| 125 | } catch (error) { |
| 126 | const detail = error instanceof Error && "stderr" in error ? String(error.stderr).trim() : ""; |
| 127 | throw new Error(`generated constants verification failed${detail === "" ? "" : `: ${detail}`}`, { |
| 128 | cause: error, |
| 129 | }); |
| 130 | } |
| 131 | |
| 132 | const sourceArg = process.argv[2]; |
| 133 | if (sourceArg !== undefined) { |
| 134 | const source = resolve(sourceArg); |
| 135 | let sourceCommit; |
| 136 | try { |
| 137 | sourceCommit = execFileSync("git", ["-C", source, "rev-parse", "--verify", "HEAD^{commit}"], { |
| 138 | encoding: "utf8", |
| 139 | stdio: ["ignore", "pipe", "pipe"], |
| 140 | }).trim(); |
| 141 | } catch (error) { |
| 142 | const detail = error instanceof Error && "stderr" in error ? String(error.stderr).trim() : ""; |
| 143 | throw new Error(`unable to read source checkout commit${detail === "" ? "" : `: ${detail}`}`, { |
| 144 | cause: error, |
| 145 | }); |
| 146 | } |
| 147 | if (sourceCommit !== pinnedCommit) { |
| 148 | throw new Error( |
| 149 | `source checkout commit mismatch: expected ${pinnedCommit}, got ${sourceCommit}`, |
| 150 | ); |
| 151 | } |
| 152 | const patches = (await readdir(new URL("patches/", vendorDir))) |
| 153 | .filter((name) => name.endsWith(".patch")) |
| 154 | .sort(); |
| 155 | for (const patch of patches) { |
| 156 | try { |
| 157 | execFileSync( |
| 158 | "git", |
| 159 | [ |
| 160 | "-C", |
| 161 | source, |
| 162 | "apply", |
| 163 | "--reverse", |
| 164 | "--check", |
| 165 | fileURLToPath(new URL(`patches/${patch}`, vendorDir)), |
| 166 | ], |
| 167 | { stdio: ["ignore", "pipe", "pipe"] }, |
| 168 | ); |
| 169 | } catch (error) { |
| 170 | const detail = error instanceof Error && "stderr" in error ? String(error.stderr).trim() : ""; |
| 171 | throw new Error( |
| 172 | `source checkout does not contain exact patch ${patch}${detail === "" ? "" : `: ${detail}`}`, |
| 173 | { cause: error }, |
| 174 | ); |
| 175 | } |
| 176 | } |
| 177 | const pageList = await readFile(resolve(source, "src/terminal/PageList.zig"), "utf8"); |
| 178 | if (!/WasmAllocator reuses freed slots[\s\S]{0,500}@memset\(page_buf, 0\)/.test(pageList)) { |
| 179 | throw new Error("upstream wasm page-zeroing guard missing"); |
| 180 | } |
| 181 | const types = await readFile(resolve(source, "src/terminal/c/types.zig"), "utf8"); |
| 182 | if (!types.includes('"GhosttyRenderStateAsciiCell", StructInfo.init(render.AsciiCell)')) { |
| 183 | throw new Error("patched ASCII cell type metadata missing"); |
| 184 | } |
| 185 | const stream = await readFile(resolve(source, "src/terminal/stream.zig"), "utf8"); |
| 186 | if ( |
| 187 | !stream.includes("if (comptime debug) {") || |
| 188 | stream.includes("if (comptime debug or !build_options.simd) {") |
| 189 | ) { |
| 190 | throw new Error("patched wasm batched VT source missing"); |
| 191 | } |
| 192 | const sourceRenderHeader = await readFile(resolve(source, "include/ghostty/vt/render.h")); |
| 193 | const vendoredRenderHeader = await readFile(new URL("include/ghostty/vt/render.h", vendorDir)); |
| 194 | if (!sourceRenderHeader.equals(vendoredRenderHeader)) { |
| 195 | throw new Error("patched source render header does not match vendored render header"); |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | console.log(`ghostty vendor verified: commit ${pinnedCommit}, sha256 ${actualHash}`); |