runtime-tree.ts 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. /** Relocatable, integrity-recorded production packages carried by one Desktop release. */
  2. import { createHash } from 'node:crypto'
  3. import { lstatSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
  4. import { isAbsolute, join, relative, sep } from 'node:path'
  5. import { valid } from 'semver'
  6. import { DESKTOP_HOST_PACKAGE, DESKTOP_HOST_RUNTIME_FILES } from './core-package-set.ts'
  7. import { parseDesktopRelease, type DesktopRelease } from './release.ts'
  8. /** Descriptor at the root of the immutable Desktop resource tree. */
  9. export const DESKTOP_RUNTIME_FILE = 'desktop-runtime.json'
  10. /** Host-owned package available to external plugins through a directory link. */
  11. export interface DesktopSharedPackage {
  12. readonly name: string
  13. readonly version: string
  14. readonly path: string
  15. }
  16. /** Final bytes and executable permissions of a runtime file. */
  17. export interface DesktopRuntimeFile {
  18. readonly path: string
  19. readonly bytes: number
  20. readonly sha256: string
  21. readonly executable: boolean
  22. }
  23. /** One signed application's production dependency tree. */
  24. export interface DesktopRuntimeDescriptor {
  25. readonly schemaVersion: 1
  26. readonly release: DesktopRelease
  27. readonly platform: NodeJS.Platform
  28. readonly arch: string
  29. readonly sharedPackages: readonly DesktopSharedPackage[]
  30. readonly files: readonly DesktopRuntimeFile[]
  31. }
  32. const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/[a-z0-9][a-z0-9._~-]*|[a-z0-9][a-z0-9._~-]*)$/u
  33. function record(value: unknown): value is Record<string, unknown> {
  34. return typeof value === 'object' && value !== null && !Array.isArray(value)
  35. }
  36. /**
  37. * Resolve one portable resource path without permitting traversal or absolute paths.
  38. * @param root - Runtime root.
  39. * @param path - Slash-separated relative path from durable metadata.
  40. * @returns Absolute resource path.
  41. */
  42. export function runtimePath(root: string, path: string): string {
  43. if (path === '' || isAbsolute(path) || path.includes('\\') || path.includes(':')
  44. || path.split('/').some(part => part === '' || part === '.' || part === '..')) {
  45. throw new Error(`desktop runtime: invalid relative path ${JSON.stringify(path)}`)
  46. }
  47. return join(root, ...path.split('/'))
  48. }
  49. /**
  50. * Inventory a materialized runtime without following links or including its descriptor.
  51. * @param root - Self-contained runtime directory.
  52. * @returns Sorted final-file inventory.
  53. */
  54. export function inventoryDesktopRuntime(root: string): DesktopRuntimeFile[] {
  55. const files: DesktopRuntimeFile[] = []
  56. const visit = (directory: string): void => {
  57. for (const entry of readdirSync(directory, { withFileTypes: true })) {
  58. const path = join(directory, entry.name)
  59. const name = relative(root, path).split(sep).join('/')
  60. if (name === DESKTOP_RUNTIME_FILE) continue
  61. if (entry.isDirectory()) visit(path)
  62. else if (entry.isFile()) {
  63. const body = readFileSync(path)
  64. files.push({ path: name, bytes: body.byteLength, sha256: createHash('sha256').update(body).digest('hex'),
  65. executable: (lstatSync(path).mode & 0o111) !== 0 })
  66. } else throw new Error(`desktop runtime: unsupported filesystem entry ${name}`)
  67. }
  68. }
  69. visit(root)
  70. return files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)
  71. }
  72. /**
  73. * Seal the final runtime tree after materialization and native signing.
  74. * @param root - Runtime output directory.
  75. * @param release - Matching shell, dsh, Host, and executable versions.
  76. * @param sharedNames - Release-owned packages supplied to plugins.
  77. * @param target - Platform and architecture selected by runtime preparation.
  78. * @returns Descriptor written beside the production packages.
  79. */
  80. export function writeDesktopRuntime(
  81. root: string, release: DesktopRelease, sharedNames: readonly string[],
  82. target: { platform: NodeJS.Platform; arch: string } = process,
  83. ): DesktopRuntimeDescriptor {
  84. const sharedPackages = [...new Set(sharedNames)].sort().map((name) => {
  85. if (!PACKAGE_NAME.test(name)) throw new Error(`desktop runtime: invalid shared package ${name}`)
  86. const path = `node_modules/${name}`
  87. const manifest = JSON.parse(readFileSync(join(runtimePath(root, path), 'package.json'), 'utf8')) as unknown
  88. if (!record(manifest) || manifest.name !== name || typeof manifest.version !== 'string' || valid(manifest.version) === null) {
  89. throw new Error(`desktop runtime: invalid shared package manifest ${name}`)
  90. }
  91. return { name, version: manifest.version, path }
  92. })
  93. const descriptor: DesktopRuntimeDescriptor = {
  94. schemaVersion: 1, release, platform: target.platform, arch: target.arch,
  95. sharedPackages, files: inventoryDesktopRuntime(root),
  96. }
  97. writeFileSync(join(root, DESKTOP_RUNTIME_FILE), `${JSON.stringify(descriptor, undefined, 2)}\n`)
  98. return descriptor
  99. }
  100. /**
  101. * Verify the runtime before linking any of its packages into writable state.
  102. * @param root - Current application's runtime resources.
  103. * @param electronVersion - Expected shell version.
  104. * @param target - Required execution target; defaults to the current process.
  105. * @returns Validated runtime descriptor.
  106. */
  107. export function verifyDesktopRuntime(
  108. root: string, electronVersion: string, target: { platform: NodeJS.Platform; arch: string } = process,
  109. ): DesktopRuntimeDescriptor {
  110. const value: unknown = JSON.parse(readFileSync(join(root, DESKTOP_RUNTIME_FILE), 'utf8'))
  111. if (!record(value) || value.schemaVersion !== 1 || value.platform !== target.platform || value.arch !== target.arch
  112. || !Array.isArray(value.sharedPackages) || !Array.isArray(value.files)) {
  113. throw new Error('desktop runtime: invalid descriptor or incompatible platform/architecture')
  114. }
  115. const release = parseDesktopRelease(value.release)
  116. if (release.version !== electronVersion) throw new Error(`desktop runtime: ${release.version} does not match Electron ${electronVersion}`)
  117. const sharedPackages = value.sharedPackages.map((entry: unknown): DesktopSharedPackage => {
  118. if (!record(entry) || typeof entry.name !== 'string' || !PACKAGE_NAME.test(entry.name)
  119. || typeof entry.version !== 'string' || valid(entry.version) === null || entry.path !== `node_modules/${entry.name}`) {
  120. throw new Error('desktop runtime: invalid shared package record')
  121. }
  122. return { name: entry.name, version: entry.version, path: entry.path }
  123. })
  124. if (new Set(sharedPackages.map(entry => entry.name)).size !== sharedPackages.length) {
  125. throw new Error('desktop runtime: duplicate shared package')
  126. }
  127. const files = value.files.map((entry: unknown): DesktopRuntimeFile => {
  128. if (!record(entry) || typeof entry.path !== 'string' || typeof entry.bytes !== 'number'
  129. || !Number.isSafeInteger(entry.bytes) || entry.bytes < 0 || typeof entry.executable !== 'boolean'
  130. || typeof entry.sha256 !== 'string' || !/^[a-f0-9]{64}$/u.test(entry.sha256)) {
  131. throw new Error('desktop runtime: invalid file inventory')
  132. }
  133. runtimePath(root, entry.path)
  134. return { path: entry.path, bytes: entry.bytes, sha256: entry.sha256, executable: entry.executable }
  135. }).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)
  136. const actual = inventoryDesktopRuntime(root)
  137. // Windows has no portable Unix executable permission bits.
  138. const comparable = (items: readonly DesktopRuntimeFile[]): unknown => process.platform === 'win32'
  139. ? items.map(({ executable: _executable, ...item }) => item) : items
  140. if (JSON.stringify(comparable(files)) !== JSON.stringify(comparable(actual))) {
  141. throw new Error('desktop runtime: integrity verification failed')
  142. }
  143. for (const entry of sharedPackages) {
  144. const manifest: unknown = JSON.parse(readFileSync(join(runtimePath(root, entry.path), 'package.json'), 'utf8'))
  145. if (!record(manifest) || manifest.name !== entry.name || manifest.version !== entry.version) {
  146. throw new Error(`desktop runtime: shared package metadata mismatch for ${entry.name}`)
  147. }
  148. }
  149. for (const name of ['@deepseek-ai/dsh', DESKTOP_HOST_PACKAGE]) {
  150. if (sharedPackages.find(entry => entry.name === name)?.version !== release.version) {
  151. throw new Error(`desktop runtime: missing or mismatched ${name}`)
  152. }
  153. }
  154. for (const file of DESKTOP_HOST_RUNTIME_FILES) {
  155. if (!files.some(entry => entry.path === `node_modules/${DESKTOP_HOST_PACKAGE}/${file}`)) {
  156. throw new Error(`desktop runtime: missing Host file ${file}`)
  157. }
  158. }
  159. return { schemaVersion: 1, release, platform: target.platform, arch: target.arch, sharedPackages, files }
  160. }
  161. /**
  162. * Identify exact runtime content independently of its installation path.
  163. * @param descriptor - Validated runtime metadata.
  164. * @returns SHA-256 runtime identity.
  165. */
  166. export function desktopRuntimeId(descriptor: DesktopRuntimeDescriptor): string {
  167. return createHash('sha256').update(JSON.stringify(descriptor)).digest('hex')
  168. }