process.spec.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. import koffi from 'koffi'
  2. import { describe, expect, it, vi } from 'vitest'
  3. import {
  4. Win32Error,
  5. drainPipe,
  6. spawnInheritedJobProcess,
  7. spawnPipedProcess,
  8. } from '../src/index.ts'
  9. import { CREATE_SUSPENDED } from '../src/abi.ts'
  10. import { processInformationType, startupInfoType } from '../src/ffi.ts'
  11. import type { NativePtr, Win32ProcessBindings } from '../src/index.ts'
  12. const PVOID = koffi.pointer('void')
  13. function inheritedApi(overrides: Partial<Win32ProcessBindings> = {}): {
  14. api: Win32ProcessBindings
  15. events: string[]
  16. createProcessAsUserW: ReturnType<typeof vi.fn>
  17. assignProcessToJobObject: ReturnType<typeof vi.fn>
  18. resumeThread: ReturnType<typeof vi.fn>
  19. } {
  20. const events: string[] = []
  21. const createProcessAsUserWImpl: Win32ProcessBindings['createProcessAsUserW'] =
  22. overrides.createProcessAsUserW
  23. ?? ((_token, _app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => {
  24. expect(koffi.decode(_startup, startupInfoType())).toMatchObject({ dwFlags: 0x101, wShowWindow: 0 })
  25. events.push('create')
  26. koffi.encode(info, processInformationType(), {
  27. hProcess: 60n,
  28. hThread: 61n,
  29. dwProcessId: 1234,
  30. dwThreadId: 5678,
  31. })
  32. return 1
  33. })
  34. const createProcessAsUserW = vi.fn(createProcessAsUserWImpl)
  35. const assignProcessToJobObject = vi.fn(() => {
  36. events.push('assign')
  37. return 1
  38. })
  39. const resumeThread = vi.fn(() => {
  40. events.push('resume')
  41. return 1
  42. })
  43. const api = {
  44. createJobObjectW: vi.fn(() => 50n),
  45. setInformationJobObject: vi.fn(() => 1),
  46. getStdHandle: vi.fn((selector: number) => BigInt(100 - selector)),
  47. setHandleInformation: vi.fn((_handle: NativePtr, _mask: number, flags: number) => {
  48. events.push(flags === 0 ? 'restore' : 'inherit')
  49. return 1
  50. }),
  51. assignProcessToJobObject,
  52. resumeThread,
  53. terminateProcess: vi.fn(() => 1),
  54. closeHandle: vi.fn((handle: NativePtr) => { events.push(`close:${handle}`); return 1 }),
  55. getLastError: vi.fn(() => 5),
  56. formatMessageW: vi.fn(() => 0),
  57. ...overrides,
  58. createProcessAsUserW,
  59. } as unknown as Win32ProcessBindings
  60. return {
  61. api,
  62. events,
  63. createProcessAsUserW,
  64. assignProcessToJobObject,
  65. resumeThread,
  66. }
  67. }
  68. describe('spawnInheritedJobProcess', () => {
  69. const token = 70n as NativePtr
  70. it('inherits the control carrier into a restricted child before resume', () => {
  71. const descriptorHandle = vi.fn(() => 107n as NativePtr)
  72. const { api, events } = inheritedApi({ uvGetOsfhandle: descriptorHandle, getFileType: vi.fn(() => 3) })
  73. const child = spawnInheritedJobProcess(api, { command: 'node.exe', args: [], cwd: 'C:\\work', token, controlFileDescriptor: 7 })
  74. expect(child.pid).toBe(1234)
  75. expect(descriptorHandle).toHaveBeenCalledExactlyOnceWith(7)
  76. expect(events.filter(event => event === 'inherit')).toHaveLength(4)
  77. expect(events.indexOf('assign')).toBeLessThan(events.indexOf('resume'))
  78. })
  79. it('creates suspended, assigns the Job, then resumes the restricted child', () => {
  80. const {
  81. api,
  82. events,
  83. createProcessAsUserW,
  84. assignProcessToJobObject,
  85. resumeThread,
  86. } = inheritedApi()
  87. const child = spawnInheritedJobProcess(api, {
  88. command: 'cmd.exe',
  89. args: ['/c', 'exit', '0'],
  90. cwd: 'C:\\work',
  91. token,
  92. })
  93. expect(child).toEqual({ pid: 1234, process: 60n, job: 50n })
  94. expect(events.indexOf('create')).toBeLessThan(events.indexOf('assign'))
  95. expect(events.indexOf('assign')).toBeLessThan(events.indexOf('resume'))
  96. expect(assignProcessToJobObject).toHaveBeenCalledWith(50n, 60n)
  97. expect(resumeThread).toHaveBeenCalledWith(61n)
  98. expect(createProcessAsUserW).toHaveBeenCalledWith(
  99. token,
  100. null,
  101. 'cmd.exe /c exit 0',
  102. null,
  103. null,
  104. 1,
  105. CREATE_SUSPENDED,
  106. null,
  107. 'C:\\work',
  108. expect.anything(),
  109. expect.anything(),
  110. )
  111. })
  112. it('restores already-enabled stdio and closes the Job when inheritance setup fails', () => {
  113. let calls = 0
  114. const closeHandle = vi.fn((_handle: NativePtr) => 1)
  115. const setHandleInformation = vi.fn((_handle: NativePtr, _mask: number, flags: number) => {
  116. if (flags === 0) return 1
  117. calls += 1
  118. return calls === 2 ? 0 : 1
  119. })
  120. const { api } = inheritedApi({ closeHandle, setHandleInformation })
  121. expect(() => spawnInheritedJobProcess(api, {
  122. command: 'cmd.exe',
  123. args: [],
  124. cwd: 'C:\\work',
  125. token,
  126. })).toThrow(Win32Error)
  127. expect(setHandleInformation).toHaveBeenCalledWith(expect.anything(), 1, 0)
  128. expect(closeHandle).toHaveBeenCalledWith(50n)
  129. })
  130. it('captures a GetStdHandle error before Job cleanup changes last-error', () => {
  131. let lastError = 123
  132. const { api } = inheritedApi({
  133. getStdHandle: vi.fn(() => 0n as NativePtr),
  134. getLastError: vi.fn(() => lastError),
  135. closeHandle: vi.fn(() => { lastError = 999; return 1 }),
  136. })
  137. let caught: unknown
  138. try {
  139. spawnInheritedJobProcess(api, { command: 'cmd.exe', args: [], cwd: 'C:\\work', token })
  140. } catch (error) {
  141. caught = error
  142. }
  143. expect(caught).toMatchObject({ api: 'GetStdHandle', win32Code: 123 })
  144. })
  145. it('captures a CreateProcess error before inheritance restoration changes last-error', () => {
  146. let lastError = 87
  147. const { api } = inheritedApi({
  148. createProcessAsUserW: vi.fn(() => 0),
  149. getLastError: vi.fn(() => lastError),
  150. setHandleInformation: vi.fn((_handle, _mask, flags) => {
  151. if (flags === 0) lastError = 999
  152. return 1
  153. }),
  154. closeHandle: vi.fn(() => { lastError = 998; return 1 }),
  155. })
  156. let caught: unknown
  157. try {
  158. spawnInheritedJobProcess(api, { command: 'cmd.exe', args: [], cwd: 'C:\\work', token })
  159. } catch (error) {
  160. caught = error
  161. }
  162. expect(caught).toMatchObject({ api: 'CreateProcessAsUserW', win32Code: 87 })
  163. })
  164. it('terminates the suspended process and closes the Job when CreateProcessAsUserW returns a null thread handle', () => {
  165. const closeHandle = vi.fn((_handle: NativePtr) => 1)
  166. const terminateProcess = vi.fn(() => 1)
  167. const { api } = inheritedApi({
  168. closeHandle,
  169. terminateProcess,
  170. createProcessAsUserW: vi.fn((_token, _app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => {
  171. koffi.encode(info, processInformationType(), {
  172. hProcess: 60n,
  173. hThread: 0n,
  174. dwProcessId: 1234,
  175. dwThreadId: 0,
  176. })
  177. return 1
  178. }),
  179. })
  180. expect(() => spawnInheritedJobProcess(api, {
  181. command: 'cmd.exe',
  182. args: [],
  183. cwd: 'C:\\work',
  184. token,
  185. })).toThrow('null process/thread handles')
  186. expect(terminateProcess).toHaveBeenCalledWith(60n, 1)
  187. expect(closeHandle).toHaveBeenCalledWith(50n)
  188. expect(closeHandle).toHaveBeenCalledWith(60n)
  189. })
  190. })
  191. describe('wait and pipe cleanup', () => {
  192. const token = 70n as NativePtr
  193. it('waits when a pipe is temporarily empty before observing EOF', async () => {
  194. const closeHandle = vi.fn(() => 1)
  195. let peeks = 0
  196. const api = {
  197. peekNamedPipe: vi.fn((_handle, _buffer, _size, _read, available) => {
  198. peeks += 1
  199. if (peeks === 1) {
  200. koffi.encode(available, 'uint32', 0)
  201. return 1
  202. }
  203. return 0
  204. }),
  205. getLastError: vi.fn(() => 109),
  206. closeHandle,
  207. } as unknown as Win32ProcessBindings
  208. await expect(drainPipe(api, 80n as NativePtr)).resolves.toEqual(Buffer.alloc(0))
  209. expect(closeHandle).toHaveBeenCalledWith(80n)
  210. })
  211. it('terminates a piped child when CreateProcess returns a null thread handle', () => {
  212. let nextPipe = 10n
  213. const terminateProcess = vi.fn(() => 1)
  214. const closeHandle = vi.fn(() => 1)
  215. const api = {
  216. createPipe: vi.fn((readSlot, writeSlot) => {
  217. koffi.encode(readSlot, PVOID, nextPipe++)
  218. koffi.encode(writeSlot, PVOID, nextPipe++)
  219. return 1
  220. }),
  221. setHandleInformation: vi.fn(() => 1),
  222. createProcessAsUserW: vi.fn((_token, _app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => {
  223. koffi.encode(info, processInformationType(), {
  224. hProcess: 60n,
  225. hThread: 0n,
  226. dwProcessId: 1234,
  227. dwThreadId: 0,
  228. })
  229. return 1
  230. }),
  231. terminateProcess,
  232. closeHandle,
  233. } as unknown as Win32ProcessBindings
  234. expect(() => spawnPipedProcess(api, {
  235. command: 'cmd.exe',
  236. args: [],
  237. cwd: 'C:\\work',
  238. token,
  239. })).toThrow('null process/thread handles')
  240. expect(terminateProcess).toHaveBeenCalledWith(60n, 1)
  241. })
  242. })