process.spec.ts 8.3 KB

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