installed-update-cos.ts 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112
  1. /** Fixed test-COS transport; callers authorize writes separately from local planning. */
  2. import { createReadStream } from 'node:fs'
  3. import { createHash } from 'node:crypto'
  4. import { Readable, Writable } from 'node:stream'
  5. import { cosOperation } from './cos-operation.ts'
  6. import { createDesktopCos, DESKTOP_COS_REGION } from './desktop-cos.ts'
  7. import { loadDesktopPackageEnvironment } from './desktop-package-environment.mjs'
  8. import type { InstalledUpdatePublicationStore, InstalledUpdateRemoteObject } from './installed-update-publication.ts'
  9. const BUCKET = 'bj-toc-download-test-1320056602'
  10. const ORIGIN = 'https://download-test.deepseek.com'
  11. /** COS reports a missing key through this error code; no other status means absence. */
  12. function isMissingObject(error: unknown): boolean {
  13. return typeof error === 'object' && error !== null && 'code' in error && error.code === 'NoSuchKey'
  14. }
  15. async function hashStream(stream: AsyncIterable<Uint8Array>): Promise<InstalledUpdateRemoteObject> {
  16. const hash = createHash('sha512')
  17. let size = 0
  18. for await (const bytes of stream) { hash.update(bytes); size += bytes.length }
  19. return { sha512: hash.digest('base64'), size }
  20. }
  21. /**
  22. * Create a fixed test transport from .env.windows, passing only test upload credentials to the SDK.
  23. * Version queries have a 30-second total deadline; object reads and PUTs have 15 minutes.
  24. * Expiration aborts HTTP requests and waits for closure before releasing the publication operation.
  25. * @returns Store whose writes are streamed and therefore cannot be repeated by the SDK.
  26. */
  27. export function createInstalledUpdateCos(): InstalledUpdatePublicationStore {
  28. const environment = loadDesktopPackageEnvironment('win32')
  29. if (environment.DSH_DESKTOP_AUTO_UPDATE_ENV !== 'test' || environment.DOWNLOAD_TEST_ORIGIN !== ORIGIN
  30. || environment.DOWNLOAD_TEST_COS_BUCKET !== BUCKET || !environment.DOWNLOAD_TEST_COS_SECRET_ID?.trim()
  31. || !environment.DOWNLOAD_TEST_COS_SECRET_KEY?.trim()) throw new Error('installed update: complete test upload settings are required')
  32. const credentials = {
  33. secretId: environment.DOWNLOAD_TEST_COS_SECRET_ID,
  34. secretKey: environment.DOWNLOAD_TEST_COS_SECRET_KEY,
  35. }
  36. const client = () => createDesktopCos(credentials)
  37. const keyAllowed = (key: string): void => {
  38. if (!/^dsh-desk\/(?:bin|feeds)\/qualification\/[a-f0-9]{24}\/win-x64\/[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(key)) {
  39. throw new Error('installed update: COS key must stay in the Windows qualification namespace')
  40. }
  41. }
  42. return {
  43. async versioningDisabled() {
  44. const cos = client()
  45. const response = await cosOperation(cos, 30_000, () => cos.getBucketVersioning({ Bucket: BUCKET, Region: DESKTOP_COS_REGION }))
  46. const status: 'Enabled' | 'Suspended' | undefined = response.VersioningConfiguration.Status
  47. return response.statusCode === 200 && status === undefined
  48. },
  49. async read(key) {
  50. keyAllowed(key)
  51. const hash = createHash('sha512')
  52. let size = 0
  53. // Output keeps the object out of memory and makes the SDK wait for the write to finish.
  54. const output = new Writable({
  55. write(bytes: Buffer, _encoding, done) { hash.update(bytes); size += bytes.length; done() },
  56. })
  57. try {
  58. const cos = client()
  59. await cosOperation(cos, 900_000, () => cos.getObject({ Bucket: BUCKET, Region: DESKTOP_COS_REGION, Key: key, Output: output }))
  60. } catch (error) {
  61. if (isMissingObject(error)) return null
  62. throw error
  63. } finally { output.destroy() }
  64. return { sha512: hash.digest('base64'), size }
  65. },
  66. async publicRead(url) {
  67. const parsed = new URL(url)
  68. if (parsed.origin !== ORIGIN || parsed.search || parsed.hash) throw new Error('installed update: exact test public URL is required')
  69. keyAllowed(parsed.pathname.slice(1))
  70. const response = await fetch(url, { redirect: 'error', cache: 'no-store', signal: AbortSignal.timeout(900_000) })
  71. if (response.status === 404) { await response.body?.cancel(); return null }
  72. if (!response.ok || !response.body) { await response.body?.cancel(); throw new Error('installed update: public object read failed') }
  73. const reader = response.body.getReader()
  74. async function* bytes() {
  75. try {
  76. for (;;) { const next = await reader.read(); if (next.done) return; yield next.value }
  77. } finally { try { await reader.cancel() } finally { reader.releaseLock() } }
  78. }
  79. return hashStream(bytes())
  80. },
  81. async put(key, object) {
  82. keyAllowed(key)
  83. const md5 = createHash('md5')
  84. const sha512 = createHash('sha512')
  85. let size = 0
  86. const add = (bytes: Buffer): void => { md5.update(bytes); sha512.update(bytes); size += bytes.length }
  87. if ('path' in object.source) {
  88. for await (const bytes of createReadStream(object.source.path)) add(bytes as Buffer)
  89. } else add(Buffer.from(object.source.contents))
  90. if (size !== object.size || sha512.digest('base64') !== object.sha512) {
  91. throw new Error('installed update: upload input bytes changed')
  92. }
  93. const headers: Record<string, string> = { 'Content-MD5': md5.digest('base64') }
  94. if (object.forbidOverwrite) headers['x-cos-forbid-overwrite'] = 'true'
  95. const body = 'path' in object.source
  96. ? createReadStream(object.source.path)
  97. : Readable.from([Buffer.from(object.source.contents)])
  98. try {
  99. const cos = client()
  100. const response = await cosOperation(cos, 900_000, () => cos.putObject({
  101. Bucket: BUCKET, Region: DESKTOP_COS_REGION, Key: key, Body: body,
  102. ContentLength: object.size, ContentType: key.endsWith('.yml') ? 'application/yaml' : 'application/octet-stream',
  103. CacheControl: 'no-store', Headers: headers }))
  104. return response.RequestId === undefined ? {} : { requestId: response.RequestId }
  105. } finally { body.destroy() }
  106. },
  107. }
  108. }