installed-update-package-content.ts 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687
  1. /** Verify identity and update configuration from an extracted installer payload, not a neighboring unpacked build. */
  2. import { createHash } from 'node:crypto'
  3. import { readFile } from 'node:fs/promises'
  4. import { createRequire } from 'node:module'
  5. import { join } from 'node:path'
  6. import { load } from 'js-yaml'
  7. import { readAsar, type Node as AsarNode } from 'app-builder-lib/out/asar/asar.js'
  8. import { readInstalledUpdateRun } from './installed-update-qualification.ts'
  9. import { verifyInstalledUpdateApplication } from './prepare-installed-update-application.ts'
  10. import { verifyDesktopRuntime } from '../src/runtime-tree.ts'
  11. const require = createRequire(import.meta.url)
  12. function object(value: unknown): Record<string, unknown> {
  13. if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('installed update: invalid package metadata')
  14. return value as Record<string, unknown>
  15. }
  16. /**
  17. * Check the actual payload's ASAR, bundled runtime, private identity, feed, cache, and publisher metadata.
  18. * @param manifest Retained qualification manifest.
  19. * @param version Exact selected version.
  20. * @param payload Extracted installer payload in a private verification directory.
  21. * @param publisher Expected DN derived from the public release certificate.
  22. * @returns Content observations only; signatures, installation, and restart are separate checks.
  23. */
  24. export async function verifyInstalledUpdatePackageContent(manifest: string, version: string, payload: string, publisher: string) {
  25. const run = await readInstalledUpdateRun(manifest)
  26. if (!run.versions.includes(version)) throw new Error('installed update: payload version is outside the run')
  27. await verifyInstalledUpdateApplication(run.root)
  28. const archive = await readAsar(join(payload, 'resources/app.asar'))
  29. const metadata = object(await archive.readJson('package.json'))
  30. if (metadata.name !== `dsh-update-test-${run.id}` || metadata.version !== version
  31. || metadata.dshDesktopAppId !== run.appId || metadata.main !== 'qualification-bootstrap.mjs'
  32. || metadata.type !== 'module' || metadata.dshMandatoryUpdatePolicy !== undefined) {
  33. throw new Error('installed update: packaged application identity, version, entry, or policy differs')
  34. }
  35. const inventory = JSON.parse(await readFile(join(run.root, 'application/result.json'), 'utf8')) as {
  36. files: { path: string; sha256: string }[]
  37. }
  38. const expectedPaths = new Set(inventory.files.map(file => file.path))
  39. const inspect = (node: AsarNode, path = ''): void => {
  40. if (path === 'node_modules' || path === 'package.json') return
  41. if (node.link !== undefined || node.unpacked === true) throw new Error('installed update: application archive contains external entries')
  42. if (node.files !== undefined) {
  43. for (const [name, child] of Object.entries(node.files)) inspect(child, path === '' ? name : `${path}/${name}`)
  44. } else if (!expectedPaths.has(path)) throw new Error('installed update: application archive contains additional files')
  45. }
  46. inspect(archive.header)
  47. for (const file of inventory.files) {
  48. const path = file.path.split('/').join(process.platform === 'win32' ? '\\' : '/')
  49. const node = archive.getFile(path, false)
  50. if (node.link !== undefined || node.unpacked === true
  51. || createHash('sha256').update(await archive.readFile(path)).digest('hex') !== file.sha256) {
  52. throw new Error('installed update: packaged application file differs from frozen inputs')
  53. }
  54. }
  55. const dependencies = []
  56. for (const name of ['electron-updater', 'semver']) {
  57. const actual = object(await archive.readJson(join('node_modules', name, 'package.json')))
  58. const expected = object(JSON.parse(await readFile(require.resolve(`${name}/package.json`), 'utf8')))
  59. if (actual.name !== name || actual.version !== expected.version) throw new Error('installed update: packaged updater dependency differs from verification tools')
  60. dependencies.push({ name, version: actual.version })
  61. }
  62. const update = object(load(await readFile(join(payload, 'resources/app-update.yml'), 'utf8')))
  63. const url = `${run.origin}/${run.feedKey.slice(0, -'nightly.yml'.length)}`
  64. if (update.provider !== 'generic' || update.url !== url || update.channel !== 'nightly'
  65. || update.updaterCacheDirName !== `dsh-update-test-${run.id}-updater`
  66. || !Array.isArray(update.publisherName) || update.publisherName.length !== 1 || update.publisherName[0] !== publisher) {
  67. throw new Error('installed update: packaged feed, cache identity, channel, or publisher differs')
  68. }
  69. const runtime = await verifyDesktopRuntime(join(payload, 'resources/dsh'), version, { platform: 'win32', arch: 'x64' })
  70. const prepared = await verifyDesktopRuntime(join(run.root, version, 'dsh'), version, { platform: 'win32', arch: 'x64' })
  71. if (JSON.stringify(runtime.release) !== JSON.stringify(prepared.release)
  72. || JSON.stringify(runtime.sharedPackages) !== JSON.stringify(prepared.sharedPackages)
  73. || JSON.stringify(runtime.files.map(file => file.path)) !== JSON.stringify(prepared.files.map(file => file.path))) {
  74. throw new Error('installed update: packaged runtime does not describe the prepared release')
  75. }
  76. for (const [index, file] of runtime.files.entries()) {
  77. if (!file.path.endsWith('.exe') && (file.sha256 !== prepared.files[index]!.sha256 || file.bytes !== prepared.files[index]!.bytes)) {
  78. throw new Error('installed update: non-executable runtime bytes differ from prepared inputs')
  79. }
  80. }
  81. return { version, appId: run.appId, applicationFiles: inventory.files.length, dependencies, dependenciesFrozen: false,
  82. runtimeFiles: runtime.files.length, feedUrl: `${run.origin}/${run.feedKey}`, installed: false,
  83. resignedExecutables: runtime.files.filter(file => file.path.endsWith('.exe')).map(file => join(payload, 'resources/dsh', file.path)) }
  84. }