media-references.host.spec.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. import { appendFile, mkdir, mkdtemp, open, realpath, rm, symlink, writeFile } from 'node:fs/promises'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  5. import { Context } from '@deepseek-ai/cordis'
  6. import { FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
  7. import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
  8. import { SessionMediaReferences } from '../src/media-references.ts'
  9. const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4])
  10. const DEFAULT_LIMIT = 20 * 1024 * 1024
  11. async function responseBytes(response: Response): Promise<Uint8Array> {
  12. return new Uint8Array(await response.arrayBuffer())
  13. }
  14. describe('SessionMediaReferences /api/file', () => {
  15. let root: string
  16. const contexts: Context[] = []
  17. beforeEach(async () => {
  18. root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-media-references-')))
  19. })
  20. afterEach(async () => {
  21. await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
  22. await rm(root, { recursive: true, force: true })
  23. })
  24. async function mount(maxBytes = DEFAULT_LIMIT) {
  25. const ctx = new Context()
  26. contexts.push(ctx)
  27. let handler: ((request: Request) => Promise<Response>) | undefined
  28. const unregister = vi.fn(() => {})
  29. ctx.provide('connection', {
  30. fetch: {
  31. register: (registered: { fetch: (request: Request) => Promise<Response> }) => {
  32. handler = registered.fetch
  33. return unregister
  34. },
  35. },
  36. } as never)
  37. ctx.provide('attachments', { imageLimits: { maxImageBytes: maxBytes } } as never)
  38. await ctx.plugin(LocalFileSystem, { cwd: root }).await()
  39. await ctx.plugin(SessionMediaReferences).await()
  40. const raw = (url: string, init?: RequestInit) => {
  41. if (handler === undefined) throw new Error('route not registered')
  42. return handler(new Request(url, init))
  43. }
  44. return {
  45. call: (path: string, init?: RequestInit) => raw(`http://127.0.0.1/api/file?path=${encodeURIComponent(path)}`, init),
  46. raw,
  47. fs: ctx.fs as LocalFileSystem,
  48. unregister,
  49. dispose: () => ctx.fiber.dispose(),
  50. }
  51. }
  52. it('serves the inclusive image cap and refuses larger images for GET, HEAD and Range', async () => {
  53. const route = await mount(PNG_BYTES.length)
  54. const path = join(root, 'bounded.png')
  55. await writeFile(path, PNG_BYTES)
  56. expect(await responseBytes(await route.call(path))).toEqual(PNG_BYTES)
  57. await appendFile(path, new Uint8Array(1))
  58. expect((await route.call(path)).status).toBe(413)
  59. expect((await route.call(path, { headers: { range: 'bytes=0-0' } })).status).toBe(413)
  60. const head = await route.call(path, { method: 'HEAD' })
  61. expect(head.status).toBe(413)
  62. expect(head.body).toBeNull()
  63. })
  64. it('rejects a sparse 1 GiB image before content I/O', async () => {
  65. const route = await mount()
  66. const inspect = vi.fn()
  67. route.fs.internals.inspectReadBytesAfterStat = inspect
  68. const path = join(root, 'huge.png')
  69. const handle = await open(path, 'w')
  70. try {
  71. await handle.truncate(1024 * 1024 * 1024)
  72. } finally {
  73. await handle.close()
  74. }
  75. expect((await route.call(path)).status).toBe(413)
  76. expect(inspect).not.toHaveBeenCalled()
  77. })
  78. it('uses the filesystem byte reader to reject post-stat image growth', async () => {
  79. const route = await mount(PNG_BYTES.length)
  80. const path = join(root, 'growing.png')
  81. await writeFile(path, PNG_BYTES)
  82. route.fs.internals.inspectReadBytesAfterStat = async () => {
  83. await appendFile(path, new Uint8Array(1))
  84. }
  85. expect((await route.call(path)).status).toBe(413)
  86. })
  87. it.each([
  88. ['png', 'image/png'], ['svg', 'image/svg+xml'], ['mp4', 'video/mp4'], ['mp3', 'audio/mpeg'],
  89. ['txt', 'text/plain'], ['html', 'text/html'], ['bin', 'application/octet-stream'], ['', 'application/octet-stream'],
  90. ])('serves .%s files with their MIME type and response protections', async (extension, mediaType) => {
  91. const route = await mount()
  92. const path = join(root, `file${extension === '' ? '' : `.${extension}`}`)
  93. await writeFile(path, PNG_BYTES)
  94. const response = await route.call(path)
  95. expect(response.status).toBe(200)
  96. expect(response.headers.get('content-type')).toBe(mediaType)
  97. expect(response.headers.get('content-length')).toBe(String(PNG_BYTES.length))
  98. expect(response.headers.get('cache-control')).toBe('private, no-store')
  99. expect(response.headers.get('x-content-type-options')).toBe('nosniff')
  100. expect(response.headers.get('content-security-policy')).toBe("sandbox; default-src 'none'")
  101. expect(await responseBytes(response)).toEqual(PNG_BYTES)
  102. })
  103. it.each(['mp4', 'mp3', 'bin'])('applies the attachment byte cap to .%s files', async (extension) => {
  104. const route = await mount(PNG_BYTES.length)
  105. const path = join(root, `file.${extension}`)
  106. await writeFile(path, PNG_BYTES)
  107. expect(await responseBytes(await route.call(path))).toEqual(PNG_BYTES)
  108. await appendFile(path, new Uint8Array(1))
  109. expect((await route.call(path)).status).toBe(413)
  110. expect((await route.call(path, { method: 'HEAD' })).status).toBe(413)
  111. })
  112. it('ignores Range headers and returns complete bodies without advertising ranges', async () => {
  113. const route = await mount()
  114. const path = join(root, 'clip.mp4')
  115. await writeFile(path, PNG_BYTES)
  116. for (const range of ['bytes=0-3', 'bytes=-4', 'bytes=999-', 'bytes=abc', 'items=0-0', 'bytes=0-1,3-4']) {
  117. const response = await route.call(path, { headers: { range } })
  118. expect(response.status).toBe(200)
  119. expect(response.headers.get('accept-ranges')).toBeNull()
  120. expect(response.headers.get('content-range')).toBeNull()
  121. expect(await responseBytes(response)).toEqual(PNG_BYTES)
  122. }
  123. })
  124. it('answers HEAD without reading content and reports missing and non-regular files', async () => {
  125. const route = await mount()
  126. const path = join(root, 'image.png')
  127. await writeFile(path, PNG_BYTES)
  128. const read = vi.spyOn(route.fs, 'readBytes')
  129. const response = await route.call(path, { method: 'HEAD', headers: { range: 'bytes=0-3' } })
  130. expect(response.status).toBe(200)
  131. expect(response.headers.get('content-length')).toBe(String(PNG_BYTES.length))
  132. expect(response.body).toBeNull()
  133. expect(read).not.toHaveBeenCalled()
  134. expect((await route.call(join(root, 'missing'), { method: 'HEAD' })).status).toBe(404)
  135. expect((await route.call(root, { method: 'HEAD' })).status).toBe(403)
  136. vi.spyOn(route.fs, 'stat').mockResolvedValue({ type: 'file', version: FsVersion('v1') })
  137. expect((await route.call(path, { method: 'HEAD' })).headers.get('content-length')).toBeNull()
  138. })
  139. it('rejects malformed paths, absent files, and directories', async () => {
  140. const route = await mount()
  141. expect((await route.raw('http://127.0.0.1/api/file')).status).toBe(400)
  142. for (const path of ['', 'relative.png', '/a\0b.png']) {
  143. expect((await route.call(path)).status).toBe(400)
  144. }
  145. const head = await route.call('', { method: 'HEAD' })
  146. expect(head.status).toBe(400)
  147. expect(head.body).toBeNull()
  148. expect((await route.call(join(root, 'missing.png'))).status).toBe(404)
  149. await mkdir(join(root, 'frames.png'))
  150. expect((await route.call(join(root, 'frames.png'))).status).toBe(403)
  151. })
  152. it('reads files and symlink targets outside the default cwd without a workspace registry', async () => {
  153. const route = await mount()
  154. const outside = await mkdtemp(join(tmpdir(), 'dsh-media-outside-'))
  155. try {
  156. const path = join(outside, 'image.png')
  157. await writeFile(path, PNG_BYTES)
  158. expect(await responseBytes(await route.call(path))).toEqual(PNG_BYTES)
  159. const link = join(root, 'linked.png')
  160. await symlink(path, link)
  161. expect(await responseBytes(await route.call(link))).toEqual(PNG_BYTES)
  162. } finally {
  163. await rm(outside, { recursive: true, force: true })
  164. }
  165. })
  166. it.skipIf(process.platform === 'win32')('rejects a FIFO before opening it', async () => {
  167. const route = await mount()
  168. const path = join(root, 'stream.png')
  169. const { execFile } = await import('node:child_process')
  170. const { promisify } = await import('node:util')
  171. await promisify(execFile)('mkfifo', [path])
  172. expect((await route.call(path)).status).toBe(403)
  173. })
  174. it('reads opaque remote targets through ctx.fs and preserves provider failures', async () => {
  175. const route = await mount()
  176. const target = { targetKey: FsTargetKey('opaque-remote-id'), displayPath: '/remote/photo.png' }
  177. vi.spyOn(route.fs, 'resolve').mockResolvedValue(target)
  178. const read = vi.spyOn(route.fs, 'readBytes').mockResolvedValue(PNG_BYTES)
  179. expect(await responseBytes(await route.call('/remote/photo.png'))).toEqual(PNG_BYTES)
  180. expect(read).toHaveBeenCalledWith(target, expect.any(AbortSignal), DEFAULT_LIMIT)
  181. for (const [code, status] of [
  182. ['FS_PERMISSION_DENIED', 403], ['FS_SANDBOX_DENIED', 403], ['FS_NOT_FOUND', 404],
  183. ['FS_NOT_REGULAR_FILE', 403], ['FS_TOO_LARGE', 413], ['FS_IO_ERROR', 500],
  184. ] as const) {
  185. read.mockRejectedValueOnce(new FsError('provider rejected read', code))
  186. expect((await route.call('/remote/photo.png')).status).toBe(status)
  187. }
  188. read.mockRejectedValueOnce(new Error('provider bug'))
  189. await expect(route.call('/remote/photo.png')).rejects.toThrow('provider bug')
  190. })
  191. it('serves an empty file and respects an aborted request', async () => {
  192. const route = await mount()
  193. const path = join(root, 'empty.png')
  194. await writeFile(path, '')
  195. const response = await route.call(path)
  196. expect(response.headers.get('content-length')).toBe('0')
  197. expect(await response.text()).toBe('')
  198. expect((await route.call(path, { signal: AbortSignal.abort() })).status).toBe(499)
  199. })
  200. it('unregisters the route on disposal', async () => {
  201. const route = await mount()
  202. await route.dispose()
  203. expect(route.unregister).toHaveBeenCalledTimes(1)
  204. })
  205. })