fileTransfer.test.ts 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
  1. // @vitest-environment node
  2. import { afterEach, expect, it } from 'vitest'
  3. import { mkdtemp, readFile, writeFile, rm, readdir, open } from 'node:fs/promises'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import { randomUUID, createHash } from 'node:crypto'
  7. import { spawn } from 'node:child_process'
  8. import { FileTransferFiles } from '../../src/shared/fileTransferEndpoint.mjs'
  9. import { FILE_TRANSFER_CHUNK_BYTES, type FileTransferEndpoint } from '../../src/shared/fileTransfer'
  10. import { copyBetweenEndpoints } from '../../src/server/fileTransfer'
  11. import { VM_FILE_TRANSFER_SCRIPT } from '../../src/runner/worker/fileTransfer'
  12. const roots: string[] = [], files: FileTransferFiles[] = []
  13. afterEach(async () => { await Promise.all(files.splice(0).map(file => file.close())); await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) })
  14. async function root() { const value = await mkdtemp(join(tmpdir(), 'yaoyao-transfer-test-')); roots.push(value); return value }
  15. function nodeEndpoint(root: string, host: string, path: string): FileTransferEndpoint {
  16. const file = new FileTransferFiles(root); files.push(file)
  17. return { host, path, name: host, chunks: true, call: action => file.call(action) }
  18. }
  19. async function pythonCall(directory: string, namespace: string, action: Record<string, unknown>): Promise<any> {
  20. return new Promise((resolve, reject) => {
  21. const child = spawn('python3', ['-c', VM_FILE_TRANSFER_SCRIPT, namespace], { cwd: directory, env: { ...process.env, TMPDIR: directory } })
  22. let stdout = '', stderr = ''
  23. child.stdout.on('data', chunk => { stdout += chunk }); child.stderr.on('data', chunk => { stderr += chunk })
  24. child.on('error', reject); child.on('close', code => { if (code) reject(new Error(stderr)); else { try { resolve(JSON.parse(stdout)) } catch (error) { reject(error) } } })
  25. child.stdin.end(JSON.stringify(action))
  26. })
  27. }
  28. function vmEndpoint(root: string, path: string): FileTransferEndpoint {
  29. const namespace = createHash('sha256').update(randomUUID()).digest('hex')
  30. return { host: 'vm', name: 'vm', path, chunks: true, call: action => pythonCall(root, namespace, action) }
  31. }
  32. it.each([['mac1', 'server'], ['server', 'mac1'], ['mac1', 'mac2'], ['mac2', 'mac1'], ['mac1', 'vm'], ['vm', 'mac1']])('copies binary files from %s to %s without model-visible bytes', async (from, to) => {
  33. const source = await root(), target = await root(), bytes = Buffer.alloc(FILE_TRANSFER_CHUNK_BYTES + 17)
  34. for (let i = 0; i < bytes.length; i++) bytes[i] = i % 251
  35. await writeFile(join(source, 'source'), bytes)
  36. const sourceEndpoint = from === 'vm' ? vmEndpoint(source, 'source') : nodeEndpoint(source, from, 'source')
  37. const targetEndpoint = to === 'vm' ? vmEndpoint(target, 'target') : nodeEndpoint(target, to, 'target')
  38. const result = await copyBetweenEndpoints(sourceEndpoint, targetEndpoint, 1024 * 1024, false, () => {})
  39. expect(result).toMatchObject({ copied: true, size: bytes.length, source: { host: from }, target: { host: to } })
  40. expect((await readFile(join(target, 'target'))).equals(bytes)).toBe(true)
  41. expect((await readFile(join(source, 'source'))).equals(bytes)).toBe(true)
  42. expect(JSON.stringify(result).length).toBeLessThan(500)
  43. expect((await readdir(target)).some(name => name.startsWith('.yaoyao-transfer-'))).toBe(false)
  44. })
  45. it('streams exactly 100 MiB in 512 KiB chunks and rejects one byte over the configured limit', async () => {
  46. const source = await root(), target = await root(), limit = 100 * 1024 * 1024
  47. const handle = await open(join(source, 'source'), 'w'); await handle.truncate(limit); await handle.close()
  48. const a = nodeEndpoint(source, 'mac1', 'source'), b = nodeEndpoint(target, 'mac2', 'target')
  49. let chunks = 0; const send = b.call
  50. b.call = async action => { if (action.op === 'transfer-append') { chunks++; expect(Buffer.from(String(action.data), 'base64').length).toBeLessThanOrEqual(FILE_TRANSFER_CHUNK_BYTES) } return send(action) }
  51. expect(await copyBetweenEndpoints(a, b, limit, false, () => {})).toMatchObject({ size: limit })
  52. expect(chunks).toBe(200)
  53. const grow = await open(join(source, 'source'), 'r+'); await grow.truncate(limit + 1); await grow.close()
  54. await expect(copyBetweenEndpoints(a, { ...b, path: 'too-big' }, limit, false, () => {})).rejects.toThrow('上限')
  55. expect(await readdir(target)).toEqual(['target'])
  56. }, 30000)
  57. it.each([false, true])('copies empty files and enforces overwrite protection (VM=%s)', async vm => {
  58. const source = await root(), target = await root(); await writeFile(join(source, 'source'), '')
  59. const a = nodeEndpoint(source, 'mac1', 'source'), b = vm ? vmEndpoint(target, 'target') : nodeEndpoint(target, 'server', 'target')
  60. expect(await copyBetweenEndpoints(a, b, 1024 * 1024, false, () => {})).toMatchObject({ size: 0 })
  61. await writeFile(join(source, 'source'), 'new')
  62. await expect(copyBetweenEndpoints(a, b, 1024 * 1024, false, () => {})).rejects.toThrow()
  63. expect((await readFile(join(target, 'target'))).length).toBe(0)
  64. await copyBetweenEndpoints(a, b, 1024 * 1024, true, () => {})
  65. expect(await readFile(join(target, 'target'), 'utf8')).toBe('new')
  66. })
  67. it('queries the commit receipt after a lost response and never repeats the commit', async () => {
  68. const source = await root(), target = await root(); await writeFile(join(source, 'source'), 'content')
  69. const a = nodeEndpoint(source, 'mac1', 'source'), b = nodeEndpoint(target, 'server', 'target'), send = b.call
  70. const operations: unknown[] = []
  71. b.call = async action => { operations.push(action.op); const result = await send(action); if (action.op === 'transfer-finish') throw new Error('response lost'); return result }
  72. expect(await copyBetweenEndpoints(a, b, 1024 * 1024, false, () => {})).toMatchObject({ copied: true })
  73. expect(operations.filter(op => op === 'transfer-finish')).toHaveLength(1)
  74. expect(operations).toContain('transfer-status')
  75. })
  76. it('rejects corrupt chunks and removes temporary files without changing the destination', async () => {
  77. const source = await root(), target = await root(); await writeFile(join(source, 'source'), 'content')
  78. const a = nodeEndpoint(source, 'mac1', 'source'), b = nodeEndpoint(target, 'server', 'target'), send = a.call
  79. a.call = async action => { const result = await send(action); return action.op === 'transfer-read' ? { ...result, data: Buffer.from('corrupt').toString('base64') } : result }
  80. await expect(copyBetweenEndpoints(a, b, 1024 * 1024, false, () => {})).rejects.toThrow('校验')
  81. expect(await readdir(target)).toEqual([])
  82. })
  83. it('bounds legacy endpoints and explains the upgrade requirement above 10 MiB', async () => {
  84. const source = await root(), target = await root(); await writeFile(join(source, 'source'), Buffer.alloc(11 * 1024 * 1024))
  85. const a = nodeEndpoint(source, 'mac1', 'source'), b = { ...nodeEndpoint(target, 'old', 'target'), chunks: false }
  86. await expect(copyBetweenEndpoints(a, b, 25 * 1024 * 1024, false, () => {})).rejects.toMatchObject({ code: 'desktop_transfer_upgrade_required' })
  87. expect(await readdir(target)).toEqual([])
  88. })
  89. it('makes chunk replays idempotent and refuses conflicting data or paths outside the root', async () => {
  90. const directory = await root(), file = new FileTransferFiles(directory); files.push(file)
  91. const transferId = randomUUID(), bytes = Buffer.from('abc')
  92. await expect(file.call({ op: 'transfer-read-open', transferId, path: '../outside', maxBytes: 1024 * 1024 })).rejects.toThrow('路径')
  93. await file.call({ op: 'transfer-write-open', transferId, path: 'target', maxBytes: 1024 * 1024, size: 3, sha256: createHash('sha256').update(bytes).digest('hex'), overwrite: false })
  94. const append = { op: 'transfer-append', transferId, offset: 0, data: bytes.toString('base64') }
  95. await file.call(append); expect(await file.call(append)).toEqual({ received: 3 })
  96. await expect(file.call({ ...append, data: Buffer.from('bad').toString('base64') })).rejects.toThrow('不一致')
  97. await file.call({ op: 'transfer-finish', transferId })
  98. expect(await readFile(join(directory, 'target'), 'utf8')).toBe('abc')
  99. })
  100. it.each(['cancelled', 'revoked', 'disconnected'])('stops before the next chunk after %s and cleans both endpoints', async reason => {
  101. const source = await root(), target = await root(); await writeFile(join(source, 'source'), Buffer.alloc(2 * FILE_TRANSFER_CHUNK_BYTES))
  102. const a = nodeEndpoint(source, 'mac1', 'source'), b = nodeEndpoint(target, 'mac2', 'target'), send = b.call
  103. let active = true, chunks = 0
  104. b.call = async action => { const result = await send(action); if (action.op === 'transfer-append') { chunks++; active = false } return result }
  105. await expect(copyBetweenEndpoints(a, b, 1024 * 1024, false, () => { if (!active) throw new Error(reason) })).rejects.toThrow(reason)
  106. expect(chunks).toBe(1); expect(await readdir(target)).toEqual([])
  107. })
  108. it('keeps concurrent transfers isolated and refuses to replace a concurrently created destination', async () => {
  109. const source = await root(), target = await root(); await writeFile(join(source, 'source'), Buffer.alloc(FILE_TRANSFER_CHUNK_BYTES + 1, 7))
  110. const a = nodeEndpoint(source, 'mac1', 'source'), b = nodeEndpoint(target, 'mac2', 'target')
  111. const outcomes = await Promise.allSettled([copyBetweenEndpoints(a, b, 1024 * 1024, false, () => {}), copyBetweenEndpoints(a, b, 1024 * 1024, false, () => {})])
  112. expect(outcomes.filter(outcome => outcome.status === 'fulfilled')).toHaveLength(1)
  113. expect((await readFile(join(target, 'target'))).equals(await readFile(join(source, 'source')))).toBe(true)
  114. expect(await readdir(target)).toEqual(['target'])
  115. })