1import { execFileSync } from "node:child_process";
2import { createHash } from "node:crypto";
3import { readdir, readFile } from "node:fs/promises";
4import { resolve } from "node:path";
5import { fileURLToPath } from "node:url";
6
7const vendorDir = new URL("./", import.meta.url);
8const pinnedCommit = (await readFile(new URL("COMMIT", vendorDir), "utf8")).trim();
9const wasm = await readFile(new URL("ghostty-vt.wasm", vendorDir));
10const expectedHash = (await readFile(new URL("SHA256", vendorDir), "utf8")).trim();
11const actualHash = createHash("sha256").update(wasm).digest("hex");
12if (actualHash !== expectedHash) {
13 throw new Error(`ghostty-vt.wasm SHA-256 mismatch: expected ${expectedHash}, got ${actualHash}`);
14}
15
16const instance = await WebAssembly.instantiate(wasm, { env: { log() {} } });
17const exports = instance.instance.exports;
18if (!(exports.memory instanceof WebAssembly.Memory)) throw new Error("wasm memory export missing");
19if (!(exports.__indirect_function_table instanceof WebAssembly.Table)) {
20 throw new Error("wasm function table export missing");
21}
22
23const runtimeSources = await Promise.all(
24 ["module.ts", "terminal.ts"].map((file) =>
25 readFile(new URL(`../src/ghostty/${file}`, vendorDir), "utf8"),
26 ),
27);
28const requiredExports = new Set(["ghostty_type_json"]);
29const requiredLayouts = new Map();
30const 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};
38for (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}
52for (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}
72for (const name of requiredExports) {
73 if (typeof exports[name] !== "function") throw new Error(`required wasm export missing: ${name}`);
74}
75const jsonPtr = exports.ghostty_type_json();
76const bytes = new Uint8Array(exports.memory.buffer, jsonPtr);
77const end = bytes.indexOf(0);
78if (end < 0) throw new Error("type metadata is not null-terminated");
79const layout = JSON.parse(new TextDecoder().decode(bytes.subarray(0, end)));
80for (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}
105const ascii = layout.GhosttyRenderStateAsciiCell;
106if (
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
119try {
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
132const sourceArg = process.argv[2];
133if (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
199console.log(`ghostty vendor verified: commit ${pinnedCommit}, sha256 ${actualHash}`);