artifact-update-server.mjs 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
  1. /** Loopback delivery of read-only executable inputs with correct hashes and optional transfer corruption. */
  2. import { createHash } from 'node:crypto'
  3. import { createReadStream } from 'node:fs'
  4. import { stat } from 'node:fs/promises'
  5. import { createServer } from 'node:http'
  6. import { once } from 'node:events'
  7. import { Readable, Transform } from 'node:stream'
  8. import { pipeline } from 'node:stream/promises'
  9. import { dump } from 'js-yaml'
  10. /**
  11. * Hash an executable without modifying or buffering the complete file.
  12. * @param {string} file Input file.
  13. * @returns {Promise<string>} Base64 SHA-512.
  14. */
  15. export async function artifactDigest(file) {
  16. const hash = createHash('sha512')
  17. for await (const chunk of createReadStream(file)) hash.update(chunk)
  18. return hash.digest('base64')
  19. }
  20. /**
  21. * Serve supplied executables through a synthetic Nightly feed on a private port.
  22. * @param {{ signed: string, unsigned: string, old?: string, signedBlockmap?: string, oldBlockmap?: string }} files Read-only input paths.
  23. * @returns {Promise<object>} Feed controls, request evidence, input hashes, and awaited close.
  24. */
  25. export async function createArtifactUpdateServer(files) {
  26. const artifacts = Object.fromEntries(await Promise.all(Object.entries(files).map(async ([name, file]) =>
  27. [name, { file, size: (await stat(file)).size, sha512: await artifactDigest(file) }])))
  28. const requests = []
  29. const failures = []
  30. const transfers = new Set()
  31. let selected = 'signed'
  32. let corrupt = false
  33. let fault
  34. let origin
  35. const server = createServer((request, response) => {
  36. const path = new URL(request.url, origin).pathname
  37. const artifact = artifacts[selected]
  38. const damage = corrupt
  39. const record = { path, artifact: selected, corrupt: damage, range: request.headers.range, bytes: 0 }
  40. requests.push(record)
  41. response.setHeader('Cache-Control', 'no-store')
  42. if (path === '/nightly.yml') {
  43. response.setHeader('Content-Type', 'application/yaml')
  44. response.end(dump({ version: '1.0.1-nightly.1', files: [{
  45. url: `${origin}/payload-1.0.1-nightly.1.exe`, size: artifact.size, sha512: artifact.sha512,
  46. }], path: 'payload-1.0.1-nightly.1.exe', sha512: artifact.sha512 }))
  47. return
  48. }
  49. const blockmap = path === '/payload-1.0.0.exe.blockmap' ? artifacts.oldBlockmap
  50. : path === '/payload-1.0.1-nightly.1.exe.blockmap' ? artifacts.signedBlockmap : undefined
  51. if (blockmap && !(fault === 'missing-old-blockmap' && blockmap === artifacts.oldBlockmap)) {
  52. response.writeHead(200, { 'Content-Length': blockmap.size })
  53. track(pipeline(createReadStream(blockmap.file), response))
  54. return
  55. }
  56. if (path !== '/payload-1.0.1-nightly.1.exe') { response.writeHead(404).end(); return }
  57. const range = request.headers.range
  58. if (range && fault === 'reject-ranges') { response.writeHead(416).end(); return }
  59. const ranges = range?.replace(/^bytes=/u, '').split(',').map(part => part.trim().split('-').map(Number))
  60. if (ranges?.some(([start, end]) => !Number.isSafeInteger(start) || !Number.isSafeInteger(end)
  61. || start < 0 || end < start || end >= artifact.size)) { response.writeHead(416).end(); return }
  62. record.bytes = ranges?.reduce((sum, [start, end]) => sum + end - start + 1, 0) ?? artifact.size
  63. const boundary = 'desktop-update-qualification'
  64. const headers = { 'Content-Type': 'application/octet-stream', 'Accept-Ranges': 'bytes' }
  65. if (!ranges) headers['Content-Length'] = artifact.size
  66. else if (ranges.length === 1) {
  67. headers['Content-Range'] = `bytes ${ranges[0][0]}-${ranges[0][1]}/${artifact.size}`
  68. headers['Content-Length'] = record.bytes
  69. }
  70. else headers['Content-Type'] = `multipart/byteranges; boundary=${boundary}`
  71. response.writeHead(ranges ? 206 : 200, headers)
  72. async function* body() {
  73. for (const [start, end] of ranges ?? [[0, artifact.size - 1]]) {
  74. if (ranges?.length > 1) yield Buffer.from(`--${boundary}\r\nContent-Type: application/octet-stream\r\nContent-Range: bytes ${start}-${end}/${artifact.size}\r\n\r\n`)
  75. yield* createReadStream(artifact.file, { start, end })
  76. if (ranges?.length > 1) yield Buffer.from('\r\n')
  77. }
  78. if (ranges?.length > 1) yield Buffer.from(`--${boundary}--\r\n`)
  79. }
  80. let first = true
  81. const transform = new Transform({ transform(chunk, encoding, callback) {
  82. if (damage && first) chunk[0] ^= 1
  83. first = false
  84. callback(null, chunk)
  85. } })
  86. track(pipeline(Readable.from(body()), transform, response))
  87. })
  88. function track(promise) {
  89. const transfer = promise
  90. .catch(error => { failures.push(String(error)) })
  91. .finally(() => transfers.delete(transfer))
  92. transfers.add(transfer)
  93. }
  94. const listening = once(server, 'listening')
  95. server.listen(0, '127.0.0.1')
  96. await listening
  97. origin = `http://127.0.0.1:${server.address().port}`
  98. return {
  99. url: `${origin}/`, requests, failures, artifacts,
  100. select(name, damage = false, failure) { selected = name; corrupt = damage; fault = failure },
  101. async close() {
  102. const closed = new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
  103. server.closeAllConnections()
  104. await Promise.all([closed, ...transfers])
  105. },
  106. }
  107. }