@gjsify/cli 0.45.0 → 0.46.0

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.
@@ -0,0 +1,663 @@
1
+ // The GUI-subsystem launcher `gjsify ship windows` writes for itself.
2
+ //
3
+ // THE DEFECT IT CLOSES. `node.exe` is an `IMAGE_SUBSYSTEM_WINDOWS_CUI` image —
4
+ // `Subsystem` = 3, measured at offset 0xd4 of `node-v24.20.0-win-x64.zip`'s
5
+ // `node.exe` — and the Node release carries no `nodew.exe` to put in its place.
6
+ // The staged launcher is a `.cmd`, which `cmd.exe` runs, and `cmd.exe` is a
7
+ // console image too. So Windows allocates a console for every double-click and
8
+ // every installer shortcut, and a black window sits behind the app for its whole
9
+ // life. ADR 0024 § M3 records that NO CI leg can see this: every Windows job
10
+ // starts the app from a shell and therefore already has a console to inherit.
11
+ //
12
+ // WHY THIS FILE EMITS THE IMAGE INSTEAD OF SHIPPING ONE. The same argument
13
+ // `deb.ts`, `rpm.ts`, `cpio.ts`, `ar.ts`, `zip.ts` and `msi.ts` already make one
14
+ // directory over, with one addition that is specific to a PROGRAM:
15
+ //
16
+ // * a prebuilt stub is a binary in git, and a binary in git needs a toolchain
17
+ // to reproduce it (there is no MSVC and no mingw on the machine that
18
+ // assembles Windows artifacts — ADR 0024 § A1: assembly happens on Linux),
19
+ // a licence row of its own, and a gate proving the committed bytes are the
20
+ // ones that source produces. Emitting the image needs none of the three: the
21
+ // bytes are a pure function of this file, so `pe-launcher.spec.ts` reads them
22
+ // back on any host.
23
+ // * the alternative that needs no new image at all — patching a copy of
24
+ // `node.exe`'s `Subsystem` from 3 to 2 — does not solve the problem it looks
25
+ // like it solves. The console is allocated for `cmd.exe`, before `node.exe`
26
+ // is started at all, so the field that decides it is `cmd.exe`'s and not
27
+ // ours. It also silently invalidates node.exe's Authenticode signature and
28
+ // makes a modified redistributable of somebody else's binary.
29
+ //
30
+ // WHAT IT DOES NOT THROW AWAY. A GUI-subsystem image is not a detached one:
31
+ // Windows allocates a console only when there is none to INHERIT, so this stub
32
+ // started from PowerShell or `cmd.exe` runs inside the console it was started
33
+ // from and every byte the app writes to stdout/stderr lands in that terminal,
34
+ // exactly as the `.cmd` does today. The Explorer path is the one with nowhere to
35
+ // write, and it is the one this stub redirects: with no console, the child is
36
+ // created `CREATE_NO_WINDOW` with its stdout and stderr pointing at
37
+ // `%TEMP%\<logLeaf>`, so an app that dies in its first second leaves a stack
38
+ // trace behind instead of a window that never appeared. Losing the output was the
39
+ // obvious design and it is the one that makes an unlaunchable app undiagnosable.
40
+ //
41
+ // IT RUNS THE `.cmd`, IT DOES NOT REPLACE IT. The stub derives its own path,
42
+ // rewrites the trailing `exe` to `cmd` and runs THAT — so every decision the
43
+ // launcher makes (the prefix, `XDG_DATA_DIRS`, `GJSIFY_GTK_RUNTIME`,
44
+ // `NODE_GI_NATIVE`, `GI_TYPELIB_PATH`, `PATH`, the interpreter) stays in
45
+ // `launcher.ts` where `assertLauncherMatchesInterpreter` and
46
+ // `.github/ship-oracle/verify-program-dir.py` already read it. A second copy of
47
+ // that logic in machine code is a second truth, and it is the copy nobody can
48
+ // read.
49
+ //
50
+ // THE ENCODING IS THE RISKY PART AND IT IS HELD BY A READER THAT IS NOT OURS:
51
+ // `objdump -x` (binutils' `pei-x86-64`) and `.github/ship-oracle/verify-program-dir.py`
52
+ // both parse the emitted file, and `tests/e2e/ship-windows` drives the emitted
53
+ // image through the same `readBinaryArch` that reads a real `node.exe`.
54
+ /**
55
+ * `IMAGE_SUBSYSTEM_WINDOWS_GUI` — the one field this whole file exists to set.
56
+ *
57
+ * The counterpart is 3, `_CUI`, which is what `node.exe` and `cmd.exe` are and
58
+ * what makes Windows allocate a console when there is none to inherit.
59
+ */
60
+ export const PE_SUBSYSTEM_GUI = 2;
61
+ /** Where `Subsystem` lands in the emitted image: `e_lfanew` + 4 + 20 + 68. */
62
+ export const PE_SUBSYSTEM_OFFSET = 0x80 + 4 + 20 + 68;
63
+ /** `IMAGE_FILE_HEADER.Machine` for x86-64. The only architecture the windows layout has. */
64
+ const MACHINE_AMD64 = 0x8664;
65
+ /** Where the PE signature starts. MSVC emits 0x78; nothing reads the value except through `e_lfanew`. */
66
+ const PE_OFFSET = 0x80;
67
+ const FILE_ALIGNMENT = 0x200;
68
+ const SECTION_ALIGNMENT = 0x1000;
69
+ const IMAGE_BASE = 0x140000000n;
70
+ /**
71
+ * The kernel32 entry points the stub calls, in the order its IAT lists them.
72
+ *
73
+ * ONE dll, and every name is a documented kernel32 export — including
74
+ * `GetConsoleCP` (which is what decides whether the child may be given a console
75
+ * of its own) and the two `lstr*` string helpers, which is why the stub needs no
76
+ * CRT and therefore carries nobody's licence but this repository's.
77
+ */
78
+ const IMPORTS = [
79
+ 'GetModuleFileNameW',
80
+ 'GetSystemDirectoryW',
81
+ 'GetCommandLineW',
82
+ 'GetConsoleCP',
83
+ 'GetStdHandle',
84
+ 'GetTempPathW',
85
+ 'CreateFileW',
86
+ 'CreateProcessW',
87
+ 'WaitForSingleObject',
88
+ 'GetExitCodeProcess',
89
+ 'ExitProcess',
90
+ 'lstrcpyW',
91
+ 'lstrcatW',
92
+ ];
93
+ /**
94
+ * A byte buffer with named positions and deferred 32-bit fixups.
95
+ *
96
+ * Everything this file emits is either an absolute value known at layout time or
97
+ * a RIP-relative displacement that is not — so the assembler records the second
98
+ * kind and patches it once the data section's addresses exist. Fixed instruction
99
+ * lengths (every displacement is a `disp32`, every branch a `rel32`) are what
100
+ * make one patching pass enough.
101
+ */
102
+ class Emitter {
103
+ bytes = [];
104
+ labels = new Map();
105
+ ripFixups = [];
106
+ branchFixups = [];
107
+ push(...values) {
108
+ for (const value of values)
109
+ this.bytes.push(value & 0xff);
110
+ }
111
+ label(name) {
112
+ this.labels.set(name, this.bytes.length);
113
+ }
114
+ /** A `disp32` that will be `symbolRva - (ripAfterThisInstruction)`. */
115
+ ripSlot(symbol) {
116
+ this.ripFixups.push({ at: this.bytes.length, symbol });
117
+ this.push(0, 0, 0, 0);
118
+ }
119
+ branchSlot(label) {
120
+ this.branchFixups.push({ at: this.bytes.length, label });
121
+ this.push(0, 0, 0, 0);
122
+ }
123
+ /** `lea <reg>, [rip + symbol]` — the ModRM byte differs, the REX prefix carries the high bit. */
124
+ leaRip(reg, symbol) {
125
+ const modrm = { rax: 0x05, rcx: 0x0d, rdx: 0x15, rdi: 0x3d, r8: 0x05, r9: 0x0d }[reg];
126
+ this.push(reg === 'r8' || reg === 'r9' ? 0x4c : 0x48, 0x8d, modrm);
127
+ this.ripSlot(symbol);
128
+ }
129
+ /** `call qword ptr [rip + symbol]` — an indirect call through the IAT slot. */
130
+ callImport(name) {
131
+ this.push(0xff, 0x15);
132
+ this.ripSlot(`iat.${name}`);
133
+ }
134
+ jmp(label) {
135
+ this.push(0xe9);
136
+ this.branchSlot(label);
137
+ }
138
+ /** `jcc rel32`, spelled by the two-byte form so no branch is ever out of range. */
139
+ jcc(condition, label) {
140
+ this.push(0x0f, { e: 0x84, ne: 0x85, b: 0x82 }[condition]);
141
+ this.branchSlot(label);
142
+ }
143
+ /** Resolve every recorded fixup. `symbolRva` answers for data, the label map for code. */
144
+ resolve(codeRva, symbolRva) {
145
+ const out = Buffer.from(this.bytes);
146
+ for (const { at, symbol } of this.ripFixups) {
147
+ // The displacement is relative to the END of the instruction, which is
148
+ // four bytes past the slot itself — the one rule about RIP-relative
149
+ // addressing that is not visible in the encoding.
150
+ out.writeInt32LE(symbolRva(symbol) - (codeRva + at + 4), at);
151
+ }
152
+ for (const { at, label } of this.branchFixups) {
153
+ const target = this.labels.get(label);
154
+ if (target === undefined)
155
+ throw new Error(`gjsify ship: the launcher stub jumps to no label "${label}".`);
156
+ out.writeInt32LE(target - (at + 4), at);
157
+ }
158
+ return new Uint8Array(out);
159
+ }
160
+ }
161
+ /** `mov <reg32>, imm32`. */
162
+ function movImm32(emit, reg, value) {
163
+ const opcode = { eax: 0xb8, ecx: 0xb9, edx: 0xba, r8d: 0xb8, r9d: 0xb9, r14d: 0xbe }[reg];
164
+ if (reg === 'r8d' || reg === 'r9d' || reg === 'r14d')
165
+ emit.push(0x41);
166
+ emit.push(opcode);
167
+ const buffer = Buffer.alloc(4);
168
+ buffer.writeInt32LE(value | 0);
169
+ emit.push(...buffer);
170
+ }
171
+ /** `mov dword ptr [rsp + disp32], imm32`. */
172
+ function movStackDword(emit, offset, value) {
173
+ emit.push(0xc7, 0x84, 0x24);
174
+ const displacement = Buffer.alloc(4);
175
+ displacement.writeInt32LE(offset);
176
+ emit.push(...displacement);
177
+ const immediate = Buffer.alloc(4);
178
+ immediate.writeInt32LE(value | 0);
179
+ emit.push(...immediate);
180
+ }
181
+ /** `mov qword ptr [rsp + disp32], <reg>`. */
182
+ function movStackFromReg(emit, reg, offset) {
183
+ const modrm = { rax: 0x84, rbx: 0x9c, rsi: 0xb4, rdi: 0xbc }[reg];
184
+ emit.push(0x48, 0x89, modrm, 0x24);
185
+ const displacement = Buffer.alloc(4);
186
+ displacement.writeInt32LE(offset);
187
+ emit.push(...displacement);
188
+ }
189
+ /** `mov rax, qword ptr [rsp + disp32]`. */
190
+ function movRaxFromStack(emit, offset) {
191
+ emit.push(0x48, 0x8b, 0x84, 0x24);
192
+ const displacement = Buffer.alloc(4);
193
+ displacement.writeInt32LE(offset);
194
+ emit.push(...displacement);
195
+ }
196
+ /** `lea <reg>, [rsp + disp32]`. */
197
+ function leaStack(emit, reg, offset) {
198
+ const modrm = { rax: 0x84, rcx: 0x8c, rdx: 0x94, rdi: 0xbc, r9: 0x8c }[reg];
199
+ emit.push(reg === 'r9' ? 0x4c : 0x48, 0x8d, modrm, 0x24);
200
+ const displacement = Buffer.alloc(4);
201
+ displacement.writeInt32LE(offset);
202
+ emit.push(...displacement);
203
+ }
204
+ // The stack frame, as offsets from `rsp` after the prologue. Named because the
205
+ // three Win32 structures below are addressed by hand and a wrong offset is a
206
+ // silent argument, not a compile error.
207
+ const FRAME = {
208
+ /** Shadow space plus the six stack arguments `CreateProcessW` takes. */
209
+ args: 0x20,
210
+ /** `SECURITY_ATTRIBUTES` — 24 bytes, `bInheritHandle` at +0x10. */
211
+ securityAttributes: 0x50,
212
+ /** `STARTUPINFOW` — 104 bytes; `cb` at +0, `dwFlags` at +0x3c, the three handles at +0x50. */
213
+ startupInfo: 0x70,
214
+ /** `PROCESS_INFORMATION` — 24 bytes; `hProcess` first. */
215
+ processInformation: 0xd8,
216
+ /** Where `GetExitCodeProcess` writes. */
217
+ exitCode: 0xf0,
218
+ /** Total frame size, 16-byte aligned. */
219
+ size: 0x110,
220
+ };
221
+ const STARTUPINFO_CB = 104;
222
+ const SECURITY_ATTRIBUTES_CB = 24;
223
+ const STARTF_USESTDHANDLES = 0x100;
224
+ const CREATE_NO_WINDOW = 0x08000000;
225
+ const GENERIC_WRITE = 0x40000000;
226
+ const FILE_SHARE_READ_WRITE = 0x3;
227
+ const CREATE_ALWAYS = 2;
228
+ const FILE_ATTRIBUTE_NORMAL = 0x80;
229
+ /** Capacities in WCHARs. `cmdBuf` holds the whole assembled command line. */
230
+ const BUFFERS = { sysBuf: 264, selfBuf: 520, tmpBuf: 264, logBuf: 600, cmdBuf: 4096 };
231
+ /**
232
+ * The stub's instruction stream.
233
+ *
234
+ * Reads top to bottom as the program it is:
235
+ *
236
+ * 1. `GetModuleFileNameW` → our own path; rewrite the trailing `exe` to `cmd`.
237
+ * A three-character overwrite and not a path search, which is what makes the
238
+ * whole stub free of string parsing: `<App>.exe` and `<App>.cmd` differ in
239
+ * exactly those bytes.
240
+ * 2. `GetSystemDirectoryW` → the real `cmd.exe`. `CreateProcessW` with a bare
241
+ * `cmd.exe` searches the CURRENT directory too, which under Explorer is the
242
+ * program directory — an absolute path is the only spelling that cannot be
243
+ * answered by a file sitting beside the app.
244
+ * 3. assemble `"<sys>\cmd.exe" /s /c ""<self>.cmd" <our own arguments>"`. `/s`
245
+ * is the documented flag that makes `cmd.exe` strip exactly the outer pair
246
+ * of quotes and take the rest literally, which is the only form that
247
+ * survives a program directory with a space in its path.
248
+ * 4. `GetConsoleCP` and `GetStdHandle` — two questions, three handles — decide
249
+ * the two remaining arguments: a console to inherit means no flags and no
250
+ * redirect (the terminal sees everything an unhidden `.cmd` shows today); no
251
+ * console but a usable stdout AND stderr means `CREATE_NO_WINDOW` and the
252
+ * caller's own handles; a dead handle on either means `CREATE_NO_WINDOW` plus
253
+ * `%TEMP%\<logLeaf>`, substituted for the dead one only so a caller's
254
+ * surviving redirect is never overwritten. `GetConsoleWindow` is deliberately
255
+ * NOT the probe — it answers NULL for a windowless console, and the comment at
256
+ * step 4 below carries the measurement that cost.
257
+ * 5. wait, and exit with the child's status — so `<App>.exe` in a script is a
258
+ * truthful `%ERRORLEVEL%` and not a fire-and-forget.
259
+ */
260
+ function emitStub() {
261
+ const emit = new Emitter();
262
+ // `and rsp, -16` then `sub rsp, FRAME.size`. The entry point is called, so
263
+ // `rsp % 16 == 8` here; aligning explicitly is cheaper than reasoning about it
264
+ // and is what every call below needs.
265
+ emit.push(0x48, 0x83, 0xe4, 0xf0);
266
+ emit.push(0x48, 0x81, 0xec, FRAME.size & 0xff, (FRAME.size >> 8) & 0xff, 0, 0);
267
+ // Zero everything from the outgoing arguments to the end of the frame. The
268
+ // stack argument slots are eight bytes each while `bInheritHandles` and
269
+ // `dwCreationFlags` are DWORDs, so their upper halves are read from whatever
270
+ // was there — `rep stosq` is what makes them defined.
271
+ leaStack(emit, 'rdi', FRAME.args);
272
+ emit.push(0x31, 0xc0); // xor eax, eax
273
+ movImm32(emit, 'ecx', (FRAME.size - FRAME.args) / 8);
274
+ emit.push(0xf3, 0x48, 0xab); // rep stosq
275
+ // ── 1. our own path, rewritten to the `.cmd` beside us ───────────────────
276
+ emit.push(0x31, 0xc9); // xor ecx, ecx — hModule = NULL
277
+ emit.leaRip('rdx', 'selfBuf');
278
+ movImm32(emit, 'r8d', BUFFERS.selfBuf);
279
+ emit.callImport('GetModuleFileNameW');
280
+ emit.push(0x83, 0xf8, 0x04); // cmp eax, 4
281
+ emit.jcc('b', 'fail');
282
+ emit.leaRip('rdx', 'selfBuf');
283
+ emit.push(0x89, 0xc1); // mov ecx, eax (zero-extends: the length in WCHARs)
284
+ emit.push(0x48, 0x8d, 0x04, 0x4a); // lea rax, [rdx + rcx*2] — one past the last WCHAR
285
+ emit.push(0x66, 0xc7, 0x40, 0xfa, 0x63, 0x00); // mov word [rax-6], 'c'
286
+ emit.push(0x66, 0xc7, 0x40, 0xfc, 0x6d, 0x00); // mov word [rax-4], 'm'
287
+ emit.push(0x66, 0xc7, 0x40, 0xfe, 0x64, 0x00); // mov word [rax-2], 'd'
288
+ // ── 2. the real cmd.exe ──────────────────────────────────────────────────
289
+ emit.leaRip('rcx', 'sysBuf');
290
+ movImm32(emit, 'edx', BUFFERS.sysBuf);
291
+ emit.callImport('GetSystemDirectoryW');
292
+ // ── 3. the command line ──────────────────────────────────────────────────
293
+ emit.leaRip('rcx', 'cmdBuf');
294
+ emit.leaRip('rdx', 'strQuote');
295
+ emit.callImport('lstrcpyW');
296
+ for (const part of ['sysBuf', 'strCmdExe', 'selfBuf', 'strQuoteSpace']) {
297
+ emit.leaRip('rcx', 'cmdBuf');
298
+ emit.leaRip('rdx', part);
299
+ emit.callImport('lstrcatW');
300
+ }
301
+ // Our own arguments, which are everything after argv[0]. Windows hands the
302
+ // raw line back, so argv[0] is skipped by the same rule the C runtime uses:
303
+ // a quoted first token ends at its closing quote, an unquoted one at the
304
+ // first space or tab.
305
+ emit.callImport('GetCommandLineW');
306
+ emit.push(0x48, 0x89, 0xc2); // mov rdx, rax
307
+ emit.push(0x66, 0x83, 0x3a, 0x22); // cmp word [rdx], '"'
308
+ emit.jcc('ne', 'skipPlain');
309
+ emit.push(0x48, 0x83, 0xc2, 0x02); // add rdx, 2
310
+ emit.label('skipQuoted');
311
+ emit.push(0x66, 0x8b, 0x02); // mov ax, [rdx]
312
+ emit.push(0x66, 0x85, 0xc0); // test ax, ax
313
+ emit.jcc('e', 'argsReady');
314
+ emit.push(0x48, 0x83, 0xc2, 0x02); // add rdx, 2
315
+ emit.push(0x66, 0x83, 0xf8, 0x22); // cmp ax, '"'
316
+ emit.jcc('ne', 'skipQuoted');
317
+ emit.jmp('skipSpace');
318
+ emit.label('skipPlain');
319
+ emit.push(0x66, 0x8b, 0x02); // mov ax, [rdx]
320
+ emit.push(0x66, 0x85, 0xc0); // test ax, ax
321
+ emit.jcc('e', 'argsReady');
322
+ emit.push(0x66, 0x83, 0xf8, 0x20); // cmp ax, ' '
323
+ emit.jcc('e', 'skipSpace');
324
+ emit.push(0x66, 0x83, 0xf8, 0x09); // cmp ax, '\t'
325
+ emit.jcc('e', 'skipSpace');
326
+ emit.push(0x48, 0x83, 0xc2, 0x02); // add rdx, 2
327
+ emit.jmp('skipPlain');
328
+ emit.label('skipSpace');
329
+ emit.push(0x66, 0x8b, 0x02); // mov ax, [rdx]
330
+ emit.push(0x66, 0x83, 0xf8, 0x20); // cmp ax, ' '
331
+ emit.jcc('e', 'skipSpaceStep');
332
+ emit.push(0x66, 0x83, 0xf8, 0x09); // cmp ax, '\t'
333
+ emit.jcc('ne', 'argsReady');
334
+ emit.label('skipSpaceStep');
335
+ emit.push(0x48, 0x83, 0xc2, 0x02); // add rdx, 2
336
+ emit.jmp('skipSpace');
337
+ emit.label('argsReady');
338
+ emit.leaRip('rcx', 'cmdBuf');
339
+ emit.callImport('lstrcatW');
340
+ emit.leaRip('rcx', 'cmdBuf');
341
+ emit.leaRip('rdx', 'strQuote');
342
+ emit.callImport('lstrcatW');
343
+ // ── 4. a console to inherit, standard handles to pass on, or a log file ──
344
+ //
345
+ // TWO INDEPENDENT QUESTIONS, and collapsing them into one is the mistake this
346
+ // sequence is the second version of. "Does this process own a console" decides
347
+ // whether `cmd.exe` may be given one of its own (`CREATE_NO_WINDOW`, which is a
348
+ // console with no window rather than no console); "can this process write
349
+ // anywhere" decides whether the child's output needs a file. They come apart in
350
+ // both directions — a scheduled task has a console with NO WINDOW, and a GUI
351
+ // process started with a redirected stdout has a writable handle and no console
352
+ // at all.
353
+ //
354
+ // `GetConsoleWindow` was the first probe and it answers neither: it returns NULL
355
+ // for a windowless console, so a task-launched run took the log branch while its
356
+ // caller's own `> file` redirect captured nothing. `GetConsoleCP` fails with no
357
+ // console and succeeds with a windowless one, which is the question actually
358
+ // being asked.
359
+ //
360
+ // `rbx`, `rsi`, `rdi` and `r14` are all non-volatile, so the handles and the
361
+ // creation flags survive every call below.
362
+ movImm32(emit, 'ecx', -10); // STD_INPUT_HANDLE
363
+ emit.callImport('GetStdHandle');
364
+ emit.push(0x48, 0x89, 0xc7); // mov rdi, rax
365
+ movImm32(emit, 'ecx', -11); // STD_OUTPUT_HANDLE
366
+ emit.callImport('GetStdHandle');
367
+ emit.push(0x48, 0x89, 0xc3); // mov rbx, rax
368
+ movImm32(emit, 'ecx', -12); // STD_ERROR_HANDLE
369
+ emit.callImport('GetStdHandle');
370
+ emit.push(0x48, 0x89, 0xc6); // mov rsi, rax
371
+ emit.callImport('GetConsoleCP');
372
+ emit.push(0x85, 0xc0); // test eax, eax
373
+ emit.jcc('ne', 'haveConsole');
374
+ // No console: the child may not be given a window, and the handles have to be
375
+ // named explicitly — a process that allocates a console gets that console's
376
+ // buffers as its standard handles unless `STARTF_USESTDHANDLES` says otherwise.
377
+ movImm32(emit, 'r14d', CREATE_NO_WINDOW);
378
+ // BOTH HANDLES ARE PROBED, not just stdout, and the reason is the whole point
379
+ // of this file: `fd 2` is where an uncaught exception's trace comes out, and
380
+ // Node prints it from C++ (`src/debug_utils.cc`), so a dead stderr loses the
381
+ // one output that says why the app did not start.
382
+ //
383
+ // MEASURED, red before green, on `win11-gjsify` (2026-09-02). The state is
384
+ // constructible with `CreateProcessW` + `DETACHED_PROCESS` (no console at all)
385
+ // and `STARTF_USESTDHANDLES` where `hStdOutput` is a real file and `hStdError`
386
+ // is NULL: with `rbx` probed alone, the child's `console.log` reached the
387
+ // caller's file and the stderr line plus the entire uncaught-exception trace
388
+ // reached NOTHING — no `%TEMP%` log was even opened, because stdout looked
389
+ // fine. With both probed, the trace lands in `%TEMP%\<logLeaf>` and the
390
+ // caller's stdout redirect is untouched. `INVALID_HANDLE_VALUE` in place of
391
+ // NULL takes the same path, measured separately.
392
+ //
393
+ // The predicate is the SAME for both, deliberately: NULL or
394
+ // `INVALID_HANDLE_VALUE`. A handle that is neither is the caller's, and the
395
+ // caller's redirect always wins over our log file.
396
+ emit.push(0x48, 0x85, 0xdb); // test rbx, rbx
397
+ emit.jcc('e', 'needLog');
398
+ emit.push(0x48, 0x83, 0xfb, 0xff); // cmp rbx, -1 (INVALID_HANDLE_VALUE)
399
+ emit.jcc('e', 'needLog');
400
+ emit.push(0x48, 0x85, 0xf6); // test rsi, rsi
401
+ emit.jcc('e', 'needLog');
402
+ emit.push(0x48, 0x83, 0xfe, 0xff); // cmp rsi, -1
403
+ emit.jcc('ne', 'useStdHandles');
404
+ emit.label('needLog');
405
+ movImm32(emit, 'ecx', BUFFERS.tmpBuf);
406
+ emit.leaRip('rdx', 'tmpBuf');
407
+ emit.callImport('GetTempPathW');
408
+ emit.leaRip('rcx', 'logBuf');
409
+ emit.leaRip('rdx', 'tmpBuf');
410
+ emit.callImport('lstrcpyW');
411
+ emit.leaRip('rcx', 'logBuf');
412
+ emit.leaRip('rdx', 'strLogLeaf');
413
+ emit.callImport('lstrcatW');
414
+ emit.leaRip('rcx', 'logBuf');
415
+ movImm32(emit, 'edx', GENERIC_WRITE);
416
+ movImm32(emit, 'r8d', FILE_SHARE_READ_WRITE);
417
+ leaStack(emit, 'r9', FRAME.securityAttributes);
418
+ movStackDword(emit, FRAME.securityAttributes, SECURITY_ATTRIBUTES_CB);
419
+ // Inheritable, or the child cannot be handed it — the one field that makes this
420
+ // a redirect rather than a file this process opened and nobody wrote to.
421
+ movStackDword(emit, FRAME.securityAttributes + 0x10, 1);
422
+ movStackDword(emit, FRAME.args, CREATE_ALWAYS);
423
+ movStackDword(emit, FRAME.args + 0x8, FILE_ATTRIBUTE_NORMAL);
424
+ movStackDword(emit, FRAME.args + 0x10, 0); // hTemplateFile
425
+ emit.callImport('CreateFileW');
426
+ emit.push(0x48, 0x83, 0xf8, 0xff); // cmp rax, -1
427
+ // A `%TEMP%` that cannot be written to is not a reason to refuse to start the
428
+ // app: run it with no window and no output rather than not at all.
429
+ emit.jcc('e', 'create');
430
+ // THE LOG REPLACES ONLY WHAT IS UNUSABLE. Both handles reach here when either
431
+ // one is dead, so substituting blindly would overwrite a redirect the caller
432
+ // asked for — `cmd /c "app.exe > file"` with a broken stderr must still put
433
+ // stdout in `file`. Each is tested again against the same predicate as above
434
+ // and only the dead one becomes the log.
435
+ emit.push(0x48, 0x85, 0xdb); // test rbx, rbx
436
+ emit.jcc('e', 'logIsStdout');
437
+ emit.push(0x48, 0x83, 0xfb, 0xff); // cmp rbx, -1
438
+ emit.jcc('ne', 'stdoutKept');
439
+ emit.label('logIsStdout');
440
+ emit.push(0x48, 0x89, 0xc3); // mov rbx, rax
441
+ emit.label('stdoutKept');
442
+ emit.push(0x48, 0x85, 0xf6); // test rsi, rsi
443
+ emit.jcc('e', 'logIsStderr');
444
+ emit.push(0x48, 0x83, 0xfe, 0xff); // cmp rsi, -1
445
+ emit.jcc('ne', 'useStdHandles');
446
+ emit.label('logIsStderr');
447
+ emit.push(0x48, 0x89, 0xc6); // mov rsi, rax
448
+ emit.label('useStdHandles');
449
+ movStackFromReg(emit, 'rdi', FRAME.startupInfo + 0x50); // hStdInput
450
+ movStackFromReg(emit, 'rbx', FRAME.startupInfo + 0x58); // hStdOutput
451
+ movStackFromReg(emit, 'rsi', FRAME.startupInfo + 0x60); // hStdError
452
+ movStackDword(emit, FRAME.startupInfo + 0x3c, STARTF_USESTDHANDLES);
453
+ emit.jmp('create');
454
+ // A console of our own: hand the child nothing and let it share everything —
455
+ // the console, and any redirection already applied to it. This is the branch
456
+ // that makes `<App>.exe` in a terminal behave exactly as the `.cmd` does.
457
+ emit.label('haveConsole');
458
+ emit.push(0x45, 0x31, 0xf6); // xor r14d, r14d
459
+ // ── 5. run it, wait, and be its exit code ────────────────────────────────
460
+ emit.label('create');
461
+ movStackDword(emit, FRAME.startupInfo, STARTUPINFO_CB); // si.cb
462
+ emit.push(0x31, 0xc9); // xor ecx, ecx — lpApplicationName = NULL
463
+ emit.leaRip('rdx', 'cmdBuf');
464
+ emit.push(0x45, 0x31, 0xc0); // xor r8d, r8d
465
+ emit.push(0x45, 0x31, 0xc9); // xor r9d, r9d
466
+ movStackDword(emit, FRAME.args, 1); // bInheritHandles = TRUE
467
+ emit.push(0x44, 0x89, 0xb4, 0x24); // mov [rsp + disp32], r14d — dwCreationFlags
468
+ {
469
+ const displacement = Buffer.alloc(4);
470
+ displacement.writeInt32LE(FRAME.args + 0x8);
471
+ emit.push(...displacement);
472
+ }
473
+ movStackDword(emit, FRAME.args + 0x10, 0); // lpEnvironment
474
+ movStackDword(emit, FRAME.args + 0x18, 0); // lpCurrentDirectory
475
+ leaStack(emit, 'rax', FRAME.startupInfo);
476
+ movStackFromReg(emit, 'rax', FRAME.args + 0x20);
477
+ leaStack(emit, 'rax', FRAME.processInformation);
478
+ movStackFromReg(emit, 'rax', FRAME.args + 0x28);
479
+ emit.callImport('CreateProcessW');
480
+ emit.push(0x85, 0xc0); // test eax, eax
481
+ emit.jcc('e', 'fail');
482
+ movRaxFromStack(emit, FRAME.processInformation); // hProcess
483
+ emit.push(0x48, 0x89, 0xc1); // mov rcx, rax
484
+ movImm32(emit, 'edx', -1); // INFINITE
485
+ emit.callImport('WaitForSingleObject');
486
+ movRaxFromStack(emit, FRAME.processInformation);
487
+ emit.push(0x48, 0x89, 0xc1); // mov rcx, rax
488
+ leaStack(emit, 'rdx', FRAME.exitCode);
489
+ emit.callImport('GetExitCodeProcess');
490
+ emit.push(0x8b, 0x8c, 0x24); // mov ecx, [rsp + disp32]
491
+ {
492
+ const displacement = Buffer.alloc(4);
493
+ displacement.writeInt32LE(FRAME.exitCode);
494
+ emit.push(...displacement);
495
+ }
496
+ emit.callImport('ExitProcess');
497
+ // Unreachable except through the two guards above. `ExitProcess` does not
498
+ // return, so there is nothing after it to fall into.
499
+ emit.label('fail');
500
+ movImm32(emit, 'ecx', 1);
501
+ emit.callImport('ExitProcess');
502
+ emit.push(0xcc); // int3 — a trap, not a fallthrough into the data section
503
+ return emit;
504
+ }
505
+ /** UTF-16LE with the terminator, which is what every `lstr*W` reads to. */
506
+ function wide(text) {
507
+ return new Uint8Array(Buffer.from(`${text}\0`, 'utf16le'));
508
+ }
509
+ /**
510
+ * Everything the stub reads or writes, laid out in one read-write section.
511
+ *
512
+ * The IAT comes FIRST because two data directories name it and the loader writes
513
+ * it; the strings and the buffers follow. Buffers are emitted as zeros rather
514
+ * than declared as uninitialised data — a `VirtualSize` larger than
515
+ * `SizeOfRawData` is the same thing with one more field to get wrong, and eleven
516
+ * kilobytes of zeros compresses to nothing in every container this image ships
517
+ * inside.
518
+ */
519
+ function dataItems(input) {
520
+ const items = [];
521
+ for (const name of IMPORTS)
522
+ items.push({ symbol: `iat.${name}`, bytes: new Uint8Array(8) });
523
+ items.push({ symbol: 'iat.null', bytes: new Uint8Array(8) });
524
+ items.push({ symbol: 'importDescriptors', bytes: new Uint8Array(40) });
525
+ for (const name of IMPORTS)
526
+ items.push({ symbol: `ilt.${name}`, bytes: new Uint8Array(8) });
527
+ items.push({ symbol: 'ilt.null', bytes: new Uint8Array(8) });
528
+ for (const name of IMPORTS) {
529
+ // IMAGE_IMPORT_BY_NAME: a two-byte hint the loader may ignore, the name,
530
+ // its terminator, and a pad byte when that lands odd — the structure has
531
+ // to start on an even address.
532
+ const raw = Buffer.from(`\0\0${name}\0`, 'latin1');
533
+ items.push({
534
+ symbol: `hint.${name}`,
535
+ bytes: new Uint8Array(raw.length % 2 === 0 ? raw : Buffer.concat([raw, Buffer.alloc(1)])),
536
+ });
537
+ }
538
+ items.push({ symbol: 'dllName', bytes: new Uint8Array(Buffer.from('KERNEL32.dll\0\0', 'latin1')) });
539
+ items.push({ symbol: 'strQuote', bytes: wide('"') });
540
+ items.push({ symbol: 'strCmdExe', bytes: wide('\\cmd.exe" /s /c ""') });
541
+ items.push({ symbol: 'strQuoteSpace', bytes: wide('" ') });
542
+ items.push({ symbol: 'strLogLeaf', bytes: wide(input.logLeaf) });
543
+ for (const [name, capacity] of Object.entries(BUFFERS)) {
544
+ items.push({ symbol: name, bytes: new Uint8Array(capacity * 2) });
545
+ }
546
+ return items;
547
+ }
548
+ function align(value, to) {
549
+ return Math.ceil(value / to) * to;
550
+ }
551
+ /**
552
+ * The GUI-subsystem launcher, as a complete PE32+ image.
553
+ *
554
+ * Deterministic: the same input always produces the same bytes, with no
555
+ * timestamp, no checksum and no build-host string anywhere in the file — which
556
+ * is what lets `tests/e2e/ship-windows` compare two runs and what keeps the
557
+ * artifact reproducible for anyone who repacks a stage (ADR 0024 § A2).
558
+ */
559
+ export function buildGuiLauncher(input) {
560
+ if (input.logLeaf === '' || /[\\/:*?"<>|]/.test(input.logLeaf)) {
561
+ throw new Error(`gjsify ship: "${input.logLeaf}" cannot be the launcher's log file name — it is empty or holds a ` +
562
+ 'character Windows refuses in one. The stub writes it into `%TEMP%`, so the value is a leaf ' +
563
+ 'and never a path.');
564
+ }
565
+ const code = emitStub();
566
+ const codeSize = code.bytes.length;
567
+ const textRva = SECTION_ALIGNMENT;
568
+ const dataRva = textRva + align(codeSize, SECTION_ALIGNMENT);
569
+ const items = dataItems(input);
570
+ const offsets = new Map();
571
+ let cursor = 0;
572
+ for (const item of items) {
573
+ offsets.set(item.symbol, cursor);
574
+ cursor += item.bytes.length;
575
+ }
576
+ const dataSize = cursor;
577
+ const rvaOf = (symbol) => {
578
+ const offset = offsets.get(symbol);
579
+ if (offset === undefined)
580
+ throw new Error(`gjsify ship: the launcher stub names no datum "${symbol}".`);
581
+ return dataRva + offset;
582
+ };
583
+ const data = Buffer.alloc(dataSize);
584
+ for (const item of items)
585
+ data.set(item.bytes, offsets.get(item.symbol) ?? 0);
586
+ // The import descriptor and both thunk arrays, filled now that every RVA is
587
+ // known. The ILT and the IAT hold the SAME values before the loader runs; the
588
+ // loader overwrites the IAT with the resolved addresses and leaves the ILT,
589
+ // which is what lets a tool read the names back out of a bound image.
590
+ data.writeUInt32LE(rvaOf('ilt.GetModuleFileNameW'), offsets.get('importDescriptors') ?? 0);
591
+ data.writeUInt32LE(rvaOf('dllName'), (offsets.get('importDescriptors') ?? 0) + 12);
592
+ data.writeUInt32LE(rvaOf('iat.GetModuleFileNameW'), (offsets.get('importDescriptors') ?? 0) + 16);
593
+ for (const name of IMPORTS) {
594
+ const hint = BigInt(rvaOf(`hint.${name}`));
595
+ data.writeBigUInt64LE(hint, offsets.get(`ilt.${name}`) ?? 0);
596
+ data.writeBigUInt64LE(hint, offsets.get(`iat.${name}`) ?? 0);
597
+ }
598
+ const text = code.resolve(textRva, rvaOf);
599
+ const textRaw = align(codeSize, FILE_ALIGNMENT);
600
+ const dataRaw = align(dataSize, FILE_ALIGNMENT);
601
+ const headerSize = FILE_ALIGNMENT;
602
+ const textOffset = headerSize;
603
+ const dataOffset = textOffset + textRaw;
604
+ const image = Buffer.alloc(dataOffset + dataRaw);
605
+ // ── DOS header ───────────────────────────────────────────────────────────
606
+ image.write('MZ', 0, 'ascii');
607
+ image.write('This program cannot be run in DOS mode.\r\r\n$', 0x40, 'ascii');
608
+ image.writeUInt32LE(PE_OFFSET, 0x3c);
609
+ // ── COFF header ──────────────────────────────────────────────────────────
610
+ image.write('PE\0\0', PE_OFFSET, 'ascii');
611
+ const coff = PE_OFFSET + 4;
612
+ image.writeUInt16LE(MACHINE_AMD64, coff);
613
+ image.writeUInt16LE(2, coff + 2); // NumberOfSections
614
+ image.writeUInt32LE(0, coff + 4); // TimeDateStamp — zero, so the image is reproducible
615
+ image.writeUInt16LE(240, coff + 16); // SizeOfOptionalHeader (112 + 16 * 8)
616
+ // EXECUTABLE_IMAGE | LARGE_ADDRESS_AWARE | RELOCS_STRIPPED. The stub is
617
+ // position-independent (every reference is RIP-relative) and carries no
618
+ // `.reloc`, so it says so rather than letting the loader look for one.
619
+ image.writeUInt16LE(0x0002 | 0x0020 | 0x0001, coff + 18);
620
+ // ── optional header ──────────────────────────────────────────────────────
621
+ const opt = coff + 20;
622
+ image.writeUInt16LE(0x20b, opt); // PE32+
623
+ image.writeUInt8(14, opt + 2); // MajorLinkerVersion — cosmetic
624
+ image.writeUInt32LE(textRaw, opt + 4); // SizeOfCode
625
+ image.writeUInt32LE(dataRaw, opt + 8); // SizeOfInitializedData
626
+ image.writeUInt32LE(textRva, opt + 16); // AddressOfEntryPoint
627
+ image.writeUInt32LE(textRva, opt + 20); // BaseOfCode
628
+ image.writeBigUInt64LE(IMAGE_BASE, opt + 24);
629
+ image.writeUInt32LE(SECTION_ALIGNMENT, opt + 32);
630
+ image.writeUInt32LE(FILE_ALIGNMENT, opt + 36);
631
+ image.writeUInt16LE(6, opt + 40); // MajorOperatingSystemVersion
632
+ image.writeUInt16LE(6, opt + 48); // MajorSubsystemVersion
633
+ image.writeUInt32LE(dataRva + align(dataSize, SECTION_ALIGNMENT), opt + 56); // SizeOfImage
634
+ image.writeUInt32LE(headerSize, opt + 60); // SizeOfHeaders
635
+ image.writeUInt16LE(PE_SUBSYSTEM_GUI, opt + 68);
636
+ image.writeUInt16LE(0x0100 | 0x8000, opt + 70); // NX_COMPAT | TERMINAL_SERVER_AWARE
637
+ image.writeBigUInt64LE(0x100000n, opt + 72); // SizeOfStackReserve
638
+ image.writeBigUInt64LE(0x1000n, opt + 80); // SizeOfStackCommit
639
+ image.writeBigUInt64LE(0x100000n, opt + 88); // SizeOfHeapReserve
640
+ image.writeBigUInt64LE(0x1000n, opt + 96); // SizeOfHeapCommit
641
+ image.writeUInt32LE(16, opt + 108); // NumberOfRvaAndSizes
642
+ const directories = opt + 112;
643
+ image.writeUInt32LE(rvaOf('importDescriptors'), directories + 1 * 8); // IMPORT
644
+ image.writeUInt32LE(40, directories + 1 * 8 + 4);
645
+ image.writeUInt32LE(rvaOf('iat.GetModuleFileNameW'), directories + 12 * 8); // IAT
646
+ image.writeUInt32LE((IMPORTS.length + 1) * 8, directories + 12 * 8 + 4);
647
+ // ── section table ────────────────────────────────────────────────────────
648
+ const sections = directories + 16 * 8;
649
+ const writeSection = (index, name, rva, virtualSize, raw, rawSize, characteristics) => {
650
+ const at = sections + index * 40;
651
+ image.write(name.padEnd(8, '\0'), at, 'latin1');
652
+ image.writeUInt32LE(virtualSize, at + 8);
653
+ image.writeUInt32LE(rva, at + 12);
654
+ image.writeUInt32LE(rawSize, at + 16);
655
+ image.writeUInt32LE(raw, at + 20);
656
+ image.writeUInt32LE(characteristics, at + 36);
657
+ };
658
+ writeSection(0, '.text', textRva, codeSize, textOffset, textRaw, 0x60000020);
659
+ writeSection(1, '.data', dataRva, dataSize, dataOffset, dataRaw, 0xc0000040);
660
+ image.set(text, textOffset);
661
+ image.set(data, dataOffset);
662
+ return new Uint8Array(image);
663
+ }