host-files.test.mjs 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899
  1. import {test} from 'node:test'
  2. import assert from 'node:assert/strict'
  3. import {mkdtemp,mkdir,writeFile,readFile,readdir,symlink,rm,realpath} from 'node:fs/promises'
  4. import {createHash} from 'node:crypto'
  5. import {tmpdir} from 'node:os'
  6. import {join} from 'node:path'
  7. import {listHostFiles,readHostFile,writeHostFile,receiveHostFile,execHostShell,resolveWithin} from './host-files.mjs'
  8. test('lists, reads and writes files inside the allowed root',async()=>{
  9. const root=await mkdtemp(join(tmpdir(),'yaoyao-host-files-'))
  10. try{
  11. await mkdir(join(root,'docs'))
  12. await writeFile(join(root,'docs','note.txt'),'你好,世界','utf8')
  13. const listing=await listHostFiles(root,'docs')
  14. assert.equal(listing.entries.length,1)
  15. assert.deepEqual(listing.entries[0],{name:'note.txt',type:'file',size:Buffer.byteLength('你好,世界'),mtime:listing.entries[0].mtime})
  16. const file=await readHostFile(root,'docs/note.txt')
  17. assert.equal(Buffer.from(file.data,'base64').toString('utf8'),'你好,世界')
  18. const written=await writeHostFile(root,{path:'docs/out/new.bin',data:Buffer.from([1,2,3]).toString('base64')})
  19. assert.equal(written.size,3)
  20. assert.deepEqual(Buffer.from(await readHostFile(root,'docs/out/new.bin').then(value=>Buffer.from(value.data,'base64'))),Buffer.from([1,2,3]))
  21. await assert.rejects(()=>listHostFiles(root,'missing'),/不存在/)
  22. await assert.rejects(()=>readHostFile(root,'docs'),/不是文件/)
  23. await assert.rejects(()=>writeHostFile(root,{path:'x.txt',data:'not base64!!'}),/base64/)
  24. }finally{await rm(root,{recursive:true,force:true})}
  25. })
  26. test('path escapes are refused for every operation',async()=>{
  27. const root=await mkdtemp(join(tmpdir(),'yaoyao-host-files-'))
  28. const outside=await mkdtemp(join(tmpdir(),'yaoyao-host-files-out-'))
  29. try{
  30. await writeFile(join(outside,'secret.txt'),'secret','utf8')
  31. await symlink(outside,join(root,'escape'),process.platform==='win32'?'junction':'dir')
  32. await writeFile(join(root,'keep.txt'),'keep','utf8')
  33. await assert.rejects(()=>resolveWithin(root,'../x'),/超出允许范围/)
  34. await assert.rejects(()=>resolveWithin(root,'/etc/passwd'),/超出允许范围/)
  35. await assert.rejects(()=>readHostFile(root,'escape/secret.txt'),/超出允许范围/)
  36. await assert.rejects(()=>listHostFiles(root,'escape'),/超出允许范围/)
  37. await assert.rejects(()=>writeHostFile(root,{path:'escape/evil.txt',data:Buffer.from('x').toString('base64')}),/超出允许范围/)
  38. await assert.rejects(()=>readHostFile(root,'keep.txt\u0000'),/路径无效/)
  39. assert.equal((await readHostFile(root,'keep.txt')).size,4)
  40. }finally{
  41. await rm(root,{recursive:true,force:true});await rm(outside,{recursive:true,force:true})
  42. }
  43. })
  44. test('receives verified binary and empty files atomically and preserves existing files unless overwrite was requested',async()=>{
  45. const source=await mkdtemp(join(tmpdir(),'yaoyao-copy-source-')),target=await mkdtemp(join(tmpdir(),'yaoyao-copy-target-'))
  46. try{
  47. await mkdir(join(source,'Desktop'))
  48. const bytes=Buffer.alloc(300_000);for(let i=0;i<bytes.length;i++)bytes[i]=i%256
  49. await writeFile(join(source,'Desktop','a.bin'),bytes)
  50. const file=await readHostFile(source,'Desktop/a.bin'),sha256=createHash('sha256').update(bytes).digest('hex')
  51. const input={path:'Desktop/a.bin',data:file.data,sha256,overwrite:false}
  52. assert.deepEqual(await receiveHostFile(target,input),{path:input.path,size:bytes.length,sha256})
  53. assert.deepEqual(await readFile(join(source,'Desktop','a.bin')),bytes)
  54. assert.deepEqual(await readFile(join(target,'Desktop','a.bin')),bytes)
  55. await assert.rejects(()=>receiveHostFile(target,{...input,sha256:'wrong'}),/校验失败/)
  56. const next=Buffer.from('replacement'),replacement={...input,data:next.toString('base64'),sha256:createHash('sha256').update(next).digest('hex')}
  57. await assert.rejects(()=>receiveHostFile(target,replacement),/已存在/)
  58. assert.deepEqual(await readFile(join(target,'Desktop','a.bin')),bytes)
  59. await receiveHostFile(target,{...replacement,overwrite:true})
  60. assert.deepEqual(await readFile(join(target,'Desktop','a.bin')),next)
  61. const empty={...input,path:'Desktop/empty',data:'',sha256:createHash('sha256').update('').digest('hex')}
  62. assert.equal((await receiveHostFile(target,empty)).size,0)
  63. assert.equal((await readFile(join(target,'Desktop','empty'))).length,0)
  64. await symlink(source,join(target,'escape'),process.platform==='win32'?'junction':'dir')
  65. await assert.rejects(()=>receiveHostFile(target,{...input,path:'escape/evil.bin'}),/超出允许范围/)
  66. assert.deepEqual((await readdir(join(target,'Desktop'))).sort(),['a.bin','empty'])
  67. }finally{await rm(source,{recursive:true,force:true});await rm(target,{recursive:true,force:true})}
  68. })
  69. test('shell execution reports stdout, exit codes, timeouts and cwd stays in root',async()=>{
  70. const root=await mkdtemp(join(tmpdir(),'yaoyao-host-files-'))
  71. try{
  72. await mkdir(join(root,'work'))
  73. const ok=await execHostShell(root,{command:process.platform==='win32'?'(Get-Location).Path':'pwd',cwd:'work'})
  74. assert.equal(ok.exitCode,0)
  75. assert.equal((await realpath(ok.stdout.trim())).toLowerCase(),(await realpath(join(root,'work'))).toLowerCase())
  76. const failing=await execHostShell(root,{command:process.platform==='win32'?"[Console]::Error.WriteLine('boom'); exit 7":'echo boom >&2; exit 7'})
  77. assert.equal(failing.exitCode,7);assert.equal(failing.stderr.trim(),'boom')
  78. const timed=await execHostShell(root,{command:process.platform==='win32'?'Start-Sleep -Seconds 5':'sleep 5',timeoutMs:1000})
  79. assert.equal(timed.timedOut,true);assert.notEqual(timed.exitCode,0)
  80. const escaped=await execHostShell(root,{command:process.platform==='win32'?'Set-Location $env:SystemRoot; (Get-Location).Path':'cd / && pwd'})
  81. assert.equal(escaped.exitCode,0)
  82. await assert.rejects(()=>execHostShell(root,{command:' '}),/命令为空/)
  83. }finally{await rm(root,{recursive:true,force:true})}
  84. })
  85. test('tilde home paths resolve inside the allowed root', async () => {
  86. const root = await mkdtemp(join(tmpdir(), 'yaoyao-host-files-'))
  87. await mkdir(join(root, 'Desktop'), { recursive: true })
  88. await writeFile(join(root, 'Desktop', 'a.txt'), 'x')
  89. const listing = await listHostFiles(root, '~/Desktop')
  90. assert.equal(listing.entries[0].name, 'a.txt')
  91. const home = await listHostFiles(root, '~')
  92. assert.ok(home.entries.some(e => e.name === 'Desktop'))
  93. await assert.rejects(() => listHostFiles(root, '/Desktop'), /超出允许范围|不存在/)
  94. })