installed-update-publication.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. /** Separate immutable qualification uploads from explicitly authorized fixed-feed publication. */
  2. import { mkdir, mkdtemp, readFile, readdir, rmdir, writeFile } from 'node:fs/promises'
  3. import { dirname, join, relative, resolve } from 'node:path'
  4. import { inspectInstalledUpdateJournals, readInstalledUpdateRun } from './installed-update-qualification.ts'
  5. import { planInstalledUpdateDistribution } from './installed-update-distribution.ts'
  6. import { installedUpdateFileHash } from './installed-update-signature.mjs'
  7. import { recordPackagingEvent } from './packaging-run.mjs'
  8. /** One fully read object; digest and byte count describe actual received bytes. */
  9. export interface InstalledUpdateRemoteObject {
  10. readonly sha512: string
  11. readonly size: number
  12. }
  13. /** Transport-owned authentication is never included in publication records. */
  14. export interface InstalledUpdatePublicationStore {
  15. /** @returns True only after authoritative bucket configuration confirms versioning is disabled. */
  16. versioningDisabled(): Promise<boolean>
  17. /** @param key Exact run-owned object key. @returns Actual object digest, or null only for a confirmed missing object. */
  18. read(key: string): Promise<InstalledUpdateRemoteObject | null>
  19. /** @param url Exact public URL, without cache-busting parameters. @returns Actual public bytes, or confirmed absence. */
  20. publicRead(url: string): Promise<InstalledUpdateRemoteObject | null>
  21. /**
  22. * Write once without automatic retries; forbidOverwrite must reach the server for immutable objects.
  23. * @param key Exact run-owned destination.
  24. * @param object Local binary or retained feed, with declared integrity and overwrite policy.
  25. * @returns Public server receipt, never authorization headers or credentials.
  26. */
  27. put(key: string, object: {
  28. readonly source: { readonly path: string } | { readonly contents: string }
  29. readonly size: number
  30. readonly sha512: string
  31. readonly forbidOverwrite: boolean
  32. }): Promise<{ readonly requestId?: string }>
  33. }
  34. /** Selected operator action; upload never advertises either version to clients. */
  35. export type InstalledUpdatePublicationAction = 'upload-binaries' | 'publish-feed'
  36. /**
  37. * Revalidate a successful local package check against current bytes, without credentials or network access.
  38. * @param manifest Original test manifest.
  39. * @param version Exact selected version.
  40. * @param receipt That version's successful package-verification result.
  41. * @returns Current distribution plan bound to the retained verification evidence.
  42. */
  43. export async function verifiedInstalledUpdateDistribution(manifest: string, version: string, receipt: string) {
  44. const run = await readInstalledUpdateRun(manifest)
  45. const path = relative(join(run.root, version, 'verification'), resolve(receipt)).replaceAll('\\', '/')
  46. if (!/^check-[^/]+\/result\.json$/u.test(path)) throw new Error('installed update: matching package verification receipt is required')
  47. const result = JSON.parse(await readFile(receipt, 'utf8')) as {
  48. schemaVersion?: unknown
  49. runId?: unknown
  50. version?: unknown
  51. stage?: unknown
  52. passed?: unknown
  53. installerSignature?: { valid?: unknown; timestamped?: unknown; sha512?: unknown; updaterVerificationInvoked?: unknown }
  54. contents?: { appId?: unknown; version?: unknown }
  55. }
  56. const distribution = await planInstalledUpdateDistribution(manifest, version)
  57. const inputs = JSON.parse(await readFile(join(dirname(receipt), 'inputs.json'), 'utf8')) as {
  58. manifestSha512?: unknown
  59. distribution?: unknown
  60. }
  61. if (result.schemaVersion !== 1 || result.runId !== run.id || result.version !== version || result.stage !== 'complete' || result.passed !== true
  62. || result.contents?.appId !== run.appId || result.contents.version !== version
  63. || result.installerSignature?.valid !== true || result.installerSignature.timestamped !== true
  64. || result.installerSignature.updaterVerificationInvoked !== true
  65. || result.installerSignature.sha512 !== distribution.binaries[0]!.sha512
  66. || inputs.manifestSha512 !== await installedUpdateFileHash(manifest)
  67. || JSON.stringify(inputs.distribution) !== JSON.stringify(distribution)) {
  68. throw new Error('installed update: current files do not match successful package verification')
  69. }
  70. return { run, distribution, receiptSha512: await installedUpdateFileHash(receipt) }
  71. }
  72. function matches(actual: InstalledUpdateRemoteObject | null, expected: InstalledUpdateRemoteObject): boolean {
  73. return actual?.sha512 === expected.sha512 && actual.size === expected.size
  74. }
  75. async function verifiedUploadReceipt(prepared: Awaited<ReturnType<typeof verifiedInstalledUpdateDistribution>>) {
  76. const parent = join(prepared.run.root, 'publication-records')
  77. for (const entry of await readdir(parent, { withFileTypes: true })) {
  78. if (!entry.isDirectory() || !entry.name.startsWith('operation-')) continue
  79. const directory = join(parent, entry.name)
  80. if (!(await readdir(directory)).includes('result.json')) continue
  81. const path = join(directory, 'result.json')
  82. const result: unknown = JSON.parse(await readFile(path, 'utf8'))
  83. if (typeof result !== 'object' || result === null) continue
  84. const fields = result as Record<string, unknown>
  85. if (fields.schemaVersion !== 1 || fields.success !== true || fields.stage !== 'complete'
  86. || fields.action !== 'upload-binaries' || fields.runId !== prepared.run.id || fields.version !== prepared.distribution.version) continue
  87. const planPath = join(directory, 'plan.json')
  88. const plan: unknown = JSON.parse(await readFile(planPath, 'utf8'))
  89. if (JSON.stringify(plan) !== JSON.stringify(prepared)) continue
  90. return { path, sha512: await installedUpdateFileHash(path), planSha512: await installedUpdateFileHash(planPath) }
  91. }
  92. throw new Error('matching successful binary upload receipt is required')
  93. }
  94. async function startupEvidence(manifest: string, directory: string | undefined) {
  95. const run = await readInstalledUpdateRun(manifest)
  96. if (!directory || !resolve(directory).replaceAll('\\', '/').endsWith(`/dsh-update-qualification/${run.id}/journals`)) {
  97. throw new Error('installed update: original installed application journal directory is required before successor publication')
  98. }
  99. const report = await inspectInstalledUpdateJournals(directory, run.versions)
  100. const ready = report.milestones['original-workspace']
  101. if (!ready || Date.parse(ready.time) > Date.now()) throw new Error('installed update: original workspace startup is not recorded')
  102. return ready
  103. }
  104. /**
  105. * Execute one separately authorized upload or feed publication with local exclusion and retained evidence.
  106. * @param manifest Original qualification manifest.
  107. * @param version Version to upload or advertise.
  108. * @param receipt Matching successful package verification.
  109. * @param action Upload objects without a feed, or publish only after public object verification.
  110. * @param store Explicit transport, supplied only after operator authorization; writes must not retry.
  111. * @param journalDirectory Original installed-app journal directory, required for successor publication.
  112. * @returns Retained operation result path; any error stops subsequent writes and preserves partial evidence.
  113. */
  114. export async function executeInstalledUpdatePublication(
  115. manifest: string, version: string, receipt: string, action: InstalledUpdatePublicationAction,
  116. store: InstalledUpdatePublicationStore, journalDirectory?: string,
  117. ): Promise<string> {
  118. const prepared = await verifiedInstalledUpdateDistribution(manifest, version, receipt)
  119. const { run, distribution } = prepared
  120. const successor = action === 'publish-feed' && version === run.versions[1]
  121. const startup = successor ? await startupEvidence(manifest, journalDirectory) : undefined
  122. const lock = join(run.root, 'publication.lock')
  123. await mkdir(lock)
  124. let record: string | undefined
  125. const result: Record<string, unknown> = { schemaVersion: 1, runId: run.id, version, action, success: false, startup,
  126. startedAt: new Date().toISOString(), singlePublisherRequired: true }
  127. const stage = (name: string, data: object = {}): void => {
  128. result.stage = name
  129. recordPackagingEvent(record!, { type: 'publication-stage', stage: name, ...data })
  130. console.log(`INSTALLED_UPDATE_PUBLICATION_STAGE ${name}`)
  131. }
  132. try {
  133. const parent = join(run.root, 'publication-records')
  134. await mkdir(parent, { recursive: true })
  135. record = await mkdtemp(join(parent, 'operation-'))
  136. console.log(`INSTALLED_UPDATE_PUBLICATION_RECORD ${record}`)
  137. await writeFile(join(record, 'plan.json'), `${JSON.stringify(prepared, null, 2)}\n`, { flag: 'wx', flush: true })
  138. if (action === 'publish-feed') {
  139. stage('binary-upload-receipt')
  140. result.binaryUploadReceipt = await verifiedUploadReceipt(prepared)
  141. stage('binary-upload-receipt-verified', result.binaryUploadReceipt as object)
  142. } else {
  143. stage('bucket-versioning')
  144. if (!await store.versioningDisabled()) throw new Error('bucket versioning is not confirmed disabled')
  145. }
  146. for (const binary of action === 'upload-binaries' ? distribution.binaries : []) {
  147. stage('binary-origin-read', { key: binary.key })
  148. const existing = await store.read(binary.key)
  149. if (existing !== null && !matches(existing, binary)) throw new Error('existing binary differs')
  150. if (existing === null) {
  151. if (action !== 'upload-binaries') throw new Error('binary upload must precede feed publication')
  152. if (JSON.stringify(await verifiedInstalledUpdateDistribution(manifest, version, receipt)) !== JSON.stringify(prepared)) {
  153. throw new Error('local inputs changed')
  154. }
  155. stage('binary-put', { key: binary.key })
  156. const response = await store.put(binary.key, { source: { path: binary.path }, size: binary.size,
  157. sha512: binary.sha512, forbidOverwrite: true })
  158. stage('binary-put-response', { key: binary.key, ...response })
  159. }
  160. stage('binary-public-read', { key: binary.key })
  161. if (!matches(await store.publicRead(`${run.origin}/${binary.key}`), binary)) throw new Error('public binary differs')
  162. }
  163. if (action === 'publish-feed') {
  164. stage('feed-origin-read')
  165. const previous = await store.read(distribution.feed.key)
  166. const previousPlan = successor ? await planInstalledUpdateDistribution(manifest, run.versions[0]) : undefined
  167. const expectedPrevious = previousPlan === undefined ? null
  168. : { sha512: previousPlan.feed.sha512, size: Buffer.byteLength(previousPlan.feed.contents) }
  169. const alreadyPublished = matches(previous, { sha512: distribution.feed.sha512, size: Buffer.byteLength(distribution.feed.contents) })
  170. if (!alreadyPublished && (expectedPrevious === null ? previous !== null : !matches(previous, expectedPrevious))) {
  171. throw new Error('unexpected previous feed')
  172. }
  173. if (successor) {
  174. const evidence = await startupEvidence(manifest, journalDirectory)
  175. if (JSON.stringify(evidence) !== JSON.stringify(startup)) throw new Error('startup evidence changed')
  176. }
  177. if (JSON.stringify(await verifiedInstalledUpdateDistribution(manifest, version, receipt)) !== JSON.stringify(prepared)) {
  178. throw new Error('local inputs changed')
  179. }
  180. await writeFile(join(record, 'feed.yml'), distribution.feed.contents, { flag: 'wx', flush: true })
  181. result.alreadyPublished = alreadyPublished
  182. if (!alreadyPublished) {
  183. stage('feed-put', { key: distribution.feed.key, previous, startedAfterOriginal: successor })
  184. result.putResponse = await store.put(distribution.feed.key, { source: { contents: distribution.feed.contents },
  185. size: Buffer.byteLength(distribution.feed.contents), sha512: distribution.feed.sha512, forbidOverwrite: !successor })
  186. }
  187. stage('feed-public-read')
  188. if (!matches(await store.publicRead(distribution.feed.url), { sha512: distribution.feed.sha512,
  189. size: Buffer.byteLength(distribution.feed.contents) })) throw new Error('public feed differs')
  190. }
  191. stage('complete')
  192. result.success = true
  193. return join(record, 'result.json')
  194. } catch (error) {
  195. result.failure = 'operation stopped; inspect the retained stage and remote state before any further publication'
  196. if (typeof error === 'object' && error !== null && 'statusCode' in error) {
  197. const statusCode = error.statusCode
  198. if (typeof statusCode === 'number' && statusCode >= 100 && statusCode <= 599) {
  199. result.httpStatus = statusCode
  200. }
  201. }
  202. throw new Error(`installed update: publication stopped; record: ${record ?? 'not allocated'}`)
  203. } finally {
  204. try {
  205. if (record !== undefined) await writeFile(join(record, 'result.json'), `${JSON.stringify({ ...result,
  206. finishedAt: new Date().toISOString() }, null, 2)}\n`, { flag: 'wx', flush: true })
  207. } finally { await rmdir(lock) }
  208. }
  209. }