Commit 1cc5981

Anton Golub <antongolub@antongolub.com>
2025-02-10 22:46:09
feat: protect `ProcessPromise` from inappropriate instantiation effects (#1097)
* feat: protect `ProcessPromise` from inappropriate instantiation effects * chore: minor imprs
1 parent 0c3313d
src/core.ts
@@ -145,6 +145,7 @@ export interface Shell<
     (opts: Partial<Omit<Options, 'sync'>>): Shell<true>
   }
 }
+const bound: [string, string, Options][] = []
 
 export const $: Shell & Options = new Proxy<Shell & Options>(
   function (pieces: TemplateStringsArray | Partial<Options>, ...args: any) {
@@ -164,25 +165,14 @@ export const $: Shell & Options = new Proxy<Shell & Options>(
     checkShell()
     checkQuote()
 
-    let resolve: Resolve, reject: Resolve
-    const process = new ProcessPromise((...args) => ([resolve, reject] = args))
     const cmd = buildCmd(
       $.quote as typeof quote,
       pieces as TemplateStringsArray,
       args
     ) as string
     const sync = snapshot[SYNC]
-
-    process._bind(
-      cmd,
-      from,
-      resolve!,
-      (v: ProcessOutput) => {
-        reject!(v)
-        if (sync) throw v
-      },
-      snapshot
-    )
+    bound.push([cmd, from, snapshot])
+    const process = new ProcessPromise(noop)
 
     if (!process.isHalted() || sync) process.run()
 
@@ -237,19 +227,26 @@ export class ProcessPromise extends Promise<ProcessOutput> {
   private _reject: Resolve = noop
   private _resolve: Resolve = noop
 
-  _bind(
-    cmd: string,
-    from: string,
-    resolve: Resolve,
-    reject: Resolve,
-    options: Options
-  ) {
-    this._command = cmd
-    this._from = from
-    this._resolve = resolve
-    this._reject = reject
-    this._snapshot = { ac: new AbortController(), ...options }
-    if (this._snapshot.halt) this._stage = 'halted'
+  constructor(executor: (resolve: Resolve, reject: Resolve) => void) {
+    let resolve: Resolve
+    let reject: Resolve
+    super((...args) => {
+      ;[resolve, reject] = args
+      executor?.(...args)
+    })
+
+    if (bound.length) {
+      const [cmd, from, snapshot] = bound.pop()!
+      this._command = cmd
+      this._from = from
+      this._resolve = resolve!
+      this._reject = (v: ProcessOutput) => {
+        reject!(v)
+        if (snapshot[SYNC]) throw v
+      }
+      this._snapshot = { ac: new AbortController(), ...snapshot }
+      if (this._snapshot.halt) this._stage = 'halted'
+    } else ProcessPromise.disarm(this)
   }
 
   run(): ProcessPromise {
@@ -653,6 +650,17 @@ export class ProcessPromise extends Promise<ProcessOutput> {
     this._stdin.removeListener(event, cb)
     return this
   }
+
+  // prettier-ignore
+  private static disarm(p: ProcessPromise, toggle = true): void {
+    Object.getOwnPropertyNames(ProcessPromise.prototype).forEach(k => {
+      if (k in Promise.prototype) return
+      if (!toggle) { Reflect.deleteProperty(p, k); return }
+      Object.defineProperty(p, k, { configurable: true, get() {
+        throw new Error('Inappropriate usage. Apply $ instead of direct instantiation.')
+      }})
+    })
+  }
 }
 
 type ProcessDto = {
test/core.test.js
@@ -456,12 +456,17 @@ describe('core', () => {
 
       it('all transitions', async () => {
         const { promise, resolve, reject } = Promise.withResolvers()
-        const p = new ProcessPromise(noop, noop)
+        const p = new ProcessPromise(noop)
+        ProcessPromise.disarm(p, false)
         assert.equal(p.stage, 'initial')
-        p._bind('echo foo', 'test', resolve, reject, {
-          ...defaults,
-          halt: true,
-        })
+
+        p._command = 'echo foo'
+        p._from = 'test'
+        p._resolve = resolve
+        p._reject = reject
+        p._snapshot = { ...defaults }
+        p._stage = 'halted'
+
         assert.equal(p.stage, 'halted')
         p.run()
         assert.equal(p.stage, 'running')
@@ -490,6 +495,13 @@ describe('core', () => {
       assert.ok(p5 !== p1)
     })
 
+    test('asserts self instantiation', async () => {
+      const p = new ProcessPromise(() => {})
+
+      assert(typeof p.then === 'function')
+      assert.throws(() => p.stage, /Inappropriate usage/)
+    })
+
     test('resolves with ProcessOutput', async () => {
       const o = await $`echo foo`
       assert.ok(o instanceof ProcessOutput)
.size-limit.json
@@ -9,7 +9,7 @@
   {
     "name": "zx/index",
     "path": "build/*.{js,cjs}",
-    "limit": "809 kB",
+    "limit": "809.1 kB",
     "brotli": false,
     "gzip": false
   },