read-bytes.spec.ts 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146
  1. /** The `readBytes` endpoint: the byte window it cuts, its defaults and cap, and the gates it shares with `read`. */
  2. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  3. import { mkdir, writeFile } from 'node:fs/promises'
  4. import { join } from 'node:path'
  5. import { FsVersion } from '@deepseek-ai/dsh-fs'
  6. import { agent, failureOf, openWorkspace, signal, type Harness } from './harness.ts'
  7. let harness: Harness
  8. let workspace: string
  9. beforeEach(async () => {
  10. harness = await openWorkspace('dsh-workspace-files-read-bytes-')
  11. workspace = harness.workspace
  12. })
  13. afterEach(async () => {
  14. await harness.dispose()
  15. })
  16. const endpoint = (caps?: { maxBytes?: number }): ReturnType<Harness['endpoint']> => harness.endpoint(caps)
  17. /** 256 bytes, each equal to its offset: the window's content names its position. */
  18. const RAMP = Buffer.from(Array.from({ length: 256 }, (_, i) => i))
  19. const decode = (data: string): Buffer => Buffer.from(data, 'base64')
  20. describe('workspaceFiles.readBytes — the window', () => {
  21. it('returns the whole file as one window by default, with its absolute path, version, and size', async () => {
  22. await writeFile(join(workspace, 'ramp.bin'), RAMP)
  23. const result = await endpoint().readBytes(agent, 'ramp.bin', {}, signal())
  24. expect(decode(result.data).equals(RAMP)).toBe(true)
  25. expect(result).toMatchObject({ offset: 0, eof: true, bytes: 256 })
  26. expect(result.absolutePath.endsWith('ramp.bin')).toBe(true)
  27. expect(result.version.length).toBeGreaterThan(0)
  28. })
  29. it('cuts the requested window and reports that more follows', async () => {
  30. await writeFile(join(workspace, 'ramp.bin'), RAMP)
  31. const result = await endpoint().readBytes(agent, 'ramp.bin', { offset: 16, length: 8 }, signal())
  32. expect([...decode(result.data)]).toEqual([16, 17, 18, 19, 20, 21, 22, 23])
  33. expect(result).toMatchObject({ offset: 16, eof: false, bytes: 256 })
  34. })
  35. it('reads a window of a file far above the byte cap', async () => {
  36. await writeFile(join(workspace, 'huge.bin'), Buffer.alloc(200_000, 7))
  37. const result = await endpoint({ maxBytes: 1024 }).readBytes(agent, 'huge.bin', { offset: 199_000, length: 1024 }, signal())
  38. expect(decode(result.data)).toHaveLength(1000)
  39. expect(result).toMatchObject({ eof: true, bytes: 200_000 })
  40. })
  41. it('infers eof from a short window when the backend reports no size', async () => {
  42. await writeFile(join(workspace, 'ramp.bin'), RAMP)
  43. vi.spyOn(harness.ctx.fs, 'stat').mockResolvedValue({ version: FsVersion('v-sizeless'), type: 'file' })
  44. const full = await endpoint().readBytes(agent, 'ramp.bin', { offset: 0, length: 256 }, signal())
  45. expect(full.eof).toBe(false)
  46. const short = await endpoint().readBytes(agent, 'ramp.bin', { offset: 250, length: 10 }, signal())
  47. expect(short).toMatchObject({ eof: true })
  48. expect(short.bytes).toBeUndefined()
  49. })
  50. it('reports eof on the window that holds the last byte, whether or not the length is reached', async () => {
  51. await writeFile(join(workspace, 'ramp.bin'), RAMP)
  52. const exact = await endpoint().readBytes(agent, 'ramp.bin', { offset: 248, length: 8 }, signal())
  53. expect(exact.eof).toBe(true)
  54. expect(decode(exact.data)).toHaveLength(8)
  55. const short = await endpoint().readBytes(agent, 'ramp.bin', { offset: 250, length: 100 }, signal())
  56. expect(short.eof).toBe(true)
  57. expect([...decode(short.data)]).toEqual([250, 251, 252, 253, 254, 255])
  58. })
  59. it('returns an empty eof window for an offset at or past the end', async () => {
  60. await writeFile(join(workspace, 'ramp.bin'), RAMP)
  61. const result = await endpoint().readBytes(agent, 'ramp.bin', { offset: 300, length: 8 }, signal())
  62. expect(result).toMatchObject({ data: '', offset: 300, eof: true, bytes: 256 })
  63. })
  64. it('returns an empty eof window for an empty file', async () => {
  65. await writeFile(join(workspace, 'empty.bin'), Buffer.alloc(0))
  66. const result = await endpoint().readBytes(agent, 'empty.bin', {}, signal())
  67. expect(result).toMatchObject({ data: '', offset: 0, eof: true, bytes: 0 })
  68. })
  69. it('carries bytes a text read would refuse: NUL and invalid UTF-8 round-trip through base64', async () => {
  70. const raw = Buffer.from([0, 0xff, 0xfe, 0x80, 0x41, 0])
  71. await writeFile(join(workspace, 'blob.bin'), raw)
  72. const result = await endpoint().readBytes(agent, 'blob.bin', {}, signal())
  73. expect(decode(result.data).equals(raw)).toBe(true)
  74. })
  75. it('names the version a stat of the same file reports', async () => {
  76. await writeFile(join(workspace, 'ramp.bin'), RAMP)
  77. const stat = await endpoint().stat(agent, 'ramp.bin', signal())
  78. const result = await endpoint().readBytes(agent, 'ramp.bin', {}, signal())
  79. expect(result.version).toBe(stat.version)
  80. })
  81. })
  82. describe('workspaceFiles.readBytes — defaults and cap', () => {
  83. it('defaults the length to the configured byte cap', async () => {
  84. await writeFile(join(workspace, 'ramp.bin'), RAMP.subarray(0, 64))
  85. const result = await endpoint({ maxBytes: 64 }).readBytes(agent, 'ramp.bin', {}, signal())
  86. expect(decode(result.data)).toHaveLength(64)
  87. expect(result.eof).toBe(true)
  88. })
  89. it('refuses a window longer than the cap as too-large rather than shortening it', async () => {
  90. await writeFile(join(workspace, 'ramp.bin'), RAMP)
  91. const failure = await failureOf(endpoint({ maxBytes: 64 }).readBytes(agent, 'ramp.bin', { length: 65 }, signal()))
  92. expect(failure.code).toBe('workspace-file/too-large')
  93. expect(failure.details).toMatchObject({ limit: 64 })
  94. })
  95. it('accepts a window exactly at the cap', async () => {
  96. await writeFile(join(workspace, 'ramp.bin'), RAMP.subarray(0, 64))
  97. const result = await endpoint({ maxBytes: 64 }).readBytes(agent, 'ramp.bin', { length: 64 }, signal())
  98. expect(decode(result.data)).toHaveLength(64)
  99. })
  100. it('refuses a negative or fractional offset and a non-positive length as bad requests', async () => {
  101. await writeFile(join(workspace, 'ramp.bin'), RAMP)
  102. // Beyond-safe integers and a window whose end overflows are refused too: they cannot index a file.
  103. const ranges = [
  104. { offset: -1 }, { offset: 1.5 }, { length: 0 }, { length: 2.5 },
  105. { offset: 2 ** 53 }, { offset: Number.MAX_SAFE_INTEGER, length: 2 },
  106. ]
  107. for (const range of ranges) {
  108. const failure = await failureOf(endpoint().readBytes(agent, 'ramp.bin', range, signal()))
  109. expect(failure.code).toBe('gateway/bad-request')
  110. }
  111. })
  112. })
  113. describe('workspaceFiles.readBytes — the gates it shares with read', () => {
  114. it('rejects a directory, a missing path, and an empty path', async () => {
  115. await mkdir(join(workspace, 'dir'))
  116. expect((await failureOf(endpoint().readBytes(agent, 'dir', {}, signal()))).code).toBe('workspace-file/not-regular-file')
  117. expect((await failureOf(endpoint().readBytes(agent, 'missing.bin', {}, signal()))).code).toBe('workspace-file/not-found')
  118. expect((await failureOf(endpoint().readBytes(agent, '', {}, signal()))).code).toBe('gateway/bad-request')
  119. })
  120. it('rejects an absolute path outside the workspace', async () => {
  121. await writeFile(join(harness.outside, 'secret.bin'), RAMP)
  122. const failure = await failureOf(endpoint().readBytes(agent, join(harness.outside, 'secret.bin'), {}, signal()))
  123. expect(failure.code).toBe('workspace-file/outside-workspace')
  124. })
  125. })