@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,113 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Release-safety gate for the committed ghostty-vt-node prebuilts. Four
|
|
3
|
+
// checks, all printed, any failure fails the gate:
|
|
4
|
+
//
|
|
5
|
+
// (a) manifests every shipped triplet carries a manifest whose binding source
|
|
6
|
+
// digest / API level / ghostty pin match the sources in this
|
|
7
|
+
// checkout and whose file hashes match the files. A triplet
|
|
8
|
+
// built from older sources reports "pending-rebuild" — after a
|
|
9
|
+
// bridge/addon change the branch is not shippable until all
|
|
10
|
+
// three platforms are rebuilt and re-stamped.
|
|
11
|
+
// (b) exact the host platform's committed prebuilt, require()d by path,
|
|
12
|
+
// embeds the current digest and its viewport API behaves
|
|
13
|
+
// (loading alone proves nothing: a stale binary loads fine).
|
|
14
|
+
// (c) native-ci the native CI build compiles from source instead of copying
|
|
15
|
+
// the tracked prebuilt, and verifies + stamps what it built;
|
|
16
|
+
// the release job checks every prebuilt it is about to publish.
|
|
17
|
+
// (d) host-paths every shipped native file is free of absolute home-directory
|
|
18
|
+
// paths (ASCII and UTF-16LE), see scripts/host-path-scan.mjs.
|
|
19
|
+
//
|
|
20
|
+
// node scripts/check-prebuilts.mjs [--all-present]
|
|
21
|
+
import { spawnSync } from 'node:child_process';
|
|
22
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
23
|
+
import { join, resolve } from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
|
|
26
|
+
import { PACKAGE_DIR } from './binding-digest.mjs';
|
|
27
|
+
import { printHostPathReport, scanHostPaths } from './host-path-scan.mjs';
|
|
28
|
+
import { PREBUILT_ROOT, checkManifests, printManifestReport, shippedTripletFor } from './prebuilt-manifest.mjs';
|
|
29
|
+
import { printVerifyReport, verifyPrebuilt } from './verify-prebuilt.mjs';
|
|
30
|
+
|
|
31
|
+
const WORKFLOW_DIR = join(PACKAGE_DIR, '..', '..', '.github', 'workflows');
|
|
32
|
+
|
|
33
|
+
function checkExactHostPrebuilt() {
|
|
34
|
+
const triplet = shippedTripletFor();
|
|
35
|
+
if (!triplet) {
|
|
36
|
+
console.log(`[ghostty-vt] no shipped prebuilt for ${process.platform}-${process.arch}; exact check runs on darwin-arm64 / linux-x64 / win32-x64`);
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
// (d) scans every triplet, including this one.
|
|
40
|
+
return printVerifyReport(verifyPrebuilt(join(PREBUILT_ROOT, triplet), { hostPathScan: false }));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Returns [name, ok, detail] rows.
|
|
44
|
+
export function nativeCiFindings(workflowDir = WORKFLOW_DIR, packageDir = PACKAGE_DIR) {
|
|
45
|
+
const findings = [];
|
|
46
|
+
const nativePath = join(workflowDir, 'ghostty-vt-node.yml');
|
|
47
|
+
const ciPath = join(workflowDir, 'ci.yml');
|
|
48
|
+
if (!existsSync(nativePath) || !existsSync(ciPath)) {
|
|
49
|
+
return [['workflows present', false, `missing ${existsSync(nativePath) ? ciPath : nativePath}`]];
|
|
50
|
+
}
|
|
51
|
+
const native = readFileSync(nativePath, 'utf8');
|
|
52
|
+
const ci = readFileSync(ciPath, 'utf8');
|
|
53
|
+
|
|
54
|
+
// A Windows checkout (core.autocrlf) has CRLF line endings.
|
|
55
|
+
const buildStep = native.match(/- name: Build ghostty-vt-node\r?\n([\s\S]*?)(?=\r?\n {6}- name:)/);
|
|
56
|
+
const buildText = buildStep ? buildStep[1] : '';
|
|
57
|
+
const forced = /ADHDEV_GHOSTTY_VT_FORCE_COMPILE:\s*['"]?1['"]?/.test(buildText) || /npm run rebuild -w packages\/ghostty-vt-node/.test(buildText);
|
|
58
|
+
// The zig version comes from toolchain.json (the pin CMake enforces and the
|
|
59
|
+
// release gate compares), never from a literal in the workflow.
|
|
60
|
+
const zigStep = native.match(/- name: Setup Zig\r?\n([\s\S]*?)(?=\r?\n {6}- name:)/);
|
|
61
|
+
const zigFromPin = !!zigStep && /version:\s*\$\{\{\s*steps\.toolchain\.outputs\.zig\s*\}\}/.test(zigStep[1])
|
|
62
|
+
&& /id: toolchain[\s\S]*?packages\/ghostty-vt-node\/toolchain\.json/.test(native);
|
|
63
|
+
findings.push(['native build installs the zig pinned in toolchain.json', zigFromPin, zigStep ? zigStep[1].trim().replace(/\s+/g, ' ') : 'no "Setup Zig" step']);
|
|
64
|
+
// A restored zig cache is mutable state from another run; release artifacts
|
|
65
|
+
// must not see one (setup-zig caches by default). No other step may
|
|
66
|
+
// restore a cache path that zig reads either.
|
|
67
|
+
const zigNoCache = !!zigStep && /use-cache:\s*false\b/.test(zigStep[1]);
|
|
68
|
+
const restoresZigCache = /actions\/cache@[\s\S]*?(zig|ZIG_)/i.test(native) || /ZIG_(GLOBAL|LOCAL)_CACHE_DIR/.test(native);
|
|
69
|
+
findings.push(['native build restores no zig cache', zigNoCache && !restoresZigCache,
|
|
70
|
+
`setup-zig use-cache: ${zigNoCache ? 'false' : 'not disabled'}${restoresZigCache ? '; another step restores or points at a zig cache' : ''}`]);
|
|
71
|
+
findings.push(['native build forces a compile', forced, buildStep ? buildText.trim().replace(/\s+/g, ' ') : 'no "Build ghostty-vt-node" step']);
|
|
72
|
+
findings.push(['native build verifies the exact staged artifact', /scripts\/verify-prebuilt\.mjs "?dist-prebuilt\//.test(native), 'verify-prebuilt.mjs on dist-prebuilt/<triplet>']);
|
|
73
|
+
findings.push(['native build stamps a manifest from the built binary', /scripts\/prebuilt-manifest\.mjs write "?dist-prebuilt\//.test(native), 'prebuilt-manifest.mjs write dist-prebuilt/<triplet>']);
|
|
74
|
+
findings.push(['release gate checks every downloaded prebuilt', /scripts\/check-prebuilts\.mjs --all-present/.test(ci), 'ci.yml runs check-prebuilts.mjs --all-present']);
|
|
75
|
+
|
|
76
|
+
const plan = spawnSync(process.execPath, [join(packageDir, 'scripts', 'build-or-use-prebuilt.mjs'), '--plan'], {
|
|
77
|
+
cwd: packageDir,
|
|
78
|
+
encoding: 'utf8',
|
|
79
|
+
env: { ...process.env, ADHDEV_GHOSTTY_VT_FORCE_COMPILE: '1' },
|
|
80
|
+
windowsHide: true,
|
|
81
|
+
});
|
|
82
|
+
const planLine = (plan.stdout || '').trim().split('\n').pop() || (plan.stderr || '').trim();
|
|
83
|
+
// A forced compile must be a clean `cmake-js rebuild`, never an incremental
|
|
84
|
+
// compile that could reuse a previous build directory's outputs.
|
|
85
|
+
findings.push(['build script honours ADHDEV_GHOSTTY_VT_FORCE_COMPILE with a clean rebuild', /plan: compile \(cmake-js rebuild\)/.test(planLine), planLine]);
|
|
86
|
+
return findings;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function checkNativeCi() {
|
|
90
|
+
const findings = nativeCiFindings();
|
|
91
|
+
console.log('[ghostty-vt] native CI build path');
|
|
92
|
+
for (const [name, ok, detail] of findings) console.log(` ${ok ? '✓' : '✗'} ${name}: ${detail}`);
|
|
93
|
+
return findings.every(([, ok]) => ok);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
|
|
97
|
+
const allPresent = process.argv.includes('--all-present');
|
|
98
|
+
const results = {};
|
|
99
|
+
console.log('── (a) prebuilt manifests vs binding sources');
|
|
100
|
+
results.manifests = printManifestReport(checkManifests({ allPresent }));
|
|
101
|
+
console.log('── (b) exact host prebuilt exercises the new API');
|
|
102
|
+
results.exact = checkExactHostPrebuilt();
|
|
103
|
+
console.log('── (c) native CI compiles instead of copying');
|
|
104
|
+
results.nativeCi = existsSync(WORKFLOW_DIR) ? checkNativeCi() : (console.log('[ghostty-vt] no .github/workflows next to this package (npm install); skipped'), true);
|
|
105
|
+
console.log('── (d) shipped native files carry no host paths');
|
|
106
|
+
results.hostPaths = printHostPathReport(scanHostPaths());
|
|
107
|
+
const failed = Object.entries(results).filter(([, ok]) => !ok).map(([name]) => name);
|
|
108
|
+
if (failed.length) {
|
|
109
|
+
console.error(`✗ ghostty-vt-node prebuilt gate failed: ${failed.join(', ')}`);
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
console.log('✓ ghostty-vt-node prebuilt gate passed');
|
|
113
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Remove a directory tree completely, or fail. CMakeLists.txt uses this to
|
|
3
|
+
// empty the private zig caches before a release build. CMake's own
|
|
4
|
+
// file(REMOVE_RECURSE) and `cmake -E remove_directory` cannot delete names
|
|
5
|
+
// Win32 normalizes away, such as the macOS AppleDouble `._.` entries zig
|
|
6
|
+
// extracts from some packages: DeleteFileW without the \\?\ prefix strips the
|
|
7
|
+
// trailing dot, fails, and the removal stops part-way. Node's fs.rmSync uses
|
|
8
|
+
// \\?\ paths on Windows and deletes them.
|
|
9
|
+
//
|
|
10
|
+
// Exit 0 only when the directory is gone afterwards; anything else is a
|
|
11
|
+
// failure the caller must treat as fatal.
|
|
12
|
+
//
|
|
13
|
+
// node scripts/clean-dir.cjs <absolute directory>
|
|
14
|
+
'use strict';
|
|
15
|
+
const fs = require('node:fs');
|
|
16
|
+
const path = require('node:path');
|
|
17
|
+
|
|
18
|
+
const target = process.argv[2];
|
|
19
|
+
if (!target || !path.isAbsolute(target) || path.parse(target).root === path.resolve(target)) {
|
|
20
|
+
console.error('usage: clean-dir.cjs <absolute directory, not a filesystem root>');
|
|
21
|
+
process.exit(2);
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
fs.rmSync(target, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
25
|
+
} catch (error) {
|
|
26
|
+
console.error(`clean-dir: could not remove ${target}: ${error.code ?? ''} ${error.message}`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
if (fs.existsSync(target)) {
|
|
30
|
+
console.error(`clean-dir: ${target} still exists after removal`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Byte-level host-path scan for shipped native binaries. Compilers and zig embed
|
|
3
|
+
// absolute build/cache paths (rpath, debug/source strings), so a prebuilt built
|
|
4
|
+
// inside a home directory publishes that user's account name. Every runtime file
|
|
5
|
+
// (.node/.dylib/.so*/.dll) is scanned as raw bytes, both as ASCII/UTF-8 and as
|
|
6
|
+
// UTF-16LE (PE resources and MSVC strings are wide), for
|
|
7
|
+
//
|
|
8
|
+
// /Users/<seg>/ /home/<seg>/ X:\Users\<seg>\ X:/Users/<seg>/
|
|
9
|
+
//
|
|
10
|
+
// where <seg> is not a CI runner account (ALLOWED_ACCOUNTS). Build from a
|
|
11
|
+
// neutral directory (/tmp/..., C:\<dir>) with a neutral zig cache instead.
|
|
12
|
+
//
|
|
13
|
+
// An optional denylist of extra literals (hostnames, account names) is read from
|
|
14
|
+
// the file named by ADHDEV_GHOSTTY_HOST_PATH_DENYLIST, or from the untracked
|
|
15
|
+
// `.host-path-denylist` next to package.json. It is never committed: this script
|
|
16
|
+
// itself must stay free of personal identifiers.
|
|
17
|
+
//
|
|
18
|
+
// node scripts/host-path-scan.mjs [<dir>...] # default: every prebuilt/<triplet>
|
|
19
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
20
|
+
import { join, relative, resolve } from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
|
|
23
|
+
import { PACKAGE_DIR } from './binding-digest.mjs';
|
|
24
|
+
import { PREBUILT_ROOT, RUNTIME_FILE_PATTERN } from './prebuilt-manifest.mjs';
|
|
25
|
+
|
|
26
|
+
export const ALLOWED_ACCOUNTS = ['runner', 'runneradmin'];
|
|
27
|
+
export const DENYLIST_ENV = 'ADHDEV_GHOSTTY_HOST_PATH_DENYLIST';
|
|
28
|
+
export const DENYLIST_FILE = join(PACKAGE_DIR, '.host-path-denylist');
|
|
29
|
+
|
|
30
|
+
const SEGMENT = String.raw`([^\\/\x00-\x1f"'<>|*?:]{1,128})`;
|
|
31
|
+
// POSIX homes are case-sensitive (avoids URL paths like /api/users/<id>/);
|
|
32
|
+
// Windows drive paths are not, and take either separator.
|
|
33
|
+
const HOME_PATHS = [
|
|
34
|
+
new RegExp(String.raw`(?<![A-Za-z]:)/(?:Users|home)/${SEGMENT}/`, 'g'),
|
|
35
|
+
new RegExp(String.raw`[A-Za-z]:[\\/]users[\\/]${SEGMENT}[\\/]`, 'gi'),
|
|
36
|
+
];
|
|
37
|
+
const PREFIX_WIDTH = 48;
|
|
38
|
+
|
|
39
|
+
export function loadDenylist(env = process.env) {
|
|
40
|
+
const file = env[DENYLIST_ENV] || DENYLIST_FILE;
|
|
41
|
+
if (!existsSync(file)) return [];
|
|
42
|
+
return readFileSync(file, 'utf8')
|
|
43
|
+
.split(/\r?\n/)
|
|
44
|
+
.map((line) => line.trim())
|
|
45
|
+
.filter((line) => line && !line.startsWith('#'));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
49
|
+
|
|
50
|
+
// Decoded views of the buffer whose character index maps back to a byte offset.
|
|
51
|
+
function* decodedViews(buffer) {
|
|
52
|
+
yield { encoding: 'ascii', text: buffer.toString('latin1'), offsetOf: (index) => index };
|
|
53
|
+
for (const shift of [0, 1]) {
|
|
54
|
+
const slice = buffer.subarray(shift, shift + ((buffer.length - shift) & ~1));
|
|
55
|
+
yield { encoding: 'utf16le', text: slice.toString('utf16le'), offsetOf: (index) => shift + index * 2 };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function scanBuffer(buffer, { denylist = [] } = {}) {
|
|
60
|
+
const hits = [];
|
|
61
|
+
const deny = denylist.length ? new RegExp(denylist.map(escapeRegExp).join('|'), 'gi') : null;
|
|
62
|
+
for (const view of decodedViews(buffer)) {
|
|
63
|
+
for (const pattern of HOME_PATHS) {
|
|
64
|
+
for (const match of view.text.matchAll(pattern)) {
|
|
65
|
+
if (ALLOWED_ACCOUNTS.includes(match[1].toLowerCase())) continue;
|
|
66
|
+
hits.push({ offset: view.offsetOf(match.index), encoding: view.encoding, kind: 'home-path', prefix: match[0].slice(0, PREFIX_WIDTH) });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (!deny) continue;
|
|
70
|
+
for (const match of view.text.matchAll(deny)) {
|
|
71
|
+
hits.push({ offset: view.offsetOf(match.index), encoding: view.encoding, kind: 'denylist', prefix: match[0].slice(0, PREFIX_WIDTH) });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return hits.sort((a, b) => a.offset - b.offset);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function runtimeFilesIn(dir) {
|
|
78
|
+
return readdirSync(dir).filter((name) => RUNTIME_FILE_PATTERN.test(name) && statSync(join(dir, name)).isFile()).sort();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function shippedPrebuiltDirs(root = PREBUILT_ROOT) {
|
|
82
|
+
if (!existsSync(root)) return [];
|
|
83
|
+
return readdirSync(root).map((name) => join(root, name)).filter((dir) => statSync(dir).isDirectory()).sort();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Returns [{ file, hits }] for every runtime file in `dirs`.
|
|
87
|
+
export function scanHostPaths(dirs = shippedPrebuiltDirs(), { denylist = loadDenylist() } = {}) {
|
|
88
|
+
const results = [];
|
|
89
|
+
for (const dir of dirs) {
|
|
90
|
+
for (const name of runtimeFilesIn(dir)) {
|
|
91
|
+
const file = join(dir, name);
|
|
92
|
+
results.push({ file, hits: scanBuffer(readFileSync(file), { denylist }) });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return results;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function printHostPathReport(results, log = console.log, { maxHitsPerFile = 10 } = {}) {
|
|
99
|
+
for (const { file, hits } of results) {
|
|
100
|
+
const shown = relative(PACKAGE_DIR, file);
|
|
101
|
+
log(` ${hits.length ? '✗' : '✓'} ${shown.startsWith('..') ? file : shown}: ${hits.length} host-path hit(s)`);
|
|
102
|
+
for (const hit of hits.slice(0, maxHitsPerFile)) {
|
|
103
|
+
log(` offset 0x${hit.offset.toString(16)} ${hit.encoding} ${hit.kind}: ${JSON.stringify(hit.prefix)}`);
|
|
104
|
+
}
|
|
105
|
+
if (hits.length > maxHitsPerFile) log(` … ${hits.length - maxHitsPerFile} more`);
|
|
106
|
+
}
|
|
107
|
+
const bad = results.filter((r) => r.hits.length);
|
|
108
|
+
log(`[ghostty-vt] ${results.length - bad.length}/${results.length} native files free of host paths (allowed accounts: ${ALLOWED_ACCOUNTS.join(', ')})`);
|
|
109
|
+
return results.length > 0 && bad.length === 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
|
|
113
|
+
const dirs = process.argv.slice(2).map((dir) => resolve(dir));
|
|
114
|
+
if (!printHostPathReport(scanHostPaths(dirs.length ? dirs : undefined))) process.exit(1);
|
|
115
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Native file format checks that never load the binary, so the release gate can
|
|
3
|
+
// validate every platform's prebuilt from any host (release runs on Linux).
|
|
4
|
+
// Each shipped triplet must contain its platform's mandatory runtime set, and
|
|
5
|
+
// every file in it must be a shared library of that platform and architecture:
|
|
6
|
+
//
|
|
7
|
+
// darwin-arm64 Mach-O 64-bit, CPU_TYPE_ARM64 (thin; fat binaries refused)
|
|
8
|
+
// linux-x64 ELF64 little-endian, EM_X86_64, ET_DYN; the libraries carry
|
|
9
|
+
// SONAME libghostty-vt.so.0 and the addon NEEDs it
|
|
10
|
+
// win32-x64 PE32+ DLL (optional header magic 0x20b, sane DLL fields),
|
|
11
|
+
// IMAGE_FILE_MACHINE_AMD64
|
|
12
|
+
//
|
|
13
|
+
// Headers are bounds-checked against the file length; a truncated or
|
|
14
|
+
// malformed header is a problem, never an exception or a silent pass.
|
|
15
|
+
//
|
|
16
|
+
// node scripts/native-format.mjs <dir> # dir name: <platform>-<arch>-node<abi>
|
|
17
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
18
|
+
import { basename, resolve } from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
export const ADDON_FILE = 'ghostty_vt_node.node';
|
|
22
|
+
export const LINUX_SONAME = 'libghostty-vt.so.0';
|
|
23
|
+
|
|
24
|
+
// The mandatory runtime set per platform: exact names or one-of patterns.
|
|
25
|
+
export const REQUIRED_RUNTIME = {
|
|
26
|
+
darwin: [ADDON_FILE, 'libghostty-vt.dylib', /^libghostty-vt\.\d+\.\d+\.\d+\.dylib$/],
|
|
27
|
+
linux: [ADDON_FILE, 'libghostty-vt.so', LINUX_SONAME, /^libghostty-vt\.so\.0\.\d+\.\d+$/],
|
|
28
|
+
win32: [ADDON_FILE, 'ghostty-vt.dll'],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const EXPECTED_FORMAT = {
|
|
32
|
+
'darwin-arm64': { format: 'macho', arch: 'arm64' },
|
|
33
|
+
'linux-x64': { format: 'elf', arch: 'x64' },
|
|
34
|
+
'win32-x64': { format: 'pe', arch: 'x64' },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// "<platform>-<arch>-node<abi>" → { platform, arch }, or null.
|
|
38
|
+
export function parseTriplet(name) {
|
|
39
|
+
const match = /^(darwin|linux|win32)-([a-z0-9]+)-node\d+$/.exec(name);
|
|
40
|
+
return match ? { platform: match[1], arch: match[2] } : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const MACHO_CPU = { 0x0100000c: 'arm64', 0x01000007: 'x64' };
|
|
44
|
+
const MACHO_FILETYPE = { 6: 'dylib', 8: 'bundle' };
|
|
45
|
+
const ELF_MACHINE = { 62: 'x64', 183: 'arm64' };
|
|
46
|
+
const PE_MACHINE = { 0x8664: 'x64', 0xaa64: 'arm64', 0x14c: 'ia32' };
|
|
47
|
+
|
|
48
|
+
// A NUL-terminated string that must lie entirely inside [base, base + size).
|
|
49
|
+
function readTableString(buffer, base, size, offset) {
|
|
50
|
+
if (offset >= size) return null;
|
|
51
|
+
const limit = Math.min(base + size, buffer.length);
|
|
52
|
+
const end = buffer.indexOf(0, base + offset);
|
|
53
|
+
if (end < 0 || end >= limit) return null;
|
|
54
|
+
return buffer.toString('latin1', base + offset, end);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const fits = (buffer, offset, length) => Number.isSafeInteger(offset) && Number.isSafeInteger(length) && offset >= 0 && length >= 0 && offset + length <= buffer.length;
|
|
58
|
+
|
|
59
|
+
function inspectElf(buffer) {
|
|
60
|
+
if (buffer.length < 64) return { format: 'elf', problem: 'truncated ELF header' };
|
|
61
|
+
if (buffer[4] !== 2) return { format: 'elf', problem: 'not ELF64' };
|
|
62
|
+
if (buffer[5] !== 1) return { format: 'elf', problem: 'not little-endian' };
|
|
63
|
+
if (buffer[6] !== 1 || buffer.readUInt32LE(20) !== 1) return { format: 'elf', problem: 'unsupported ELF version' };
|
|
64
|
+
const type = buffer.readUInt16LE(16);
|
|
65
|
+
const machine = buffer.readUInt16LE(18);
|
|
66
|
+
const result = { format: 'elf', arch: ELF_MACHINE[machine] ?? `machine-${machine}`, kind: type === 3 ? 'shared' : `type-${type}`, soname: null, needed: [] };
|
|
67
|
+
if (buffer.readUInt16LE(52) !== 64) return { ...result, problem: 'bad ELF header size' };
|
|
68
|
+
// Program headers: a shared object is loaded through them.
|
|
69
|
+
const phoff = Number(buffer.readBigUInt64LE(32));
|
|
70
|
+
const phentsize = buffer.readUInt16LE(54);
|
|
71
|
+
const phnum = buffer.readUInt16LE(56);
|
|
72
|
+
if (phentsize !== 56 || phnum === 0 || !fits(buffer, phoff, phnum * phentsize)) return { ...result, problem: 'program header table missing or out of bounds' };
|
|
73
|
+
// Section headers: DT_SONAME / DT_NEEDED come from SHT_DYNAMIC + its string table.
|
|
74
|
+
const shoff = Number(buffer.readBigUInt64LE(40));
|
|
75
|
+
const shentsize = buffer.readUInt16LE(58);
|
|
76
|
+
const shnum = buffer.readUInt16LE(60);
|
|
77
|
+
if (!shoff || shentsize !== 64 || shnum === 0 || !fits(buffer, shoff, shnum * shentsize)) return { ...result, problem: 'section header table missing or out of bounds' };
|
|
78
|
+
if (buffer.readUInt16LE(62) >= shnum) return { ...result, problem: 'section name table index out of range' };
|
|
79
|
+
const section = (index) => {
|
|
80
|
+
const base = shoff + index * shentsize;
|
|
81
|
+
return { type: buffer.readUInt32LE(base + 4), offset: Number(buffer.readBigUInt64LE(base + 24)), size: Number(buffer.readBigUInt64LE(base + 32)), link: buffer.readUInt32LE(base + 40) };
|
|
82
|
+
};
|
|
83
|
+
for (let i = 0; i < shnum; i++) {
|
|
84
|
+
const dynamic = section(i);
|
|
85
|
+
if (dynamic.type !== 6 /* SHT_DYNAMIC */) continue;
|
|
86
|
+
if (!fits(buffer, dynamic.offset, dynamic.size) || dynamic.size % 16) return { ...result, problem: 'dynamic section out of bounds' };
|
|
87
|
+
if (dynamic.link === 0 || dynamic.link >= shnum) return { ...result, problem: 'dynamic section has no string table' };
|
|
88
|
+
const strtab = section(dynamic.link);
|
|
89
|
+
if (strtab.type !== 3 /* SHT_STRTAB */ || !fits(buffer, strtab.offset, strtab.size)) return { ...result, problem: 'dynamic string table out of bounds' };
|
|
90
|
+
for (let at = dynamic.offset; at < dynamic.offset + dynamic.size; at += 16) {
|
|
91
|
+
const tag = Number(buffer.readBigInt64LE(at));
|
|
92
|
+
if (tag === 0) break;
|
|
93
|
+
if (tag !== 1 && tag !== 14) continue;
|
|
94
|
+
const value = readTableString(buffer, strtab.offset, strtab.size, Number(buffer.readBigUInt64LE(at + 8)));
|
|
95
|
+
if (value === null) return { ...result, problem: 'dynamic entry string out of bounds' };
|
|
96
|
+
if (tag === 1) result.needed.push(value);
|
|
97
|
+
else result.soname = value;
|
|
98
|
+
}
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
return { ...result, problem: 'no dynamic section' };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function inspectMachO(buffer) {
|
|
105
|
+
if (buffer.length < 32) return { format: 'macho', problem: 'truncated Mach-O header' };
|
|
106
|
+
const cpu = buffer.readUInt32LE(4);
|
|
107
|
+
const filetype = buffer.readUInt32LE(12);
|
|
108
|
+
const result = { format: 'macho', arch: MACHO_CPU[cpu] ?? `cpu-0x${cpu.toString(16)}`, kind: MACHO_FILETYPE[filetype] ?? `filetype-${filetype}` };
|
|
109
|
+
// Load commands: exactly ncmds well-formed commands filling sizeofcmds.
|
|
110
|
+
const ncmds = buffer.readUInt32LE(16);
|
|
111
|
+
const sizeofcmds = buffer.readUInt32LE(20);
|
|
112
|
+
if (ncmds === 0 || !fits(buffer, 32, sizeofcmds)) return { ...result, problem: 'load commands missing or out of bounds' };
|
|
113
|
+
let at = 32;
|
|
114
|
+
for (let i = 0; i < ncmds; i++) {
|
|
115
|
+
if (at + 8 > 32 + sizeofcmds) return { ...result, problem: 'load command table truncated' };
|
|
116
|
+
const cmdsize = buffer.readUInt32LE(at + 4);
|
|
117
|
+
if (cmdsize < 8 || cmdsize % 8 || at + cmdsize > 32 + sizeofcmds) return { ...result, problem: `malformed load command ${i}` };
|
|
118
|
+
at += cmdsize;
|
|
119
|
+
}
|
|
120
|
+
if (at !== 32 + sizeofcmds) return { ...result, problem: 'load commands do not fill sizeofcmds' };
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const PE32_PLUS_MAGIC = 0x20b;
|
|
125
|
+
const PE32_PLUS_MIN_OPTIONAL_HEADER = 112; // standard + Windows-specific fields, before data directories
|
|
126
|
+
|
|
127
|
+
function inspectPe(buffer) {
|
|
128
|
+
if (buffer.length < 0x40) return { format: 'pe', problem: 'truncated DOS header' };
|
|
129
|
+
const peOffset = buffer.readUInt32LE(0x3c);
|
|
130
|
+
if (peOffset < 0x40 || !fits(buffer, peOffset, 24) || buffer.readUInt32LE(peOffset) !== 0x00004550) return { format: 'pe', problem: 'no PE signature' };
|
|
131
|
+
const machine = buffer.readUInt16LE(peOffset + 4);
|
|
132
|
+
const sections = buffer.readUInt16LE(peOffset + 6);
|
|
133
|
+
const optionalSize = buffer.readUInt16LE(peOffset + 20);
|
|
134
|
+
const characteristics = buffer.readUInt16LE(peOffset + 22);
|
|
135
|
+
const result = { format: 'pe', arch: PE_MACHINE[machine] ?? `machine-0x${machine.toString(16)}`, kind: characteristics & 0x2000 ? 'dll' : 'exe' };
|
|
136
|
+
const opt = peOffset + 24;
|
|
137
|
+
if (optionalSize === 0) return { ...result, problem: 'no optional header' };
|
|
138
|
+
if (optionalSize < PE32_PLUS_MIN_OPTIONAL_HEADER || !fits(buffer, opt, optionalSize)) return { ...result, problem: `optional header size ${optionalSize} invalid or out of bounds` };
|
|
139
|
+
const magic = buffer.readUInt16LE(opt);
|
|
140
|
+
if (magic !== PE32_PLUS_MAGIC) return { ...result, problem: `optional header magic 0x${magic.toString(16)}, want 0x20b (PE32+)` };
|
|
141
|
+
const problems = [];
|
|
142
|
+
if (!(characteristics & 0x0002)) problems.push('not an executable image');
|
|
143
|
+
const imageBase = buffer.readBigUInt64LE(opt + 24);
|
|
144
|
+
const sectionAlignment = buffer.readUInt32LE(opt + 32);
|
|
145
|
+
const fileAlignment = buffer.readUInt32LE(opt + 36);
|
|
146
|
+
const sizeOfHeaders = buffer.readUInt32LE(opt + 60);
|
|
147
|
+
const subsystem = buffer.readUInt16LE(opt + 68);
|
|
148
|
+
const rvaCount = buffer.readUInt32LE(opt + 108);
|
|
149
|
+
if (imageBase % 0x10000n !== 0n) problems.push('ImageBase not 64K aligned');
|
|
150
|
+
if (fileAlignment < 512 || fileAlignment > 65536 || (fileAlignment & (fileAlignment - 1))) problems.push(`FileAlignment ${fileAlignment} invalid`);
|
|
151
|
+
if (sectionAlignment < fileAlignment) problems.push('SectionAlignment below FileAlignment');
|
|
152
|
+
if (subsystem !== 2 && subsystem !== 3) problems.push(`subsystem ${subsystem} is not a Windows GUI/CUI DLL`);
|
|
153
|
+
if (rvaCount > 16 || PE32_PLUS_MIN_OPTIONAL_HEADER + rvaCount * 8 > optionalSize) problems.push(`NumberOfRvaAndSizes ${rvaCount} does not fit the optional header`);
|
|
154
|
+
const sectionTable = opt + optionalSize;
|
|
155
|
+
if (sections === 0 || sections > 96 || !fits(buffer, sectionTable, sections * 40)) problems.push('section table missing or out of bounds');
|
|
156
|
+
else if (sizeOfHeaders < sectionTable + sections * 40 || sizeOfHeaders > buffer.length) problems.push('SizeOfHeaders inconsistent');
|
|
157
|
+
return problems.length ? { ...result, problem: problems.join('; ') } : result;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Identifies a native binary from its bytes alone.
|
|
161
|
+
export function inspectNative(buffer) {
|
|
162
|
+
if (buffer.length >= 4 && buffer.readUInt32BE(0) === 0x7f454c46) return inspectElf(buffer);
|
|
163
|
+
if (buffer.length >= 4 && buffer.readUInt32LE(0) === 0xfeedfacf) return inspectMachO(buffer);
|
|
164
|
+
if (buffer.length >= 4 && (buffer.readUInt32BE(0) === 0xcafebabe || buffer.readUInt32BE(0) === 0xbebafeca)) return { format: 'macho-fat', problem: 'universal (fat) Mach-O; ship a thin binary' };
|
|
165
|
+
if (buffer.length >= 4 && buffer.readUInt32LE(0) === 0xfeedface) return { format: 'macho', problem: '32-bit Mach-O' };
|
|
166
|
+
if (buffer.length >= 2 && buffer[0] === 0x4d && buffer[1] === 0x5a) return inspectPe(buffer);
|
|
167
|
+
return { format: 'unknown', problem: 'unrecognized file format' };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Problems with one file for the given platform-arch ([] when it fits).
|
|
171
|
+
export function checkNativeFile(name, buffer, platform, arch) {
|
|
172
|
+
const expected = EXPECTED_FORMAT[`${platform}-${arch}`];
|
|
173
|
+
if (!expected) return [`${name}: no shipped format for ${platform}-${arch}`];
|
|
174
|
+
const info = inspectNative(buffer);
|
|
175
|
+
const problems = [];
|
|
176
|
+
if (info.problem) problems.push(`${name}: ${info.problem}`);
|
|
177
|
+
if (info.format !== expected.format) return [`${name}: ${info.format} binary, want ${expected.format} for ${platform}-${arch}`];
|
|
178
|
+
if (info.arch && info.arch !== expected.arch) problems.push(`${name}: ${info.format} ${info.arch}, want ${expected.arch}`);
|
|
179
|
+
if (info.problem) return problems;
|
|
180
|
+
if (info.format === 'macho' && info.kind !== 'dylib' && info.kind !== 'bundle') problems.push(`${name}: Mach-O ${info.kind}, want a dylib/bundle`);
|
|
181
|
+
if (info.format === 'pe' && info.kind !== 'dll') problems.push(`${name}: PE image is not a DLL`);
|
|
182
|
+
if (info.format === 'elf') {
|
|
183
|
+
if (info.kind !== 'shared') problems.push(`${name}: ELF ${info.kind}, want ET_DYN`);
|
|
184
|
+
if (name === ADDON_FILE) {
|
|
185
|
+
if (!info.needed.includes(LINUX_SONAME)) problems.push(`${name}: does not NEED ${LINUX_SONAME} (needs ${info.needed.join(', ') || 'nothing'})`);
|
|
186
|
+
} else if (info.soname !== LINUX_SONAME) {
|
|
187
|
+
problems.push(`${name}: SONAME ${info.soname ?? 'missing'}, want ${LINUX_SONAME}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return problems;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Problems with the runtime set present in `dir` for platform-arch.
|
|
194
|
+
export function checkRuntimeSet(dir, platform, arch, runtimeFiles) {
|
|
195
|
+
const required = REQUIRED_RUNTIME[platform];
|
|
196
|
+
if (!required || !EXPECTED_FORMAT[`${platform}-${arch}`]) return [`no shipped runtime set for ${platform}-${arch}`];
|
|
197
|
+
const problems = [];
|
|
198
|
+
for (const want of required) {
|
|
199
|
+
const found = typeof want === 'string' ? runtimeFiles.includes(want) : runtimeFiles.some((name) => want.test(name));
|
|
200
|
+
if (!found) problems.push(`required ${typeof want === 'string' ? want : want.source} missing`);
|
|
201
|
+
}
|
|
202
|
+
for (const name of runtimeFiles) problems.push(...checkNativeFile(name, readFileSync(resolve(dir, name)), platform, arch));
|
|
203
|
+
return problems;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
|
|
207
|
+
const dir = process.argv[2] && resolve(process.argv[2]);
|
|
208
|
+
const parsed = dir && parseTriplet(basename(dir));
|
|
209
|
+
if (!parsed || !existsSync(dir)) {
|
|
210
|
+
console.error('usage: native-format.mjs <dir named <platform>-<arch>-node<abi>>');
|
|
211
|
+
process.exit(2);
|
|
212
|
+
}
|
|
213
|
+
const files = readdirSync(dir).filter((name) => /\.(node|dylib|so|so\.\d.*|dll)$/.test(name)).sort();
|
|
214
|
+
for (const name of files) console.log(` ${name}: ${JSON.stringify(inspectNative(readFileSync(resolve(dir, name))))}`);
|
|
215
|
+
const problems = checkRuntimeSet(dir, parsed.platform, parsed.arch, files);
|
|
216
|
+
for (const problem of problems) console.log(` ✗ ${problem}`);
|
|
217
|
+
console.log(`[ghostty-vt] ${problems.length ? 'native format FAILED' : 'native format ok'} for ${parsed.platform}-${parsed.arch}`);
|
|
218
|
+
if (problems.length) process.exit(1);
|
|
219
|
+
}
|