@adhdev/ghostty-vt-node 1.0.78 → 1.0.82-rc.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CMakeLists.txt +284 -0
- package/README.md +151 -9
- package/binding-candidates.js +40 -0
- package/index.d.ts +107 -0
- package/index.js +92 -48
- package/package.json +6 -2
- package/prebuilt/darwin-arm64-node137/ghostty_vt_node.node +0 -0
- package/prebuilt/darwin-arm64-node137/libghostty-vt.0.1.0.dylib +0 -0
- package/prebuilt/darwin-arm64-node137/libghostty-vt.dylib +0 -0
- package/prebuilt/darwin-arm64-node137/manifest.json +34 -0
- package/prebuilt/linux-x64-node137/ghostty_vt_node.node +0 -0
- package/prebuilt/linux-x64-node137/libghostty-vt.so +0 -0
- package/prebuilt/linux-x64-node137/libghostty-vt.so.0 +0 -0
- package/prebuilt/linux-x64-node137/libghostty-vt.so.0.1.0 +0 -0
- package/prebuilt/linux-x64-node137/manifest.json +38 -0
- package/prebuilt/win32-x64-node137/ghostty-vt.dll +0 -0
- package/prebuilt/win32-x64-node137/ghostty_vt_node.node +0 -0
- package/prebuilt/win32-x64-node137/manifest.json +30 -0
- package/scripts/binding-digest.mjs +60 -0
- package/scripts/build-or-use-prebuilt.mjs +60 -31
- package/scripts/check-prebuilts.mjs +113 -0
- package/scripts/clean-dir.cjs +32 -0
- package/scripts/host-path-scan.mjs +115 -0
- package/scripts/native-format.mjs +219 -0
- package/scripts/prebuilt-manifest.mjs +231 -0
- package/scripts/probe-build-provenance.mjs +288 -0
- package/scripts/verify-prebuilt.mjs +115 -0
- package/src/addon.cc +403 -23
- package/src/ghostty_bridge.c +344 -0
- package/src/ghostty_bridge.h +96 -0
- package/toolchain.json +3 -0
- package/prebuilt/darwin-arm64-node115/ghostty_vt_node.node +0 -0
- package/prebuilt/darwin-arm64-node115/libghostty-vt.0.1.0.dylib +0 -0
- package/prebuilt/darwin-arm64-node115/libghostty-vt.dylib +0 -0
- package/prebuilt/darwin-arm64-node127/ghostty_vt_node.node +0 -0
- package/prebuilt/darwin-arm64-node127/libghostty-vt.0.1.0.dylib +0 -0
- package/prebuilt/darwin-arm64-node127/libghostty-vt.dylib +0 -0
- package/prebuilt/darwin-arm64-node141/ghostty_vt_node.node +0 -0
- package/prebuilt/darwin-arm64-node141/libghostty-vt.0.1.0.dylib +0 -0
- package/prebuilt/darwin-arm64-node141/libghostty-vt.dylib +0 -0
- package/prebuilt/linux-x64-node115/ghostty_vt_node.node +0 -0
- package/prebuilt/linux-x64-node115/libghostty-vt.so +0 -0
- package/prebuilt/linux-x64-node115/libghostty-vt.so.0 +0 -0
- package/prebuilt/linux-x64-node115/libghostty-vt.so.0.1.0 +0 -0
- package/prebuilt/linux-x64-node127/ghostty_vt_node.node +0 -0
- package/prebuilt/linux-x64-node127/libghostty-vt.so +0 -0
- package/prebuilt/linux-x64-node127/libghostty-vt.so.0 +0 -0
- package/prebuilt/linux-x64-node127/libghostty-vt.so.0.1.0 +0 -0
- package/prebuilt/linux-x64-node141/ghostty_vt_node.node +0 -0
- package/prebuilt/linux-x64-node141/libghostty-vt.so +0 -0
- package/prebuilt/linux-x64-node141/libghostty-vt.so.0 +0 -0
- package/prebuilt/linux-x64-node141/libghostty-vt.so.0.1.0 +0 -0
- package/prebuilt/win32-x64-node127/ghostty-vt.dll +0 -0
- package/prebuilt/win32-x64-node127/ghostty_vt_node.node +0 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Per-platform prebuilt manifests: prebuilt/<triplet>/manifest.json records the
|
|
3
|
+
// binding source digest, API level and build provenance the binary reports
|
|
4
|
+
// about ITSELF (read from the compiled addon, never from the current sources)
|
|
5
|
+
// plus the sha256 of every runtime file in the directory. `check` compares each
|
|
6
|
+
// shipped triplet against the sources and toolchain pin in this checkout, so
|
|
7
|
+
// editing the bridge/addon (or the zig pin) makes every prebuilt "pending
|
|
8
|
+
// rebuild" until it is rebuilt on its own platform. It also validates, without
|
|
9
|
+
// loading anything, that the directory holds its platform's runtime set in the
|
|
10
|
+
// right native format (scripts/native-format.mjs), so release CI on Linux can
|
|
11
|
+
// reject e.g. darwin files placed in the win32 directory.
|
|
12
|
+
//
|
|
13
|
+
// node scripts/prebuilt-manifest.mjs write <dir> # stamp a directory (loads its addon)
|
|
14
|
+
// node scripts/prebuilt-manifest.mjs emit [<from>] # copy build/Release into the shipped
|
|
15
|
+
// # triplet for this platform-arch + stamp
|
|
16
|
+
// node scripts/prebuilt-manifest.mjs check [--all-present]
|
|
17
|
+
import { createHash } from 'node:crypto';
|
|
18
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
import { join, resolve } from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
|
|
23
|
+
import { PACKAGE_DIR, computeBindingDigest, pinnedGhosttyRevision, pinnedZigVersion } from './binding-digest.mjs';
|
|
24
|
+
import { checkRuntimeSet, parseTriplet } from './native-format.mjs';
|
|
25
|
+
|
|
26
|
+
export const PREBUILT_ROOT = join(PACKAGE_DIR, 'prebuilt');
|
|
27
|
+
export const ADDON_FILE = 'ghostty_vt_node.node';
|
|
28
|
+
export const MANIFEST_FILE = 'manifest.json';
|
|
29
|
+
// 2: adds `release` and `provenance` (resolved ghostty source, toolchain).
|
|
30
|
+
export const MANIFEST_SCHEMA = 2;
|
|
31
|
+
// The addon plus its co-located runtime libs. Matches build-or-use-prebuilt.mjs.
|
|
32
|
+
export const RUNTIME_FILE_PATTERN = /\.(node|dylib|so|so\.\d.*|dll)$/;
|
|
33
|
+
// The committed prebuilt set (see .gitignore). One N-API binary per
|
|
34
|
+
// platform-arch serves every Node ABI; the node137 name is only an address.
|
|
35
|
+
export const SHIPPED_TRIPLETS = ['darwin-arm64-node137', 'linux-x64-node137', 'win32-x64-node137'];
|
|
36
|
+
|
|
37
|
+
const requireNative = createRequire(import.meta.url);
|
|
38
|
+
|
|
39
|
+
export function expectedApiLevel(packageDir = PACKAGE_DIR) {
|
|
40
|
+
const header = readFileSync(join(packageDir, 'src', 'ghostty_bridge.h'), 'utf8');
|
|
41
|
+
const match = header.match(/#define ADHDEV_GHOSTTY_API_LEVEL (\d+)/);
|
|
42
|
+
if (!match) throw new Error('src/ghostty_bridge.h does not define ADHDEV_GHOSTTY_API_LEVEL');
|
|
43
|
+
return Number(match[1]);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function shippedTripletFor(platform = process.platform, arch = process.arch) {
|
|
47
|
+
return SHIPPED_TRIPLETS.find((name) => name.startsWith(`${platform}-${arch}-`)) ?? null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const sha256File = (file) => createHash('sha256').update(readFileSync(file)).digest('hex');
|
|
51
|
+
|
|
52
|
+
function runtimeFiles(dir) {
|
|
53
|
+
return readdirSync(dir).filter((name) => RUNTIME_FILE_PATTERN.test(name)).sort();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Loads the exact addon in `dir` (no package-level fallback) and returns what
|
|
57
|
+
// it reports about itself. Binaries predating getBindingInfo report level 1.
|
|
58
|
+
export function readBinaryInfo(dir) {
|
|
59
|
+
const addon = requireNative(resolve(dir, ADDON_FILE));
|
|
60
|
+
if (typeof addon.getBindingInfo !== 'function') {
|
|
61
|
+
return { apiLevel: 1, sourceDigest: null, ghosttyRevision: null, napiVersion: null };
|
|
62
|
+
}
|
|
63
|
+
return addon.getBindingInfo();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function writeManifest(dir) {
|
|
67
|
+
const info = readBinaryInfo(dir);
|
|
68
|
+
if (!info.sourceDigest) throw new Error(`${dir}: addon does not embed a binding source digest (api level ${info.apiLevel})`);
|
|
69
|
+
const files = {};
|
|
70
|
+
for (const name of runtimeFiles(dir)) {
|
|
71
|
+
const file = join(dir, name);
|
|
72
|
+
files[name] = { sha256: sha256File(file), size: statSync(file).size };
|
|
73
|
+
}
|
|
74
|
+
const manifest = {
|
|
75
|
+
schema: MANIFEST_SCHEMA,
|
|
76
|
+
platform: process.platform,
|
|
77
|
+
arch: process.arch,
|
|
78
|
+
apiLevel: info.apiLevel,
|
|
79
|
+
sourceDigest: info.sourceDigest,
|
|
80
|
+
ghosttyRevision: info.ghosttyRevision,
|
|
81
|
+
napiVersion: info.napiVersion,
|
|
82
|
+
builtWithNode: process.version,
|
|
83
|
+
release: info.releaseEligible === true,
|
|
84
|
+
provenance: {
|
|
85
|
+
problems: info.provenanceProblems ?? ['no-build-provenance'],
|
|
86
|
+
ghosttySourceRevision: info.ghosttySourceRevision ?? null,
|
|
87
|
+
ghosttySourceClean: info.ghosttySourceClean ?? false,
|
|
88
|
+
zigVersion: info.zigVersion ?? null,
|
|
89
|
+
cCompiler: info.cCompiler ?? null,
|
|
90
|
+
cxxCompiler: info.cxxCompiler ?? null,
|
|
91
|
+
buildType: info.buildType ?? null,
|
|
92
|
+
},
|
|
93
|
+
files,
|
|
94
|
+
};
|
|
95
|
+
writeFileSync(join(dir, MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
96
|
+
return manifest;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const LINUX_SONAME = 'libghostty-vt.so.0';
|
|
100
|
+
const LINUX_VERSIONED = /^libghostty-vt\.so\.0\.\d+\.\d+$/;
|
|
101
|
+
|
|
102
|
+
export function emitPrebuilt(fromDir = join(PACKAGE_DIR, 'build', 'Release')) {
|
|
103
|
+
const triplet = shippedTripletFor() ?? `${process.platform}-${process.arch}-node${process.versions.modules}`;
|
|
104
|
+
const target = join(PREBUILT_ROOT, triplet);
|
|
105
|
+
mkdirSync(target, { recursive: true });
|
|
106
|
+
// Replace the whole runtime set: a leftover library from the previous build
|
|
107
|
+
// (e.g. the Linux SONAME file, which the build output may not contain) would
|
|
108
|
+
// otherwise be shipped next to an addon built against a different one.
|
|
109
|
+
for (const name of runtimeFiles(target)) rmSync(join(target, name));
|
|
110
|
+
for (const name of runtimeFiles(fromDir)) copyFileSync(join(fromDir, name), join(target, name));
|
|
111
|
+
const versioned = readdirSync(target).find((name) => LINUX_VERSIONED.test(name));
|
|
112
|
+
if (versioned && !existsSync(join(target, LINUX_SONAME))) copyFileSync(join(target, versioned), join(target, LINUX_SONAME));
|
|
113
|
+
const manifest = writeManifest(target);
|
|
114
|
+
return { triplet, target, manifest };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Status of one prebuilt directory against the current sources:
|
|
118
|
+
// ok manifest matches the sources and every file hash matches
|
|
119
|
+
// pending-rebuild built from other sources/toolchain, or stamped with an older
|
|
120
|
+
// manifest schema (or never stamped): rebuild it on its platform
|
|
121
|
+
// wrong-platform the manifest or the files are for another platform/arch
|
|
122
|
+
// invalid-runtime the platform's mandatory runtime set is incomplete or a file
|
|
123
|
+
// is not a shared library of the right format
|
|
124
|
+
// corrupt files differ from what the manifest recorded
|
|
125
|
+
// non-release built with ADHDEV_GHOSTTY_UNVERIFIED_DEV_BUILD (unverified
|
|
126
|
+
// ghostty source or toolchain): never publishable
|
|
127
|
+
export function checkTriplet(triplet, expected, root = PREBUILT_ROOT) {
|
|
128
|
+
const dir = join(root, triplet);
|
|
129
|
+
if (!existsSync(join(dir, ADDON_FILE))) return { triplet, status: 'missing', detail: `no ${ADDON_FILE}` };
|
|
130
|
+
const manifestPath = join(dir, MANIFEST_FILE);
|
|
131
|
+
if (!existsSync(manifestPath)) return { triplet, status: 'pending-rebuild', detail: 'no manifest.json (binary predates the current binding sources)' };
|
|
132
|
+
let manifest;
|
|
133
|
+
try {
|
|
134
|
+
manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
135
|
+
} catch (error) {
|
|
136
|
+
return { triplet, status: 'corrupt', detail: `unreadable manifest.json: ${error.message}` };
|
|
137
|
+
}
|
|
138
|
+
const target = parseTriplet(triplet);
|
|
139
|
+
if (!target) return { triplet, status: 'wrong-platform', detail: 'directory name is not <platform>-<arch>-node<abi>' };
|
|
140
|
+
if (manifest.platform !== target.platform || manifest.arch !== target.arch) {
|
|
141
|
+
return { triplet, status: 'wrong-platform', detail: `manifest is for ${manifest.platform}-${manifest.arch}` };
|
|
142
|
+
}
|
|
143
|
+
const present = runtimeFiles(dir);
|
|
144
|
+
const runtimeProblems = checkRuntimeSet(dir, target.platform, target.arch, present);
|
|
145
|
+
if (runtimeProblems.length) return { triplet, status: 'invalid-runtime', detail: runtimeProblems.join('; ') };
|
|
146
|
+
|
|
147
|
+
const problems = [];
|
|
148
|
+
const recorded = Object.keys(manifest.files ?? {}).sort();
|
|
149
|
+
for (const name of recorded) {
|
|
150
|
+
if (!present.includes(name)) problems.push(`${name} missing`);
|
|
151
|
+
else if (sha256File(join(dir, name)) !== manifest.files[name].sha256) problems.push(`${name} hash differs from manifest`);
|
|
152
|
+
}
|
|
153
|
+
// An unrecorded file is accepted only as a byte-identical alias of a recorded
|
|
154
|
+
// one: release CI materializes the Linux SONAME copy after artifacts drop
|
|
155
|
+
// symlinks (ensure-prebuilt-sonames.mjs).
|
|
156
|
+
const recordedHashes = new Set(recorded.map((name) => manifest.files[name].sha256));
|
|
157
|
+
for (const name of present) {
|
|
158
|
+
if (!recorded.includes(name) && !recordedHashes.has(sha256File(join(dir, name)))) problems.push(`${name} not in manifest`);
|
|
159
|
+
}
|
|
160
|
+
if (!recorded.includes(ADDON_FILE)) problems.push(`manifest does not record ${ADDON_FILE}`);
|
|
161
|
+
// The addon embeds its digest as a string; a manifest claiming another one
|
|
162
|
+
// was not stamped from this binary.
|
|
163
|
+
else if (typeof manifest.sourceDigest === 'string' && manifest.sourceDigest
|
|
164
|
+
&& !readFileSync(join(dir, ADDON_FILE)).includes(Buffer.from(manifest.sourceDigest, 'latin1'))) {
|
|
165
|
+
problems.push(`${ADDON_FILE} does not embed the manifest sourceDigest`);
|
|
166
|
+
}
|
|
167
|
+
if (problems.length) return { triplet, status: 'corrupt', detail: problems.join('; ') };
|
|
168
|
+
|
|
169
|
+
if (manifest.schema !== MANIFEST_SCHEMA) {
|
|
170
|
+
return { triplet, status: 'pending-rebuild', detail: `manifest schema ${manifest.schema} predates build provenance (want ${MANIFEST_SCHEMA})` };
|
|
171
|
+
}
|
|
172
|
+
const stale = [];
|
|
173
|
+
if (manifest.sourceDigest !== expected.sourceDigest) stale.push(`sourceDigest ${String(manifest.sourceDigest).slice(0, 12)} != ${expected.sourceDigest.slice(0, 12)}`);
|
|
174
|
+
if (manifest.apiLevel !== expected.apiLevel) stale.push(`apiLevel ${manifest.apiLevel} != ${expected.apiLevel}`);
|
|
175
|
+
if (manifest.ghosttyRevision !== expected.ghosttyRevision) stale.push(`ghosttyRevision ${manifest.ghosttyRevision} != ${expected.ghosttyRevision}`);
|
|
176
|
+
const provenance = manifest.provenance ?? {};
|
|
177
|
+
if (provenance.zigVersion !== expected.zigVersion) stale.push(`zig ${provenance.zigVersion} != ${expected.zigVersion} (toolchain.json)`);
|
|
178
|
+
if (stale.length) return { triplet, status: 'pending-rebuild', detail: stale.join('; ') };
|
|
179
|
+
|
|
180
|
+
const unverified = [];
|
|
181
|
+
if (manifest.release !== true) unverified.push('stamped non-release');
|
|
182
|
+
if (Array.isArray(provenance.problems) && provenance.problems.length) unverified.push(`problems: ${provenance.problems.join(', ')}`);
|
|
183
|
+
if (provenance.ghosttySourceRevision !== expected.ghosttyRevision) unverified.push(`built ghostty ${provenance.ghosttySourceRevision} != pin`);
|
|
184
|
+
if (provenance.ghosttySourceClean !== true) unverified.push('ghostty source not clean');
|
|
185
|
+
if (unverified.length) return { triplet, status: 'non-release', detail: unverified.join('; ') };
|
|
186
|
+
return { triplet, status: 'ok', detail: `digest ${manifest.sourceDigest.slice(0, 12)}, api ${manifest.apiLevel}, zig ${provenance.zigVersion}, ${provenance.cxxCompiler}, node ${manifest.builtWithNode}` };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function currentExpectation(packageDir = PACKAGE_DIR) {
|
|
190
|
+
return {
|
|
191
|
+
sourceDigest: computeBindingDigest(packageDir),
|
|
192
|
+
apiLevel: expectedApiLevel(packageDir),
|
|
193
|
+
ghosttyRevision: pinnedGhosttyRevision(packageDir),
|
|
194
|
+
zigVersion: pinnedZigVersion(packageDir),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function checkManifests({ allPresent = false, root = PREBUILT_ROOT } = {}) {
|
|
199
|
+
const expected = currentExpectation();
|
|
200
|
+
const triplets = new Set(SHIPPED_TRIPLETS);
|
|
201
|
+
if (allPresent && existsSync(root)) {
|
|
202
|
+
for (const name of readdirSync(root)) {
|
|
203
|
+
if (existsSync(join(root, name, ADDON_FILE))) triplets.add(name);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return { expected, results: [...triplets].sort().map((triplet) => checkTriplet(triplet, expected, root)) };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function printManifestReport({ expected, results }, log = console.log) {
|
|
210
|
+
log(`[ghostty-vt] binding source digest ${expected.sourceDigest} (api ${expected.apiLevel}, ghostty ${expected.ghosttyRevision.slice(0, 12)}, zig ${expected.zigVersion})`);
|
|
211
|
+
for (const r of results) log(` ${r.status === 'ok' ? '✓' : '✗'} ${r.triplet.padEnd(22)} ${r.status.padEnd(16)} ${r.detail}`);
|
|
212
|
+
const bad = results.filter((r) => r.status !== 'ok');
|
|
213
|
+
log(`[ghostty-vt] ${results.length - bad.length}/${results.length} prebuilts match the binding sources`);
|
|
214
|
+
return bad.length === 0;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
|
|
218
|
+
const [command, arg] = process.argv.slice(2);
|
|
219
|
+
if (command === 'write' && arg) {
|
|
220
|
+
const manifest = writeManifest(resolve(arg));
|
|
221
|
+
console.log(`[ghostty-vt] wrote ${join(arg, MANIFEST_FILE)} (digest ${manifest.sourceDigest}, api ${manifest.apiLevel})`);
|
|
222
|
+
} else if (command === 'emit') {
|
|
223
|
+
const { target, manifest } = emitPrebuilt(arg ? resolve(arg) : undefined);
|
|
224
|
+
console.log(`[ghostty-vt] emitted prebuilt → ${target} (digest ${manifest.sourceDigest}, api ${manifest.apiLevel})`);
|
|
225
|
+
} else if (command === 'check') {
|
|
226
|
+
if (!printManifestReport(checkManifests({ allPresent: process.argv.includes('--all-present') }))) process.exit(1);
|
|
227
|
+
} else {
|
|
228
|
+
console.error('usage: prebuilt-manifest.mjs write <dir> | emit [<from>] | check [--all-present]');
|
|
229
|
+
process.exit(2);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Scripted probe for the build-provenance guarantees in CMakeLists.txt. Needs a
|
|
3
|
+
// configured cmake-js build of this package (e.g. a neutral /tmp/gvn copy after
|
|
4
|
+
// `cmake-js rebuild`) plus cmake, git, network access and the pinned zig; it is
|
|
5
|
+
// not part of the unit tests because it compiles twice from empty caches.
|
|
6
|
+
// Each probe mutates the build tree and restores it; the final state is a
|
|
7
|
+
// fresh, release-eligible build. Run it with exclusive use of the build tree.
|
|
8
|
+
//
|
|
9
|
+
// node scripts/probe-build-provenance.mjs <package copy dir> # uses <dir>/build
|
|
10
|
+
//
|
|
11
|
+
// Probes:
|
|
12
|
+
// stale-runtime-relink same-size garbage in the installed runtime + addon
|
|
13
|
+
// relink: the shipped runtime must be the rebuilt one
|
|
14
|
+
// stale-runtime-idle same, nothing to relink: the runtime is still refreshed
|
|
15
|
+
// ambient-cache-poison ZIG_GLOBAL_CACHE_DIR / ZIG_LOCAL_CACHE_DIR pointing at
|
|
16
|
+
// a poisoned package cache are never read or written
|
|
17
|
+
// source-modified a ghostty source edit after configure fails the build
|
|
18
|
+
// dev-override ADHDEV_GHOSTTY_UNVERIFIED_DEV_BUILD=ON alone => release=0
|
|
19
|
+
// poison-is-live control: the poisoned PRIVATE caches break a build
|
|
20
|
+
// that keeps them (dev override), so the poison bites
|
|
21
|
+
// zig-strict "0.15.2!" / "0.15 .2" / two lines refused, " X\r\n" accepted
|
|
22
|
+
// clean-fails-closed a cache clean that leaves anything behind (simulated
|
|
23
|
+
// with a no-op fs.rmSync) fails the release configure
|
|
24
|
+
// reconfigure-clears a release configure empties the private caches/prefix,
|
|
25
|
+
// including planted trailing-dot (`._.`) entries that
|
|
26
|
+
// CMake's file(REMOVE_RECURSE) cannot delete on Windows
|
|
27
|
+
// private-cache-poison ...so the poisoned package/artifact caches are never
|
|
28
|
+
// used: the release rebuild yields the baseline runtime
|
|
29
|
+
// source-override FETCHCONTENT_SOURCE_DIR_GHOSTTY => refused
|
|
30
|
+
import { spawnSync } from 'node:child_process';
|
|
31
|
+
import { createHash, randomFillSync } from 'node:crypto';
|
|
32
|
+
import { appendFileSync, chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
33
|
+
import { tmpdir } from 'node:os';
|
|
34
|
+
import { join, resolve } from 'node:path';
|
|
35
|
+
|
|
36
|
+
import { pinnedZigVersion } from './binding-digest.mjs';
|
|
37
|
+
import { checkNativeFile } from './native-format.mjs';
|
|
38
|
+
|
|
39
|
+
// Canonical path: the build embeds source paths (e.g. /tmp vs /private/tmp on
|
|
40
|
+
// macOS), so every configure must see the same spelling for runtimes to match.
|
|
41
|
+
const dir = process.argv[2] && existsSync(process.argv[2]) ? realpathSync(resolve(process.argv[2])) : null;
|
|
42
|
+
if (!dir || !existsSync(join(dir, 'build', 'CMakeCache.txt'))) {
|
|
43
|
+
console.error('usage: probe-build-provenance.mjs <dir containing a configured cmake-js build/>');
|
|
44
|
+
process.exit(2);
|
|
45
|
+
}
|
|
46
|
+
const build = join(dir, 'build');
|
|
47
|
+
const source = join(build, '_deps', 'ghostty-src');
|
|
48
|
+
const zigDir = join(build, 'ghostty-zig');
|
|
49
|
+
const globalCache = join(zigDir, 'global-cache');
|
|
50
|
+
const localCache = join(zigDir, 'local-cache');
|
|
51
|
+
const cache = readFileSync(join(build, 'CMakeCache.txt'), 'utf8');
|
|
52
|
+
const realZig = /^ZIG_EXECUTABLE:FILEPATH=(.*)$/m.exec(cache)?.[1];
|
|
53
|
+
const isWin = process.platform === 'win32';
|
|
54
|
+
const libName = process.platform === 'darwin' ? 'libghostty-vt.0.1.0.dylib' : isWin ? 'ghostty-vt.dll' : 'libghostty-vt.so.0.1.0';
|
|
55
|
+
const installedLib = join(zigDir, 'prefix', isWin ? 'bin' : 'lib', libName);
|
|
56
|
+
const releaseLib = join(build, 'Release', libName);
|
|
57
|
+
const sha = (file) => (existsSync(file) ? createHash('sha256').update(readFileSync(file)).digest('hex') : null);
|
|
58
|
+
const run = (cmd, args, env = {}) => {
|
|
59
|
+
const result = spawnSync(cmd, args, { cwd: dir, encoding: 'utf8', windowsHide: true, env: { ...process.env, ...env }, maxBuffer: 1 << 28 });
|
|
60
|
+
return { status: result.status, out: `${result.stdout ?? ''}${result.stderr ?? ''}` };
|
|
61
|
+
};
|
|
62
|
+
const configure = (defs = [], env = {}) => run('cmake', [build, ...defs.map((d) => `-D${d}`)], env);
|
|
63
|
+
const compile = (env = {}) => run('cmake', ['--build', build, '--config', 'Release'], env);
|
|
64
|
+
// Two fresh zig builds are not byte-identical: the debug map / debug info names
|
|
65
|
+
// the per-build local-cache object directories and their mtimes, and the image
|
|
66
|
+
// id (LC_UUID, build-id, PDB GUID) and code signature follow from that. The
|
|
67
|
+
// LOADED code and data must be identical, so runtimes from different fresh
|
|
68
|
+
// builds are compared by a digest of their loaded sections:
|
|
69
|
+
// Mach-O every section outside __LINKEDIT (zerofill excluded)
|
|
70
|
+
// ELF SHF_ALLOC sections except NOBITS and notes (build-id)
|
|
71
|
+
// PE every section, with debug-directory and export timestamps / debug
|
|
72
|
+
// payloads zeroed
|
|
73
|
+
const loadedDigest = (file) => {
|
|
74
|
+
if (!existsSync(file)) return null;
|
|
75
|
+
const b = Buffer.from(readFileSync(file));
|
|
76
|
+
const hash = createHash('sha256');
|
|
77
|
+
if (b.readUInt32LE(0) === 0xfeedfacf) {
|
|
78
|
+
let at = 32;
|
|
79
|
+
for (let i = 0; i < b.readUInt32LE(16); i++) {
|
|
80
|
+
const cmd = b.readUInt32LE(at);
|
|
81
|
+
if (cmd === 0x19 && b.toString('latin1', at + 8, at + 24).replace(/\0.*$/s, '') !== '__LINKEDIT') {
|
|
82
|
+
for (let k = 0; k < b.readUInt32LE(at + 64); k++) {
|
|
83
|
+
const sect = at + 72 + k * 80;
|
|
84
|
+
const type = b.readUInt32LE(sect + 64) & 0xff;
|
|
85
|
+
if (type === 0x01 || type === 0x0c || type === 0x12) continue; // zerofill kinds
|
|
86
|
+
const size = Number(b.readBigUInt64LE(sect + 40));
|
|
87
|
+
const off = b.readUInt32LE(sect + 48);
|
|
88
|
+
hash.update(b.subarray(sect, sect + 32)).update(b.subarray(off, off + size));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
at += b.readUInt32LE(at + 4);
|
|
92
|
+
}
|
|
93
|
+
} else if (b.readUInt32BE(0) === 0x7f454c46) {
|
|
94
|
+
const shoff = Number(b.readBigUInt64LE(40));
|
|
95
|
+
for (let i = 0; i < b.readUInt16LE(60); i++) {
|
|
96
|
+
const sh = shoff + i * 64;
|
|
97
|
+
const type = b.readUInt32LE(sh + 4);
|
|
98
|
+
const flags = Number(b.readBigUInt64LE(sh + 8));
|
|
99
|
+
if (!(flags & 2) || type === 8 || type === 7) continue;
|
|
100
|
+
const off = Number(b.readBigUInt64LE(sh + 24));
|
|
101
|
+
hash.update(b.subarray(sh, sh + 64)).update(b.subarray(off, off + Number(b.readBigUInt64LE(sh + 32))));
|
|
102
|
+
}
|
|
103
|
+
} else if (b[0] === 0x4d && b[1] === 0x5a) {
|
|
104
|
+
const pe = b.readUInt32LE(0x3c);
|
|
105
|
+
const opt = pe + 24;
|
|
106
|
+
const sectionTable = opt + b.readUInt16LE(pe + 20);
|
|
107
|
+
const sections = Array.from({ length: b.readUInt16LE(pe + 6) }, (_, i) => {
|
|
108
|
+
const sh = sectionTable + i * 40;
|
|
109
|
+
return { sh, va: b.readUInt32LE(sh + 12), vsize: b.readUInt32LE(sh + 8), raw: b.readUInt32LE(sh + 20), rawSize: b.readUInt32LE(sh + 16) };
|
|
110
|
+
});
|
|
111
|
+
const fileOffset = (rva) => { const s = sections.find((x) => rva >= x.va && rva < x.va + Math.max(x.vsize, x.rawSize)); return s ? s.raw + rva - s.va : -1; };
|
|
112
|
+
const exportDir = fileOffset(b.readUInt32LE(opt + 112));
|
|
113
|
+
if (b.readUInt32LE(opt + 116) && exportDir >= 0) b.fill(0, exportDir + 4, exportDir + 8);
|
|
114
|
+
const debugDir = fileOffset(b.readUInt32LE(opt + 112 + 6 * 8));
|
|
115
|
+
const debugSize = b.readUInt32LE(opt + 116 + 6 * 8);
|
|
116
|
+
for (let d = 0; debugDir >= 0 && d + 28 <= debugSize; d += 28) {
|
|
117
|
+
const entry = debugDir + d;
|
|
118
|
+
b.fill(0, entry + 4, entry + 8);
|
|
119
|
+
const dataSize = b.readUInt32LE(entry + 16);
|
|
120
|
+
const dataPtr = b.readUInt32LE(entry + 24);
|
|
121
|
+
if (dataPtr && dataPtr + dataSize <= b.length) b.fill(0, dataPtr, dataPtr + dataSize);
|
|
122
|
+
}
|
|
123
|
+
for (const s of sections) hash.update(b.subarray(s.sh, s.sh + 40)).update(b.subarray(s.raw, s.raw + s.rawSize));
|
|
124
|
+
} else {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
return hash.digest('hex');
|
|
128
|
+
};
|
|
129
|
+
const platformArch = [process.platform, process.arch];
|
|
130
|
+
const releaseFlag = (out) => /provenance: .* release=(\d)/.exec(out)?.[1] ?? null;
|
|
131
|
+
const walk = (root) => (existsSync(root) ? readdirSync(root, { recursive: true, withFileTypes: true }) : [])
|
|
132
|
+
.filter((entry) => entry.isFile()).map((entry) => join(entry.parentPath ?? entry.path, entry.name));
|
|
133
|
+
// path:size:mtime of every file: shows a tree was neither modified nor added to.
|
|
134
|
+
const snapshot = (root) => JSON.stringify(walk(root).sort().map((file) => { const st = statSync(file); return `${file}:${st.size}:${st.mtimeMs}`; }));
|
|
135
|
+
|
|
136
|
+
const results = [];
|
|
137
|
+
const probe = (name, ok, detail) => { results.push(ok); console.log(` ${ok ? '✓' : '✗'} ${name}: ${detail}`); };
|
|
138
|
+
const plantGarbage = () => {
|
|
139
|
+
const garbage = Buffer.alloc(statSync(installedLib).size);
|
|
140
|
+
randomFillSync(garbage);
|
|
141
|
+
writeFileSync(installedLib, garbage);
|
|
142
|
+
return sha(installedLib);
|
|
143
|
+
};
|
|
144
|
+
// Make every Zig source in a package cache fail to compile and every cached
|
|
145
|
+
// libghostty-vt artifact garbage, so any use of the poisoned state is visible.
|
|
146
|
+
const POISON = '\ncomptime { @compileError("adhdev probe: poisoned zig package cache"); }\n';
|
|
147
|
+
const poisonCaches = (globalRoot, localRoot) => {
|
|
148
|
+
let sources = 0;
|
|
149
|
+
let artifacts = 0;
|
|
150
|
+
for (const file of walk(join(globalRoot, 'p'))) {
|
|
151
|
+
if (file.endsWith('.zig')) { appendFileSync(file, POISON); sources++; }
|
|
152
|
+
}
|
|
153
|
+
for (const file of walk(localRoot)) {
|
|
154
|
+
if (/ghostty-vt/.test(file) && /\.(dylib|so[.\d]*|dll|lib|a)$/.test(file)) {
|
|
155
|
+
const garbage = Buffer.alloc(statSync(file).size);
|
|
156
|
+
randomFillSync(garbage);
|
|
157
|
+
writeFileSync(file, garbage);
|
|
158
|
+
artifacts++;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return { sources, artifacts };
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
// Baseline: a fresh release configure + build (empty private caches).
|
|
165
|
+
const baseline = configure(['ADHDEV_GHOSTTY_UNVERIFIED_DEV_BUILD=OFF', `ZIG_EXECUTABLE=${realZig}`]);
|
|
166
|
+
let built = baseline.status === 0 ? compile() : baseline;
|
|
167
|
+
if (built.status !== 0 || releaseFlag(baseline.out) !== '1') { console.error(`baseline release build failed:\n${built.out.slice(-2000)}`); process.exit(1); }
|
|
168
|
+
const good = sha(installedLib);
|
|
169
|
+
const goodLoaded = loadedDigest(installedLib);
|
|
170
|
+
console.log(`[probe] baseline runtime ${good?.slice(0, 16)} (loaded sections ${goodLoaded?.slice(0, 16)}, ${libName})`);
|
|
171
|
+
|
|
172
|
+
let planted = plantGarbage();
|
|
173
|
+
rmSync(join(build, 'Release', 'ghostty_vt_node.node'), { force: true });
|
|
174
|
+
built = compile();
|
|
175
|
+
probe('stale-runtime-relink', built.status === 0 && sha(installedLib) === good && sha(releaseLib) === good,
|
|
176
|
+
`planted ${planted.slice(0, 16)} -> installed ${sha(installedLib)?.slice(0, 16)}, Release ${sha(releaseLib)?.slice(0, 16)}`);
|
|
177
|
+
|
|
178
|
+
planted = plantGarbage();
|
|
179
|
+
built = compile();
|
|
180
|
+
probe('stale-runtime-idle', built.status === 0 && sha(installedLib) === good, `planted ${planted.slice(0, 16)} -> installed ${sha(installedLib)?.slice(0, 16)}`);
|
|
181
|
+
|
|
182
|
+
const ambient = mkdtempSync(join(tmpdir(), 'ghostty-ambient-zig-'));
|
|
183
|
+
try {
|
|
184
|
+
// Ambient caches: a poisoned copy of this build's own package cache.
|
|
185
|
+
const ambientGlobal = join(ambient, 'global');
|
|
186
|
+
const ambientLocal = join(ambient, 'local');
|
|
187
|
+
cpSync(join(globalCache, 'p'), join(ambientGlobal, 'p'), { recursive: true });
|
|
188
|
+
mkdirSync(ambientLocal, { recursive: true });
|
|
189
|
+
const ambientPoison = poisonCaches(ambientGlobal, ambientLocal);
|
|
190
|
+
const ambientEnv = { ZIG_GLOBAL_CACHE_DIR: ambientGlobal, ZIG_LOCAL_CACHE_DIR: ambientLocal };
|
|
191
|
+
let before = snapshot(ambient);
|
|
192
|
+
rmSync(join(build, 'Release', 'ghostty_vt_node.node'), { force: true });
|
|
193
|
+
built = compile(ambientEnv);
|
|
194
|
+
probe('ambient-cache-poison', built.status === 0 && sha(installedLib) === good && snapshot(ambient) === before,
|
|
195
|
+
`${ambientPoison.sources} poisoned package sources in ZIG_GLOBAL_CACHE_DIR; build exit ${built.status}, runtime ${sha(installedLib)?.slice(0, 16)}, ambient caches ${snapshot(ambient) === before ? 'untouched' : 'CHANGED'}`);
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
appendFileSync(join(source, 'README.md'), 'probe\n');
|
|
199
|
+
built = compile();
|
|
200
|
+
probe('source-modified', built.status !== 0 && /ghostty source tree changed since configure/.test(built.out), `build exit ${built.status}`);
|
|
201
|
+
} finally {
|
|
202
|
+
run('git', ['-C', source, 'checkout', '--', 'README.md']);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Poison this build's PRIVATE caches. A configure that keeps them (dev
|
|
206
|
+
// override) must then fail to build: proof that the poison would be used.
|
|
207
|
+
const privatePoison = poisonCaches(globalCache, localCache);
|
|
208
|
+
let configured = configure(['ADHDEV_GHOSTTY_UNVERIFIED_DEV_BUILD=ON']);
|
|
209
|
+
probe('dev-override', configured.status === 0 && releaseFlag(configured.out) === '0' && /unverified-dev-build/.test(configured.out), `release=${releaseFlag(configured.out)}`);
|
|
210
|
+
built = compile();
|
|
211
|
+
probe('poison-is-live', built.status !== 0 && /poisoned zig package cache/.test(built.out),
|
|
212
|
+
`${privatePoison.sources} sources / ${privatePoison.artifacts} artifacts poisoned in the private caches; kept caches => build exit ${built.status}`);
|
|
213
|
+
|
|
214
|
+
// The fake zig prints EXACT bytes on every platform: a node script copies a
|
|
215
|
+
// data file to stdout, behind a shell (POSIX) or .cmd (Windows) wrapper. A
|
|
216
|
+
// cmd.exe `set /p` stub cannot emit a CR/LF or leading whitespace verbatim.
|
|
217
|
+
const fakeDir = mkdtempSync(join(tmpdir(), 'ghostty-fake-zig-'));
|
|
218
|
+
try {
|
|
219
|
+
const fakeOutput = join(fakeDir, 'version-output.txt');
|
|
220
|
+
const fakeScript = join(fakeDir, 'fake-zig.cjs');
|
|
221
|
+
writeFileSync(fakeScript, `process.stdout.write(require('node:fs').readFileSync(${JSON.stringify(fakeOutput)}));\n`);
|
|
222
|
+
const fake = join(fakeDir, isWin ? 'zig.cmd' : 'zig');
|
|
223
|
+
writeFileSync(fake, isWin
|
|
224
|
+
? `@"${process.execPath}" "${fakeScript}" %*\r\n`
|
|
225
|
+
: `#!/bin/sh\nexec '${process.execPath.replace(/'/g, "'\\''")}' '${fakeScript.replace(/'/g, "'\\''")}' "$@"\n`);
|
|
226
|
+
chmodSync(fake, 0o755);
|
|
227
|
+
const pin = pinnedZigVersion();
|
|
228
|
+
for (const [output, accept] of [[`${pin}!`, false], [pin.replace(/\.(\d+)$/, ' .$1'), false], [`${pin}\nextra`, false], [` ${pin}\r\n`, true]]) {
|
|
229
|
+
writeFileSync(fakeOutput, output);
|
|
230
|
+
configured = configure(['ADHDEV_GHOSTTY_UNVERIFIED_DEV_BUILD=OFF', `ZIG_EXECUTABLE=${fake}`]);
|
|
231
|
+
const accepted = configured.status === 0 && releaseFlag(configured.out) === '1';
|
|
232
|
+
probe(`zig-strict ${JSON.stringify(output)}`, accepted === accept, accept ? 'accepted' : `refused (exit ${configured.status})`);
|
|
233
|
+
}
|
|
234
|
+
} finally {
|
|
235
|
+
rmSync(fakeDir, { recursive: true, force: true });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Plant names Win32 normalizes away (zig extracts such `._.` AppleDouble
|
|
239
|
+
// files from some packages); \\?\ keeps the trailing dot on Windows.
|
|
240
|
+
const raw = (file) => (isWin ? `\\\\?\\${file}` : file);
|
|
241
|
+
mkdirSync(join(globalCache, 'p', 'adhdev-probe'), { recursive: true });
|
|
242
|
+
writeFileSync(raw(join(globalCache, 'p', 'adhdev-probe', '._.')), 'AppleDouble');
|
|
243
|
+
writeFileSync(raw(join(localCache, 'stale.')), 'x');
|
|
244
|
+
|
|
245
|
+
// A clean that leaves the caches in place must stop the release configure.
|
|
246
|
+
const mockDir = mkdtempSync(join(tmpdir(), 'ghostty-rm-mock-'));
|
|
247
|
+
try {
|
|
248
|
+
const mock = join(mockDir, 'rm-noop.cjs');
|
|
249
|
+
writeFileSync(mock, "require('node:fs').rmSync = () => {};\n");
|
|
250
|
+
configured = configure(['ADHDEV_GHOSTTY_UNVERIFIED_DEV_BUILD=OFF', `ZIG_EXECUTABLE=${realZig}`], { NODE_OPTIONS: `--require ${JSON.stringify(mock)}` });
|
|
251
|
+
const kept = walk(globalCache).length + walk(localCache).length;
|
|
252
|
+
probe('clean-fails-closed', configured.status !== 0 && /could not empty the private zig caches/.test(configured.out) && kept > 0,
|
|
253
|
+
`configure exit ${configured.status}; ${kept} planted cache files left in place`);
|
|
254
|
+
} finally {
|
|
255
|
+
rmSync(mockDir, { recursive: true, force: true });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Release configure with the real zig and the ambient poison still in the
|
|
259
|
+
// environment: the private caches and prefix are emptied, then rebuilt
|
|
260
|
+
// from freshly fetched packages.
|
|
261
|
+
configured = configure(['ADHDEV_GHOSTTY_UNVERIFIED_DEV_BUILD=OFF', `ZIG_EXECUTABLE=${realZig}`], ambientEnv);
|
|
262
|
+
const leftovers = walk(globalCache).length + walk(localCache).length;
|
|
263
|
+
probe('reconfigure-clears', configured.status === 0 && releaseFlag(configured.out) === '1' && leftovers === 0 && !existsSync(installedLib),
|
|
264
|
+
`release=${releaseFlag(configured.out)}, ${leftovers} files left in the private caches (incl. planted ._. / trailing-dot), runtime ${existsSync(installedLib) ? 'still present' : 'deleted'}`);
|
|
265
|
+
before = snapshot(ambient);
|
|
266
|
+
built = compile(ambientEnv);
|
|
267
|
+
// A fresh build: equal loaded sections to the baseline (bytes differ only in
|
|
268
|
+
// per-build debug metadata), a well-formed library, shipped as built.
|
|
269
|
+
const formatProblems = existsSync(installedLib) ? checkNativeFile(libName, readFileSync(installedLib), ...platformArch) : ['missing'];
|
|
270
|
+
probe('private-cache-poison', built.status === 0 && loadedDigest(installedLib) === goodLoaded && formatProblems.length === 0
|
|
271
|
+
&& sha(releaseLib) === sha(installedLib) && snapshot(ambient) === before,
|
|
272
|
+
`fresh release build exit ${built.status}, loaded sections ${loadedDigest(installedLib)?.slice(0, 16)} (baseline ${goodLoaded?.slice(0, 16)}; raw ${sha(installedLib)?.slice(0, 16)} vs ${good?.slice(0, 16)}), format ${formatProblems.length ? formatProblems.join('; ') : 'ok'}, ambient ${snapshot(ambient) === before ? 'untouched' : 'CHANGED'}`);
|
|
273
|
+
} finally {
|
|
274
|
+
rmSync(ambient, { recursive: true, force: true });
|
|
275
|
+
run('git', ['-C', source, 'checkout', '--', '.']);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const probeBuild = mkdtempSync(join(tmpdir(), 'ghostty-override-'));
|
|
279
|
+
try {
|
|
280
|
+
const override = run('cmake', ['-S', dir, '-B', probeBuild, `-DFETCHCONTENT_SOURCE_DIR_GHOSTTY=${source}`, `-DZIG_EXECUTABLE=${realZig}`]);
|
|
281
|
+
probe('source-override', override.status !== 0 && /ghostty-source-override/.test(override.out), `configure exit ${override.status}`);
|
|
282
|
+
} finally {
|
|
283
|
+
rmSync(probeBuild, { recursive: true, force: true });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const failed = results.filter((ok) => !ok).length;
|
|
287
|
+
console.log(`[probe] ${results.length - failed}/${results.length} provenance probes passed`);
|
|
288
|
+
if (failed) process.exit(1);
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Exercise the EXACT addon in a directory — require()d by path, so neither the
|
|
3
|
+
// package loader's build/Release preference nor its same-platform fallback can
|
|
4
|
+
// substitute a different binary — and fail unless it is built from the binding
|
|
5
|
+
// sources in this checkout, by the pinned toolchain from a verified ghostty
|
|
6
|
+
// checkout (release eligible), and its viewport API behaves. Loading alone
|
|
7
|
+
// proves nothing about the API: a stale binary loads fine. Every native file in the
|
|
8
|
+
// directory is also byte-scanned for embedded home-directory paths.
|
|
9
|
+
//
|
|
10
|
+
// node scripts/verify-prebuilt.mjs <dir> # e.g. prebuilt/darwin-arm64-node137
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
13
|
+
import { basename, resolve } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
|
|
16
|
+
import { computeBindingDigest, pinnedGhosttyRevision, pinnedZigVersion } from './binding-digest.mjs';
|
|
17
|
+
import { scanHostPaths } from './host-path-scan.mjs';
|
|
18
|
+
import { checkNativeFile } from './native-format.mjs';
|
|
19
|
+
import { ADDON_FILE, RUNTIME_FILE_PATTERN, expectedApiLevel } from './prebuilt-manifest.mjs';
|
|
20
|
+
|
|
21
|
+
const requireNative = createRequire(import.meta.url);
|
|
22
|
+
|
|
23
|
+
function expect(checks, name, actual, expected) {
|
|
24
|
+
const ok = JSON.stringify(actual) === JSON.stringify(expected);
|
|
25
|
+
checks.push({ name, ok, detail: ok ? JSON.stringify(actual) : `got ${JSON.stringify(actual)}, want ${JSON.stringify(expected)}` });
|
|
26
|
+
return ok;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function scanChecks(dir) {
|
|
30
|
+
return scanHostPaths([dir]).map(({ file, hits }) => ({
|
|
31
|
+
name: `no host paths in ${basename(file)}`,
|
|
32
|
+
ok: hits.length === 0,
|
|
33
|
+
detail: hits.length === 0
|
|
34
|
+
? 'clean (ASCII + UTF-16LE)'
|
|
35
|
+
: hits.slice(0, 5).map((hit) => `0x${hit.offset.toString(16)} ${hit.encoding} ${JSON.stringify(hit.prefix)}`).join('; ') + (hits.length > 5 ? ` … +${hits.length - 5}` : ''),
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function verifyPrebuilt(dir, { hostPathScan = true } = {}) {
|
|
40
|
+
// Scanned before loading: a binary that leaks paths fails even if it cannot load.
|
|
41
|
+
const checks = hostPathScan ? scanChecks(dir) : [];
|
|
42
|
+
for (const name of readdirSync(dir).filter((entry) => RUNTIME_FILE_PATTERN.test(entry)).sort()) {
|
|
43
|
+
const problems = checkNativeFile(name, readFileSync(resolve(dir, name)), process.platform, process.arch);
|
|
44
|
+
checks.push({ name: `native format of ${name}`, ok: problems.length === 0, detail: problems.length ? problems.join('; ') : `${process.platform}-${process.arch} shared library` });
|
|
45
|
+
}
|
|
46
|
+
const file = resolve(dir, ADDON_FILE);
|
|
47
|
+
let addon;
|
|
48
|
+
try {
|
|
49
|
+
addon = requireNative(file);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
checks.push({ name: 'load', ok: false, detail: error.message });
|
|
52
|
+
return { file, checks };
|
|
53
|
+
}
|
|
54
|
+
if (typeof addon.getBindingInfo !== 'function') {
|
|
55
|
+
checks.push({ name: 'getBindingInfo', ok: false, detail: 'missing: binary predates API level 2 (rebuild it)' });
|
|
56
|
+
return { file, checks };
|
|
57
|
+
}
|
|
58
|
+
const info = addon.getBindingInfo();
|
|
59
|
+
expect(checks, 'apiLevel', info.apiLevel, expectedApiLevel());
|
|
60
|
+
expect(checks, 'sourceDigest matches sources', info.sourceDigest, computeBindingDigest());
|
|
61
|
+
expect(checks, 'ghosttyRevision is the pin', info.ghosttyRevision, pinnedGhosttyRevision());
|
|
62
|
+
expect(checks, 'built ghostty checkout is the pin', info.ghosttySourceRevision, pinnedGhosttyRevision());
|
|
63
|
+
expect(checks, 'built ghostty checkout is clean', info.ghosttySourceClean, true);
|
|
64
|
+
expect(checks, 'zig is the toolchain.json pin', info.zigVersion, pinnedZigVersion());
|
|
65
|
+
expect(checks, 'release eligible (no provenance problems)', [info.releaseEligible, info.provenanceProblems], [true, []]);
|
|
66
|
+
|
|
67
|
+
const terminal = addon.createTerminal({ cols: 12, rows: 4, scrollback: 100000 });
|
|
68
|
+
try {
|
|
69
|
+
for (const name of ['getViewport', 'readRow', 'scrollViewport']) {
|
|
70
|
+
expect(checks, `${name} present`, typeof terminal[name], 'function');
|
|
71
|
+
}
|
|
72
|
+
if (checks.some((check) => !check.ok)) return { file, info, checks };
|
|
73
|
+
|
|
74
|
+
terminal.write('old\r\n'.repeat(40) + '> FINAL');
|
|
75
|
+
const view = terminal.getViewport({ includeRows: 'all', cells: true });
|
|
76
|
+
expect(checks, 'scrollbar', view.scrollbar, { total: 41, offset: 37, len: 4 });
|
|
77
|
+
expect(checks, 'cursor (active)', [view.cursorRow, view.cursorCol, view.cursorViewportRow], [3, 7, 3]);
|
|
78
|
+
expect(checks, 'cursor row text', view.lines[view.cursorRow].text, '> FINAL');
|
|
79
|
+
terminal.write('Z');
|
|
80
|
+
const marked = terminal.getViewport({ includeRows: 'cursor' });
|
|
81
|
+
expect(checks, 'marker lands on cursor row', marked.lines[0].text, '> FINALZ');
|
|
82
|
+
terminal.scrollViewport('top');
|
|
83
|
+
expect(checks, 'ACTIVE row survives viewport scroll', terminal.readRow(3).text, '> FINALZ');
|
|
84
|
+
expect(checks, 'VIEWPORT row is history', terminal.readRow(3, { space: 'viewport' }).text, 'old');
|
|
85
|
+
terminal.write('\x1bc\x1b[?1049hA한é');
|
|
86
|
+
const alt = terminal.getViewport({ includeRows: 'cursor', cells: true });
|
|
87
|
+
expect(checks, 'alternate screen', alt.activeScreen, 'alternate');
|
|
88
|
+
expect(checks, 'cells', alt.lines[0].cells.slice(0, 4).map((cell) => [cell.text, cell.width, cell.wideKind]), [
|
|
89
|
+
['A', 1, 'narrow'], ['한', 2, 'wide'], ['', 0, 'spacer-tail'], ['é', 1, 'narrow'],
|
|
90
|
+
]);
|
|
91
|
+
expect(checks, 'legacy formatter still works', terminal.formatPlainText({ trim: true }), 'A한é');
|
|
92
|
+
} catch (error) {
|
|
93
|
+
checks.push({ name: 'exercise', ok: false, detail: error.stack || String(error) });
|
|
94
|
+
} finally {
|
|
95
|
+
terminal.dispose();
|
|
96
|
+
}
|
|
97
|
+
return { file, info, checks };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function printVerifyReport({ file, checks }, log = console.log) {
|
|
101
|
+
log(`[ghostty-vt] exercising ${file}`);
|
|
102
|
+
for (const check of checks) log(` ${check.ok ? '✓' : '✗'} ${check.name}: ${check.detail}`);
|
|
103
|
+
const ok = checks.length > 0 && checks.every((check) => check.ok);
|
|
104
|
+
log(`[ghostty-vt] ${ok ? 'exact prebuilt verified' : 'exact prebuilt FAILED'}`);
|
|
105
|
+
return ok;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
|
|
109
|
+
const dir = process.argv[2];
|
|
110
|
+
if (!dir) {
|
|
111
|
+
console.error('usage: verify-prebuilt.mjs <dir containing ghostty_vt_node.node>');
|
|
112
|
+
process.exit(2);
|
|
113
|
+
}
|
|
114
|
+
if (!printVerifyReport(verifyPrebuilt(resolve(dir)))) process.exit(1);
|
|
115
|
+
}
|