packaging-run.mjs 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. /** Persist redacted packaging evidence and terminate the owned stage tree on fatal signing failures. */
  2. import { spawn } from 'node:child_process'
  3. import { appendFileSync, existsSync, mkdirSync, mkdtempSync, realpathSync, writeFileSync } from 'node:fs'
  4. import { join, resolve } from 'node:path'
  5. import { StringDecoder } from 'node:string_decoder'
  6. const FATAL_NOTIFICATION = 'DSH_DESKTOP_PACKAGING_FATAL'
  7. /**
  8. * Append one credential-free event before its corresponding operation starts.
  9. * @param {string} directory Private run directory.
  10. * @param {object} event Whitelisted event fields; never pass command arguments or environments.
  11. * @returns {void}
  12. */
  13. export function recordPackagingEvent(directory, event) {
  14. appendFileSync(join(directory, 'events.jsonl'), `${JSON.stringify({ time: new Date().toISOString(), pid: process.pid, ...event })}\n`, { flush: true })
  15. }
  16. /**
  17. * Publish the fatal marker before rejecting a signing operation.
  18. * @param {string} directory Private run directory.
  19. * @param {string} reason Credential-free failure category.
  20. * @returns {void}
  21. */
  22. export function failPackagingRun(directory, reason) {
  23. try {
  24. writeFileSync(join(directory, 'fatal.json'), `${JSON.stringify({ time: new Date().toISOString(), pid: process.pid, reason })}\n`, { flush: true })
  25. } finally { process.stderr.write(`\n${FATAL_NOTIFICATION}\n`) }
  26. }
  27. /**
  28. * Redact complete secrets even when process output splits them across chunks.
  29. * @param {readonly string[]} secrets Exact inherited credential values.
  30. * @param {(text: string) => void} emit Redacted output sink.
  31. * @returns {{write: (chunk: Buffer) => void, end: () => void}} Bounded streaming redactor.
  32. */
  33. export function packagingOutputRedactor(secrets, emit) {
  34. const values = [...new Set(secrets.filter(Boolean))].sort((a, b) => b.length - a.length)
  35. const decoder = new StringDecoder('utf8')
  36. let buffer = ''
  37. function drain(final) {
  38. let output = ''
  39. while (buffer.length > 0) {
  40. const match = values.find(value => buffer.startsWith(value))
  41. if (match !== undefined) { output += '[REDACTED]'; buffer = buffer.slice(match.length); continue }
  42. if (!final && values.some(value => value.startsWith(buffer))) break
  43. output += buffer[0]
  44. buffer = buffer.slice(1)
  45. }
  46. if (output !== '') emit(output)
  47. }
  48. return {
  49. write(chunk) { buffer += decoder.write(chunk); drain(false) },
  50. end() { buffer += decoder.end(); drain(true) },
  51. }
  52. }
  53. /**
  54. * Allocate a run whose failures never become release completion records.
  55. * @param {string} root Parent for retained packaging records.
  56. * @param {object} metadata Public target/version metadata only.
  57. * @returns {{directory: string, run: (stage: string, executable: string, args: readonly string[], options: {cwd: string, env: NodeJS.ProcessEnv, timeoutMs?: number}) => Promise<void>, finish: (success: boolean) => void}} Owned run supervisor; an optional stage deadline records timeout independently of exit status and awaits termination.
  58. */
  59. export function createPackagingRun(root, metadata) {
  60. mkdirSync(root, { recursive: true })
  61. const directory = realpathSync(mkdtempSync(join(resolve(root), `${new Date().toISOString().replaceAll(':', '-')}-`)))
  62. for (const name of ['events.jsonl', 'stdout.log', 'stderr.log']) {
  63. writeFileSync(join(directory, name), '', { flag: 'wx', mode: 0o600 })
  64. }
  65. writeFileSync(join(directory, 'run.json'), `${JSON.stringify({ startedAt: new Date().toISOString(), pid: process.pid, ...metadata })}\n`, { flag: 'wx', flush: true })
  66. let failed = false
  67. let active = false
  68. const fatal = join(directory, 'fatal.json')
  69. async function run(stage, executable, args, options) {
  70. if (failed || existsSync(fatal)) throw new Error(`desktop package: run is blocked; see ${directory}`)
  71. if (active) throw new Error('desktop package: supervised stages must run sequentially')
  72. active = true
  73. let child
  74. let fatalObserved = false
  75. let launchError = false
  76. let outputError = false
  77. let termination
  78. let terminationCode
  79. let terminationError = false
  80. let stageClosed = false
  81. let timedOut = false
  82. let deadline
  83. let closed
  84. const safeEnvironment = Object.fromEntries(Object.entries(options.env).filter(([name]) => !/KEY|SECRET|TOKEN|PASSWORD|^NODE_OPTIONS$/iu.test(name)))
  85. function stop() {
  86. fatalObserved = true
  87. failed = true
  88. if (termination !== undefined || child?.pid === undefined || stageClosed) return
  89. if (process.platform === 'win32') {
  90. const killer = spawn(join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'taskkill.exe'), ['/PID', String(child.pid), '/T', '/F'], {
  91. env: safeEnvironment, windowsHide: true, stdio: 'ignore',
  92. })
  93. termination = new Promise(resolveTermination => {
  94. killer.once('error', () => { terminationError = true })
  95. killer.once('close', code => {
  96. terminationCode = code
  97. if (code !== 0) { terminationError = true; child.kill() }
  98. resolveTermination()
  99. })
  100. })
  101. } else {
  102. try { process.kill(-child.pid, 'SIGKILL') } catch (error) { if (error.code !== 'ESRCH') terminationError = true }
  103. termination = Promise.resolve()
  104. }
  105. }
  106. const interrupted = () => stop()
  107. process.once('SIGINT', interrupted)
  108. process.once('SIGTERM', interrupted)
  109. try {
  110. recordPackagingEvent(directory, { type: 'stage-start', stage })
  111. child = spawn(executable, [...args], { cwd: options.cwd, env: { ...options.env, DSH_DESKTOP_PACKAGING_RUN_DIR: directory },
  112. windowsHide: true, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'] })
  113. closed = new Promise(resolveClose => {
  114. child.once('error', () => { launchError = true })
  115. child.once('close', (code, signal) => { stageClosed = true; resolveClose({ code, signal }) })
  116. })
  117. recordPackagingEvent(directory, { type: 'stage-spawn', stage, childPid: child.pid })
  118. if (options.timeoutMs !== undefined) deadline = setTimeout(() => { timedOut = true; stop() }, options.timeoutMs)
  119. const secrets = Object.entries(options.env).filter(([name]) => /KEY|SECRET|TOKEN|PASSWORD/iu.test(name)).map(([, value]) => value ?? '')
  120. const streams = [['stdout', child.stdout, process.stdout], ['stderr', child.stderr, process.stderr]]
  121. for (const [name, stream, consoleStream] of streams) {
  122. let notification = ''
  123. const redactor = packagingOutputRedactor(secrets, text => {
  124. try {
  125. appendFileSync(join(directory, `${name}.log`), text, { flush: true })
  126. consoleStream.write(text)
  127. } catch { outputError = true; stop() }
  128. notification += text
  129. if (notification.includes(FATAL_NOTIFICATION)) stop()
  130. notification = notification.slice(-FATAL_NOTIFICATION.length)
  131. })
  132. stream.on('data', chunk => redactor.write(chunk))
  133. stream.once('end', () => redactor.end())
  134. stream.once('error', () => { outputError = true; stop() })
  135. }
  136. if (existsSync(fatal)) stop()
  137. const result = await closed
  138. await termination
  139. fatalObserved ||= existsSync(fatal)
  140. recordPackagingEvent(directory, { type: 'stage-end', stage, ...result, timedOut, fatalObserved, launchError, outputError, terminationCode, terminationError })
  141. if (result.code !== 0 || result.signal !== null || fatalObserved || launchError || outputError || terminationError) {
  142. failed = true
  143. throw new Error(`desktop package: ${stage} failed; evidence: ${directory}`)
  144. }
  145. } catch (error) {
  146. failed = true
  147. stop()
  148. await closed
  149. await termination
  150. throw error
  151. } finally {
  152. clearTimeout(deadline)
  153. process.removeListener('SIGINT', interrupted)
  154. process.removeListener('SIGTERM', interrupted)
  155. active = false
  156. }
  157. }
  158. return {
  159. directory,
  160. run,
  161. finish(success) {
  162. if (active) throw new Error('desktop package: cannot finish an active run')
  163. writeFileSync(join(directory, 'result.json'), `${JSON.stringify({ completedAt: new Date().toISOString(), success: success && !failed && !existsSync(fatal) })}\n`, { flag: 'wx', flush: true })
  164. },
  165. }
  166. }