read.spec.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. /** The `read` endpoint: its four gates and the line window it cuts. */
  2. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  3. import { mkdir, rm, symlink, writeFile } from 'node:fs/promises'
  4. import { join } from 'node:path'
  5. import { FsError } from '@deepseek-ai/dsh-fs'
  6. import { failureOf, openWorkspace, signal, type Harness } from './harness.ts'
  7. let harness: Harness
  8. let workspace: string
  9. let outside: string
  10. beforeEach(async () => {
  11. harness = await openWorkspace('dsh-workspace-files-read-')
  12. workspace = harness.workspace
  13. outside = harness.outside
  14. })
  15. afterEach(async () => {
  16. await harness.dispose()
  17. })
  18. const endpoint = (caps?: { maxBytes?: number; maxLines?: number }): ReturnType<Harness['endpoint']> =>
  19. harness.endpoint(caps)
  20. /** Twenty lines, `line 1` through `line 20`, terminated by a final newline. */
  21. async function twentyLines(): Promise<void> {
  22. await writeFile(join(workspace, 'long.txt'), `${Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join('\n')}\n`, 'utf8')
  23. }
  24. /** A file whose second line carries a NUL byte past the backend's 8 KiB binary sample. */
  25. async function lateNul(): Promise<void> {
  26. await writeFile(join(workspace, 'late-nul.txt'), Buffer.concat([
  27. Buffer.from(`${'a'.repeat(9000)}\nb`, 'utf8'),
  28. Buffer.from([0]),
  29. Buffer.from('c\n', 'utf8'),
  30. ]))
  31. }
  32. describe('workspaceFiles.read — the happy path', () => {
  33. it('returns the whole file as one page with its absolute path, version, and byte size', async () => {
  34. await writeFile(join(workspace, 'notes.txt'), 'hello\nworld\n', 'utf8')
  35. const result = await endpoint().read(harness.scope, 'notes.txt', {}, signal())
  36. expect(result.text).toBe('hello\nworld')
  37. expect(result.offset).toBe(1)
  38. expect(result.lines).toBe(2)
  39. expect(result.eof).toBe(true)
  40. expect(result.bytes).toBe(12)
  41. expect(result.version.length).toBeGreaterThan(0)
  42. expect(result.absolutePath.endsWith('notes.txt')).toBe(true)
  43. })
  44. it('reads a nested path relative to the workspace root, not to any backend cwd', async () => {
  45. await mkdir(join(workspace, 'src', 'deep'), { recursive: true })
  46. await writeFile(join(workspace, 'src', 'deep', 'a.ts'), 'export {}\n', 'utf8')
  47. const result = await endpoint().read(harness.scope, 'src/deep/a.ts', {}, signal())
  48. expect(result.text).toBe('export {}')
  49. })
  50. it('returns an empty page for an empty file', async () => {
  51. await writeFile(join(workspace, 'empty.txt'), '', 'utf8')
  52. const result = await endpoint().read(harness.scope, 'empty.txt', {}, signal())
  53. expect(result).toMatchObject({ text: '', lines: 0, eof: true, bytes: 0 })
  54. })
  55. it('accepts multi-byte UTF-8 and counts the file bytes, not its characters', async () => {
  56. await writeFile(join(workspace, 'zh.txt'), '侧栏', 'utf8')
  57. const result = await endpoint().read(harness.scope, 'zh.txt', {}, signal())
  58. expect(result.text).toBe('侧栏')
  59. expect(result.bytes).toBe(6)
  60. })
  61. })
  62. describe('workspaceFiles.read — the line window', () => {
  63. it('cuts the requested lines and reports that more follow', async () => {
  64. await twentyLines()
  65. const result = await endpoint().read(harness.scope, 'long.txt', { offset: 6, limit: 3 }, signal())
  66. expect(result).toMatchObject({ offset: 6, text: 'line 6\nline 7\nline 8', lines: 3, eof: false })
  67. })
  68. it('reports eof on the page that holds the last line, whether or not the limit is reached', async () => {
  69. await twentyLines()
  70. const service = endpoint()
  71. const exact = await service.read(harness.scope, 'long.txt', { offset: 16, limit: 5 }, signal())
  72. expect(exact).toMatchObject({ text: 'line 16\nline 17\nline 18\nline 19\nline 20', lines: 5, eof: true })
  73. const beyond = await service.read(harness.scope, 'long.txt', { offset: 19, limit: 10 }, signal())
  74. expect(beyond).toMatchObject({ text: 'line 19\nline 20', lines: 2, eof: true })
  75. })
  76. it('treats a final newline as the last line terminator, not as an empty line after it', async () => {
  77. await writeFile(join(workspace, 'two.txt'), 'a\nb\n', 'utf8')
  78. await writeFile(join(workspace, 'three.txt'), 'a\nb\n\n', 'utf8')
  79. const service = endpoint()
  80. expect(await service.read(harness.scope, 'two.txt', { limit: 2 }, signal())).toMatchObject({ text: 'a\nb', lines: 2, eof: true })
  81. expect(await service.read(harness.scope, 'three.txt', { limit: 2 }, signal())).toMatchObject({ text: 'a\nb', lines: 2, eof: false })
  82. // The third line is empty, not absent: `lines` tells it from a page past the end.
  83. expect(await service.read(harness.scope, 'three.txt', { offset: 3 }, signal())).toMatchObject({ text: '', lines: 1, eof: true })
  84. })
  85. it('returns an empty eof page for an offset past the last line', async () => {
  86. await twentyLines()
  87. const result = await endpoint().read(harness.scope, 'long.txt', { offset: 21 }, signal())
  88. expect(result).toMatchObject({ offset: 21, text: '', lines: 0, eof: true })
  89. })
  90. it('defaults the limit to the configured page size', async () => {
  91. await twentyLines()
  92. const result = await endpoint({ maxLines: 5 }).read(harness.scope, 'long.txt', {}, signal())
  93. expect(result.text.split('\n')).toHaveLength(5)
  94. expect(result.eof).toBe(false)
  95. })
  96. it('refuses a limit above the configured page size and a non-positive-integer window', async () => {
  97. await twentyLines()
  98. const service = endpoint({ maxLines: 5 })
  99. for (const range of [{ limit: 6 }, { offset: 0 }, { limit: 1.5 }, { offset: -3 }]) {
  100. const failure = await failureOf(service.read(harness.scope, 'long.txt', range, signal()))
  101. expect(failure.code).toBe('gateway/bad-request')
  102. }
  103. })
  104. it('keeps carriage returns: the page is the file text, not a rendering of it', async () => {
  105. await writeFile(join(workspace, 'crlf.txt'), 'a\r\nb\r\n', 'utf8')
  106. const result = await endpoint().read(harness.scope, 'crlf.txt', {}, signal())
  107. expect(result.text).toBe('a\r\nb\r')
  108. })
  109. })
  110. describe('workspaceFiles.read — read access and file kinds', () => {
  111. it('reads an absolute path outside the workspace without mutating it', async () => {
  112. await writeFile(join(outside, 'notes.txt'), 'outside\nread only\n', 'utf8')
  113. const write = vi.spyOn(harness.ctx.fs, 'writeText')
  114. const edit = vi.spyOn(harness.ctx.fs, 'editText')
  115. const result = await endpoint().read(harness.scope, join(outside, 'notes.txt'), {}, signal())
  116. expect(result).toMatchObject({ text: 'outside\nread only', lines: 2, eof: true })
  117. expect(write).not.toHaveBeenCalled()
  118. expect(edit).not.toHaveBeenCalled()
  119. })
  120. it('resolves a relative file outside the workspace on the Host', async () => {
  121. await writeFile(join(outside, 'notes.txt'), 'outside', 'utf8')
  122. expect(await endpoint().read(harness.scope, '../outside/notes.txt', {}, signal())).toMatchObject({ text: 'outside', eof: true })
  123. })
  124. it('preserves a filesystem provider refusal for an outside file', async () => {
  125. await writeFile(join(outside, 'notes.txt'), 'outside', 'utf8')
  126. const refusal = new FsError('backend denied read', 'FS_SANDBOX_DENIED')
  127. vi.spyOn(harness.ctx.fs, 'streamText').mockRejectedValue(refusal)
  128. await expect(endpoint().read(harness.scope, join(outside, 'notes.txt'), {}, signal())).rejects.toBe(refusal)
  129. })
  130. it('rejects a symlink that points out of the workspace — the case a prefix test cannot see', async () => {
  131. await writeFile(join(outside, 'secret.txt'), 'no', 'utf8')
  132. // The path itself is inside the workspace and would pass any string
  133. // comparison; only lstat (before the follow) or realpath containment catches it.
  134. await symlink(join(outside, 'secret.txt'), join(workspace, 'link.txt'))
  135. const failure = await failureOf(endpoint().read(harness.scope, 'link.txt', {}, signal()))
  136. expect(failure.code).toBe('workspace-file/not-regular-file')
  137. expect(failure.details).toMatchObject({ kind: 'symlink' })
  138. })
  139. it('rejects a symlink even when it points back inside the workspace', async () => {
  140. await writeFile(join(workspace, 'real.txt'), 'fine', 'utf8')
  141. await symlink(join(workspace, 'real.txt'), join(workspace, 'alias.txt'))
  142. const failure = await failureOf(endpoint().read(harness.scope, 'alias.txt', {}, signal()))
  143. expect(failure.code).toBe('workspace-file/not-regular-file')
  144. })
  145. it('rejects a directory, which has no text to return', async () => {
  146. await mkdir(join(workspace, 'src'), { recursive: true })
  147. const failure = await failureOf(endpoint().read(harness.scope, 'src', {}, signal()))
  148. expect(failure.code).toBe('workspace-file/not-regular-file')
  149. expect(failure.details).toMatchObject({ kind: 'directory' })
  150. })
  151. it('reports a missing path as not found', async () => {
  152. const failure = await failureOf(endpoint().read(harness.scope, 'nope.txt', {}, signal()))
  153. expect(failure.code).toBe('workspace-file/not-found')
  154. })
  155. it('refuses an empty path as a bad request', async () => {
  156. const failure = await failureOf(endpoint().read(harness.scope, '', {}, signal()))
  157. expect(failure.code).toBe('gateway/bad-request')
  158. })
  159. })
  160. describe('workspaceFiles.read — gate 3: the page byte cap', () => {
  161. it('fails a page above the cap rather than returning it shortened', async () => {
  162. await writeFile(join(workspace, 'big.txt'), 'x'.repeat(4096), 'utf8')
  163. const failure = await failureOf(endpoint({ maxBytes: 1024 }).read(harness.scope, 'big.txt', {}, signal()))
  164. expect(failure.code).toBe('workspace-file/too-large')
  165. expect(failure.details).toMatchObject({ limit: 1024 })
  166. })
  167. it('accepts a page exactly at the cap, because the cap is inclusive', async () => {
  168. await writeFile(join(workspace, 'exact.txt'), `${'x'.repeat(31)}\n${'y'.repeat(32)}\n`, 'utf8')
  169. const result = await endpoint({ maxBytes: 64 }).read(harness.scope, 'exact.txt', {}, signal())
  170. expect(result.text).toHaveLength(64)
  171. })
  172. it('counts the newlines between the page lines against the cap', async () => {
  173. await writeFile(join(workspace, 'exact.txt'), `${'x'.repeat(31)}\n${'y'.repeat(32)}\n`, 'utf8')
  174. const failure = await failureOf(endpoint({ maxBytes: 63 }).read(harness.scope, 'exact.txt', {}, signal()))
  175. expect(failure.code).toBe('workspace-file/too-large')
  176. })
  177. it('caps the page, not the file: a small window of a file far above the cap reads', async () => {
  178. await writeFile(join(workspace, 'huge.txt'), Array.from({ length: 2000 }, (_, i) => `row ${i} ${'z'.repeat(100)}`).join('\n'), 'utf8')
  179. const result = await endpoint({ maxBytes: 1024 }).read(harness.scope, 'huge.txt', { offset: 1990, limit: 3 }, signal())
  180. expect(result.text.split('\n')).toHaveLength(3)
  181. expect(result.eof).toBe(false)
  182. expect(result.bytes).toBeGreaterThan(200_000)
  183. })
  184. })
  185. describe('workspaceFiles.read — gate 4: text only', () => {
  186. it('rejects bytes that are not valid UTF-8', async () => {
  187. await writeFile(join(workspace, 'bin.dat'), Buffer.from([0xff, 0xfe, 0xfd]))
  188. const failure = await failureOf(endpoint().read(harness.scope, 'bin.dat', {}, signal()))
  189. expect(failure.code).toBe('workspace-file/not-text')
  190. })
  191. it('rejects a page that carries NUL bytes, wherever in the file the page lies', async () => {
  192. await writeFile(join(workspace, 'nul.dat'), Buffer.from([0x61, 0x00, 0x62]))
  193. const service = endpoint()
  194. expect((await failureOf(service.read(harness.scope, 'nul.dat', {}, signal()))).code).toBe('workspace-file/not-text')
  195. // Past the backend's own binary sample, so only the page scan can see it.
  196. await lateNul()
  197. expect((await failureOf(service.read(harness.scope, 'late-nul.txt', { offset: 2 }, signal()))).code).toBe('workspace-file/not-text')
  198. })
  199. it('reads a page that ends before a NUL byte, because detection is per page', async () => {
  200. await lateNul()
  201. const result = await endpoint().read(harness.scope, 'late-nul.txt', { limit: 1 }, signal())
  202. expect(result.text).toHaveLength(9000)
  203. expect(result.eof).toBe(false)
  204. })
  205. })
  206. describe('workspaceFiles.read — the file changing under its gate', () => {
  207. /** Run `mutate` after the path gate has looked, so what follows sees a different filesystem. */
  208. function afterGate(mutate: () => Promise<void>): void {
  209. const fs = harness.ctx.fs
  210. const lstat = fs.lstat.bind(fs)
  211. vi.spyOn(fs, 'lstat').mockImplementation(async (path, opts, signal) => {
  212. const entry = await lstat(path, opts, signal)
  213. await mutate()
  214. return entry
  215. })
  216. }
  217. it('reports a file deleted after the gate as not found, not as an internal failure', async () => {
  218. await writeFile(join(workspace, 'fleeting.txt'), 'x', 'utf8')
  219. afterGate(() => rm(join(workspace, 'fleeting.txt')))
  220. const failure = await failureOf(endpoint().read(harness.scope, 'fleeting.txt', {}, signal()))
  221. expect(failure.code).toBe('workspace-file/not-found')
  222. })
  223. it('reports a file replaced by a directory after the gate as not a regular file', async () => {
  224. await writeFile(join(workspace, 'fleeting.txt'), 'x', 'utf8')
  225. afterGate(async () => {
  226. await rm(join(workspace, 'fleeting.txt'))
  227. await mkdir(join(workspace, 'fleeting.txt'))
  228. })
  229. const failure = await failureOf(endpoint().read(harness.scope, 'fleeting.txt', {}, signal()))
  230. expect(failure.code).toBe('workspace-file/not-regular-file')
  231. expect(failure.details).toMatchObject({ kind: 'directory' })
  232. })
  233. it('passes any other backend failure through unchanged', async () => {
  234. await writeFile(join(workspace, 'notes.txt'), 'x', 'utf8')
  235. vi.spyOn(harness.ctx.fs, 'streamText').mockRejectedValue(new FsError('disk unreadable', 'FS_IO_ERROR'))
  236. await expect(endpoint().read(harness.scope, 'notes.txt', {}, signal())).rejects.toMatchObject({ code: 'FS_IO_ERROR' })
  237. })
  238. })