| 1 | // Windows executes the real generated PowerShell and compiles its real INPUT |
| 2 | // structs. Every native import is replaced before execution with managed stubs: |
| 3 | // no test in this file can send keyboard/mouse input to the user's desktop. |
| 4 | import assert from "node:assert/strict"; |
| 5 | import { spawnSync } from "node:child_process"; |
| 6 | import path from "node:path"; |
| 7 | import { test } from "node:test"; |
| 8 | import win32 from "../src/backends/win32.mjs"; |
| 9 | |
| 10 | const WINDOWS = { skip: process.platform !== "win32" && "Requires Windows PowerShell; managed stubs only, never live OS input", timeout: 60_000 }; |
| 11 | const FRAME = "CU_MANAGED_FIXTURE:"; |
| 12 | const NATIVE_IMPORT = /\[DllImport\("user32\.dll"(?:,\s*SetLastError\s*=\s*true)?\)\]\s+(?:public|private)\s+static\s+extern\s+[^;]+;/gu; |
| 13 | const FORBIDDEN = /DllImport|LibraryImport|\bextern\b|user32\.dll|GetDelegateForFunctionPointer|NativeLibrary|SendKeys/iu; |
| 14 | |
| 15 | function decodedCommand(command, args) { |
| 16 | assert.equal(command, "powershell.exe"); |
| 17 | assert.deepEqual(args.slice(0, 3), ["-NoProfile", "-NonInteractive", "-EncodedCommand"]); |
| 18 | assert.equal(args.length, 4); |
| 19 | return Buffer.from(args[3], "base64").toString("utf16le"); |
| 20 | } |
| 21 | |
| 22 | function managedScript(original, { returns = [], mutate = (value) => value } = {}) { |
| 23 | assert.ok(returns.every((value) => Number.isInteger(value) && value >= 0 && value <= 0xffffffff)); |
| 24 | const stubs = { |
| 25 | SetCursorPos: "public static bool SetCursorPos(int X, int Y) { Calls.Add(new Call { kind = \"SetCursorPos\", x = X, y = Y }); return true; }", |
| 26 | mouse_event: "public static void mouse_event(uint dwFlags, int dx, int dy, uint dwData, UIntPtr dwExtraInfo) { Calls.Add(new Call { kind = \"mouse_event\", flags = dwFlags, x = dx, y = dy, data = dwData, extra = dwExtraInfo.ToUInt64() }); }", |
| 27 | GetCursorPos: "public static bool GetCursorPos(out POINT lpPoint) { lpPoint = new POINT(); Calls.Add(new Call { kind = \"GetCursorPos\" }); return true; }", |
| 28 | SetForegroundWindow: "public static bool SetForegroundWindow(IntPtr hWnd) { Calls.Add(new Call { kind = \"SetForegroundWindow\" }); return true; }", |
| 29 | SendInput: `private static uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize) { |
| 30 | uint inserted = nextReturn < ReturnPlan.Length ? ReturnPlan[nextReturn++] : nInputs; |
| 31 | var events = new KeyEvent[pInputs.Length]; |
| 32 | for (int i = 0; i < pInputs.Length; i++) { |
| 33 | events[i] = new KeyEvent { type = pInputs[i].type, vk = pInputs[i].u.ki.wVk, scan = pInputs[i].u.ki.wScan, flags = pInputs[i].u.ki.dwFlags }; |
| 34 | } |
| 35 | Calls.Add(new Call { kind = "SendInput", count = nInputs, size = cbSize, arrayType = pInputs.GetType().FullName, events = events, inserted = inserted }); |
| 36 | return inserted; |
| 37 | }`, |
| 38 | }; |
| 39 | const replaced = new Set(); |
| 40 | let script = original.replace(NATIVE_IMPORT, (declaration) => { |
| 41 | const name = declaration.match(/\b(\w+)\s*\(/gu)?.at(-1)?.replace(/\s*\($/u, ""); |
| 42 | assert.ok(Object.hasOwn(stubs, name), `Refusing unknown native declaration: ${name}`); |
| 43 | assert.equal(replaced.has(name), false, `Duplicate native declaration: ${name}`); |
| 44 | replaced.add(name); |
| 45 | return stubs[name].replace(/^(?:public|private)/u, declaration.match(/\]\s+(public|private)\b/u)[1]); |
| 46 | }); |
| 47 | assert.deepEqual([...replaced].sort(), Object.keys(stubs).sort(), "Every expected native import must be replaced"); |
| 48 | assert.match(script, /public static class User32 \{/u); |
| 49 | script = script.replace("public static class User32 {", `public static class User32 { |
| 50 | public sealed class KeyEvent { public uint type; public ushort vk; public ushort scan; public uint flags; } |
| 51 | public sealed class Call { |
| 52 | public string kind; public uint count; public int size; public string arrayType; public KeyEvent[] events; |
| 53 | public uint inserted; public uint flags; public uint data; public int x; public int y; public ulong extra; |
| 54 | } |
| 55 | public static readonly System.Collections.Generic.List<Call> Calls = new System.Collections.Generic.List<Call>(); |
| 56 | private static readonly uint[] ReturnPlan = new uint[] { ${returns.map((value) => `${value}u`).join(", ")} }; |
| 57 | private static int nextReturn; |
| 58 | `); |
| 59 | script = mutate(script); |
| 60 | assert.doesNotMatch(script, FORBIDDEN, "Refusing to execute a fixture containing a native import or alternate input sink"); |
| 61 | return script; |
| 62 | } |
| 63 | |
| 64 | function encodedFixture(script) { |
| 65 | const wrapped = `[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); |
| 66 | $fixtureOutput = New-Object 'System.Collections.Generic.List[string]'; $fixtureError = $null; |
| 67 | try { |
| 68 | & { |
| 69 | ${script} |
| 70 | } | ForEach-Object { [void]$fixtureOutput.Add([string]$_); }; |
| 71 | } catch { $fixtureError = $_.Exception.ToString(); } |
| 72 | $fixtureCalls = @(); $fixtureLayout = $null; |
| 73 | if ('User32' -as [type]) { |
| 74 | $fixtureCalls = @([User32]::Calls); |
| 75 | $fixtureLayout = @{ |
| 76 | pointerSize = [IntPtr]::Size; |
| 77 | inputSize = [Runtime.InteropServices.Marshal]::SizeOf([type]'User32+INPUT'); |
| 78 | mouseSize = [Runtime.InteropServices.Marshal]::SizeOf([type]'User32+MOUSEINPUT'); |
| 79 | keySize = [Runtime.InteropServices.Marshal]::SizeOf([type]'User32+KEYBDINPUT'); |
| 80 | unionOffset = [Runtime.InteropServices.Marshal]::OffsetOf([type]'User32+INPUT', 'u').ToInt32(); |
| 81 | keyExtraOffset = [Runtime.InteropServices.Marshal]::OffsetOf([type]'User32+KEYBDINPUT', 'dwExtraInfo').ToInt32(); |
| 82 | keyScanOffset = [Runtime.InteropServices.Marshal]::OffsetOf([type]'User32+KEYBDINPUT', 'wScan').ToInt32(); |
| 83 | keyFlagsOffset = [Runtime.InteropServices.Marshal]::OffsetOf([type]'User32+KEYBDINPUT', 'dwFlags').ToInt32(); |
| 84 | }; |
| 85 | } |
| 86 | Write-Output ('${FRAME}' + (@{ output = @($fixtureOutput.ToArray()); error = $fixtureError; calls = $fixtureCalls; layout = $fixtureLayout } | ConvertTo-Json -Depth 10 -Compress)); |
| 87 | if ($null -ne $fixtureError) { exit 86; } |
| 88 | `; |
| 89 | assert.doesNotMatch(wrapped, FORBIDDEN, "Final executable fixture must contain no native import"); |
| 90 | const encoded = Buffer.from(wrapped, "utf16le").toString("base64"); |
| 91 | assert.ok(encoded.length < 30_000, "Keep the encoded fixture under Windows' command-line limit"); |
| 92 | return encoded; |
| 93 | } |
| 94 | |
| 95 | function fixture({ returns, mutate } = {}) { |
| 96 | const invocations = []; |
| 97 | const backend = win32.create({ exec: { persistentInputOwner: true, run: async (command, args) => { |
| 98 | // This assertion is inside the only spawn path, not just in test options. |
| 99 | assert.equal(process.platform, "win32", "Real PowerShell is Windows-only"); |
| 100 | const encoded = encodedFixture(managedScript(decodedCommand(command, args), { returns, mutate })); |
| 101 | const executable = path.join(process.env.SystemRoot || "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe"); |
| 102 | const result = spawnSync(executable, ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded], { |
| 103 | encoding: "utf8", windowsHide: true, timeout: 25_000, maxBuffer: 4 * 1024 * 1024, |
| 104 | }); |
| 105 | assert.ifError(result.error); |
| 106 | assert.equal(result.signal, null); |
| 107 | const frames = result.stdout.split(/\r?\n/u).filter((line) => line.startsWith(FRAME)); |
| 108 | assert.equal(frames.length, 1, `Fixture must produce one trace, even on failure: ${result.stderr}`); |
| 109 | const trace = JSON.parse(frames[0].slice(FRAME.length)); |
| 110 | invocations.push({ ...trace, code: result.status }); |
| 111 | // Forward actual script output and actual process failure. Do not return a |
| 112 | // fabricated success JSON: that would conceal PowerShell conversion errors. |
| 113 | return { code: result.status, stdout: trace.output.join("\n"), stderr: trace.error || result.stderr }; |
| 114 | } } }); |
| 115 | return { backend, invocations }; |
| 116 | } |
| 117 | |
| 118 | async function sampleScript() { |
| 119 | let script; |
| 120 | await win32.create({ exec: { run: async (command, args) => { |
| 121 | script = decodedCommand(command, args); |
| 122 | return { code: 0, stdout: "", stderr: "" }; |
| 123 | } } }).type({ text: "A" }); |
| 124 | return script; |
| 125 | } |
| 126 | |
| 127 | function assertLayout(layout) { |
| 128 | assert.ok([4, 8].includes(layout.pointerSize)); |
| 129 | // Windows ABI expectations, independent of the structs being compiled. |
| 130 | const is64 = layout.pointerSize === 8; |
| 131 | assert.equal(layout.inputSize, is64 ? 40 : 28); |
| 132 | assert.equal(layout.mouseSize, is64 ? 32 : 24); |
| 133 | assert.equal(layout.keySize, is64 ? 24 : 16); |
| 134 | assert.equal(layout.unionOffset, is64 ? 8 : 4); |
| 135 | assert.equal(layout.keyExtraOffset, is64 ? 16 : 12); |
| 136 | assert.equal(layout.keyScanOffset, 2); |
| 137 | assert.equal(layout.keyFlagsOffset, 4); |
| 138 | } |
| 139 | |
| 140 | function onlyTrace(invocations) { |
| 141 | assert.equal(invocations.length, 1, "An input action must not replay its PowerShell script"); |
| 142 | return invocations[0]; |
| 143 | } |
| 144 | |
| 145 | function noSuccess(trace) { |
| 146 | assert.notEqual(trace.code, 0); |
| 147 | assert.ok(trace.error); |
| 148 | assert.equal(trace.output.some((line) => /"ok"\s*:\s*true/u.test(line)), false, "No success output after a compiler, conversion, or delivery error"); |
| 149 | } |
| 150 | |
| 151 | test("win32 native fixture: all generated imports become managed methods before execution", async () => { |
| 152 | const script = managedScript(await sampleScript()); |
| 153 | assert.doesNotMatch(script, FORBIDDEN); |
| 154 | assert.match(script, /public static void SendString\(string text\)/u); |
| 155 | assert.ok(encodedFixture(script).length < 30_000, "The actual executable envelope fits Windows' command-line limit"); |
| 156 | }); |
| 157 | |
| 158 | test("win32 native fixture: unknown and reintroduced imports fail closed", async () => { |
| 159 | const script = await sampleScript(); |
| 160 | for (const unsafe of [ |
| 161 | '[DllImport("kernel32.dll")] public static extern void Unsafe();', |
| 162 | '[DllImport("user32.dll")] public static extern void Unsafe();', |
| 163 | "public static extern void Unsafe();", |
| 164 | '[LibraryImport("user32.dll")] public static partial void Unsafe();', |
| 165 | ]) { |
| 166 | assert.throws(() => managedScript(`${script}\n${unsafe}`), /Refusing/); |
| 167 | assert.throws(() => managedScript(script, { mutate: (safe) => `${safe}\n${unsafe}` }), /Refusing/); |
| 168 | } |
| 169 | }); |
| 170 | |
| 171 | test("win32 native contract: typed chord arrays compile and obey INPUT ABI", WINDOWS, async () => { |
| 172 | const { backend, invocations } = fixture(); |
| 173 | assert.equal((await backend.key({ text: "ctrl+shift+a", repeat: 2 })).action_sent, true); |
| 174 | const trace = onlyTrace(invocations); |
| 175 | assertLayout(trace.layout); |
| 176 | assert.equal(trace.calls.length, 1); |
| 177 | const call = trace.calls[0]; |
| 178 | assert.equal(call.kind, "SendInput"); |
| 179 | assert.equal(call.arrayType, "User32+INPUT[]"); |
| 180 | assert.equal(call.count, 8); |
| 181 | assert.equal(call.size, trace.layout.pointerSize === 8 ? 40 : 28); |
| 182 | assert.deepEqual(call.events, [ |
| 183 | [17, 0], [16, 0], [65, 0], [65, 2], [65, 0], [65, 2], [16, 2], [17, 2], |
| 184 | ].map(([vk, flags]) => ({ type: 1, vk, scan: 0, flags }))); |
| 185 | }); |
| 186 | |
| 187 | test("win32 native contract: Unicode A, CJK and a surrogate pair use complete down/up pairs", WINDOWS, async () => { |
| 188 | const { backend, invocations } = fixture(); |
| 189 | const result = await backend.type({ text: "A漢😀" }); |
| 190 | assert.equal(result.action_sent, true); |
| 191 | assert.equal(result.chars, 4); |
| 192 | const trace = onlyTrace(invocations); |
| 193 | assertLayout(trace.layout); |
| 194 | assert.equal(trace.calls.length, 4); |
| 195 | for (const [index, scan] of [0x0041, 0x6f22, 0xd83d, 0xde00].entries()) { |
| 196 | const call = trace.calls[index]; |
| 197 | assert.equal(call.kind, "SendInput"); |
| 198 | assert.equal(call.count, 2); |
| 199 | assert.equal(call.inserted, 2); |
| 200 | assert.equal(call.size, trace.layout.pointerSize === 8 ? 40 : 28); |
| 201 | assert.deepEqual(call.events, [{ type: 1, vk: 0, scan, flags: 4 }, { type: 1, vk: 0, scan, flags: 6 }]); |
| 202 | } |
| 203 | }); |
| 204 | |
| 205 | test("win32 native contract: held chords bind SendKey and release in reverse order", WINDOWS, async () => { |
| 206 | const { backend, invocations } = fixture(); |
| 207 | assert.equal((await backend.hold_key({ text: "ctrl+a", duration: 0.05 })).action_sent, true); |
| 208 | const trace = onlyTrace(invocations); |
| 209 | assertLayout(trace.layout); |
| 210 | assert.deepEqual(trace.calls.map((call) => [call.kind, call.count, call.inserted]), Array(4).fill(["SendInput", 1, 1])); |
| 211 | assert.deepEqual(trace.calls.flatMap((call) => call.events), [ |
| 212 | [17, 0], [65, 0], [65, 2], [17, 2], |
| 213 | ].map(([vk, flags]) => ({ type: 1, vk, scan: 0, flags }))); |
| 214 | assert.ok(trace.calls.every((call) => call.arrayType === "User32+INPUT[]" && call.size === trace.layout.inputSize)); |
| 215 | }); |
| 216 | |
| 217 | test("win32 native contract: zero delivery rejects before later text or success", WINDOWS, async () => { |
| 218 | const { backend, invocations } = fixture({ returns: [0] }); |
| 219 | await assert.rejects(backend.type({ text: "AB" }), /SendInput inserted 0 of 2/u); |
| 220 | const trace = onlyTrace(invocations); |
| 221 | noSuccess(trace); |
| 222 | assert.deepEqual(trace.calls.map((call) => call.inserted), [0]); |
| 223 | assert.deepEqual(trace.calls[0].events.map((event) => event.scan), [65, 65]); |
| 224 | }); |
| 225 | |
| 226 | for (const cleanupResult of [1, 0]) { |
| 227 | test(`win32 native contract: partial Unicode delivery 1 -> ${cleanupResult} releases only its matching up`, WINDOWS, async () => { |
| 228 | const { backend, invocations } = fixture({ returns: [1, cleanupResult] }); |
| 229 | await assert.rejects(backend.type({ text: "AB" }), /SendInput inserted 1 of 2/u); |
| 230 | const trace = onlyTrace(invocations); |
| 231 | noSuccess(trace); |
| 232 | assert.deepEqual(trace.calls.map((call) => call.count), [2, 1]); |
| 233 | assert.deepEqual(trace.calls.map((call) => call.inserted), [1, cleanupResult]); |
| 234 | assert.deepEqual(trace.calls[0].events, [{ type: 1, vk: 0, scan: 65, flags: 4 }, { type: 1, vk: 0, scan: 65, flags: 6 }]); |
| 235 | assert.deepEqual(trace.calls[1].events, [{ type: 1, vk: 0, scan: 65, flags: 6 }]); |
| 236 | if (cleanupResult === 0) { |
| 237 | assert.match(trace.error, /key-up recovery failed/u); |
| 238 | assert.match(trace.error, /SendInput inserted 0 of 1/u); |
| 239 | } else assert.doesNotMatch(trace.error, /key-up recovery failed/u); |
| 240 | }); |
| 241 | } |
| 242 | |
| 243 | test("win32 native contract: C# compiler errors stop before script success", WINDOWS, async () => { |
| 244 | const { backend, invocations } = fixture({ mutate: (script) => script.replace("public static class User32 {", "public static class User32 { invalid C# declaration;") }); |
| 245 | await assert.rejects(backend.type({ text: "A" })); |
| 246 | const trace = onlyTrace(invocations); |
| 247 | noSuccess(trace); |
| 248 | assert.equal(trace.layout, null); |
| 249 | assert.deepEqual(trace.calls, []); |
| 250 | }); |
| 251 | |
| 252 | test("win32 native contract: PowerShell argument conversion errors stop before script success", WINDOWS, async () => { |
| 253 | const { backend, invocations } = fixture({ mutate: (script) => { |
| 254 | assert.match(script, /\[User32\]::SendString\(\$text\);/u); |
| 255 | return script.replace("[User32]::SendString($text);", "[User32]::SendKey('not-a-number', 0);"); |
| 256 | } }); |
| 257 | await assert.rejects(backend.type({ text: "A" })); |
| 258 | const trace = onlyTrace(invocations); |
| 259 | noSuccess(trace); |
| 260 | assertLayout(trace.layout); |
| 261 | assert.deepEqual(trace.calls, []); |
| 262 | }); |
| 263 | |
| 264 | for (const [direction, vertical, horizontal] of [ |
| 265 | ["down", 0xfffffe98, 0], ["up", 360, 0], ["left", 0, 0xfffffe98], ["right", 0, 360], |
| 266 | ]) { |
| 267 | test(`win32 native contract: ${direction} wheel binds the correct signed DWORD bits`, WINDOWS, async () => { |
| 268 | const { backend, invocations } = fixture(); |
| 269 | assert.equal((await backend.scroll({ target: { x: 12, y: 34 }, direction, amount: 3 })).action_sent, true); |
| 270 | const trace = onlyTrace(invocations); |
| 271 | assert.equal(trace.calls.length, 3); |
| 272 | assert.deepEqual([trace.calls[0].kind, trace.calls[0].x, trace.calls[0].y], ["SetCursorPos", 12, 34]); |
| 273 | assert.deepEqual(trace.calls.slice(1).map(({ kind, flags, data, extra }) => ({ kind, flags, data, extra })), [ |
| 274 | { kind: "mouse_event", flags: 0x0800, data: vertical, extra: 0 }, |
| 275 | { kind: "mouse_event", flags: 0x1000, data: horizontal, extra: 0 }, |
| 276 | ]); |
| 277 | }); |
| 278 | } |
| 279 |