host-files.mjs 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
  1. import {readdir,readFile,writeFile,mkdir,stat,realpath,open,link,rename,unlink} from 'node:fs/promises'
  2. import {createHash,randomUUID} from 'node:crypto'
  3. import {join,resolve} from 'node:path'
  4. import {homedir} from 'node:os'
  5. import {execFile} from 'node:child_process'
  6. import {hostPathInput,pathInside} from '../src/shared/hostPaths.mjs'
  7. import {windowsShell} from './windows-shell.mjs'
  8. export const HOST_SHELL=process.platform==='win32'?join(process.env.SystemRoot||'C:\\Windows','System32','WindowsPowerShell','v1.0','powershell.exe'):'/bin/zsh'
  9. export const FILE_READ_LIMIT=12*1024*1024
  10. export const FILE_WRITE_LIMIT=10*1024*1024
  11. export const FILE_LIST_LIMIT=500
  12. export const SHELL_OUTPUT_LIMIT=512*1024
  13. export const SHELL_TIMEOUT_MAX=120000
  14. /** Resolve a user-supplied path inside an allowed root, refusing escapes
  15. * through .., absolute outsiders and symlinks pointing out of the root. */
  16. export async function resolveWithin(root,input,kind='target'){
  17. const base=await realpath(root)
  18. const raw=hostPathInput(input)
  19. const target=resolve(base,raw)
  20. if(!pathInside(base,target))throw new Error('路径超出允许范围')
  21. let real=target
  22. try{real=await realpath(target)}catch(error){
  23. if(kind==='existing')throw new Error('文件或目录不存在')
  24. if(kind==='write'){
  25. let directory=join(target,'..')
  26. for(;;){
  27. try{
  28. const parent=await realpath(directory)
  29. if(!pathInside(base,parent))throw new Error('路径超出允许范围')
  30. break
  31. }catch(failure){
  32. if(failure.code!=='ENOENT')throw failure
  33. const next=join(directory,'..')
  34. if(directory===next)throw error
  35. directory=next
  36. }
  37. }
  38. return {base,target}
  39. }
  40. }
  41. if(!pathInside(base,real))throw new Error('路径超出允许范围')
  42. return {base,target}
  43. }
  44. export async function listHostFiles(root,input){
  45. const {target}=await resolveWithin(root,input,'existing')
  46. const info=await stat(target).catch(()=>{throw new Error('文件或目录不存在')})
  47. if(!info.isDirectory())throw new Error('不是目录')
  48. const entries=await readdir(target,{withFileTypes:true})
  49. const limited=entries.slice(0,FILE_LIST_LIMIT)
  50. const rows=await Promise.all(limited.map(async entry=>{
  51. const full=join(target,entry.name)
  52. const details=await stat(full).then(value=>({size:value.size,mtime:Math.round(value.mtimeMs)})).catch(()=>({size:0,mtime:0}))
  53. return {name:entry.name,type:entry.isDirectory()?'directory':entry.isSymbolicLink()?'symlink':'file',...details}
  54. }))
  55. return {path:String(input),entries:rows,truncated:entries.length>FILE_LIST_LIMIT}
  56. }
  57. export async function readHostFile(root,input){
  58. const {target}=await resolveWithin(root,input,'existing')
  59. const info=await stat(target).catch(()=>{throw new Error('文件不存在')})
  60. if(!info.isFile())throw new Error('不是文件')
  61. if(info.size>FILE_READ_LIMIT)throw new Error(`文件超过读取上限(${Math.round(FILE_READ_LIMIT/1024/1024)} MB)`)
  62. const bytes=await readFile(target)
  63. return {path:String(input),size:bytes.length,data:bytes.toString('base64')}
  64. }
  65. export async function writeHostFile(root,input){
  66. const bytes=Buffer.from(String(input?.data??''),'base64')
  67. if(!bytes.length)throw new Error('文件内容为空')
  68. if(bytes.length>FILE_WRITE_LIMIT)throw new Error(`内容超过写入上限(${Math.round(FILE_WRITE_LIMIT/1024/1024)} MB)`)
  69. if(bytes.toString('base64')!==String(input?.data??''))throw new Error('文件内容必须是 base64')
  70. const {target}=await resolveWithin(root,input?.path,'write')
  71. await mkdir(join(target,'..'),{recursive:true})
  72. await writeFile(target,bytes,{mode:0o600})
  73. return {path:String(input?.path),size:bytes.length}
  74. }
  75. /** Receive a brokered copy: verify bytes, stage beside the destination, then
  76. * commit atomically. An older client rejects the new op instead of clobbering. */
  77. export async function receiveHostFile(root,input){
  78. if(typeof input?.data!=='string'||input.data.length>14*1024*1024)throw new Error('传输文件内容无效或超过 10 MiB')
  79. if(typeof input?.overwrite!=='boolean')throw new Error('传输覆盖选项无效')
  80. const bytes=Buffer.from(input.data,'base64')
  81. if(bytes.length>FILE_WRITE_LIMIT||bytes.toString('base64')!==input.data)throw new Error('传输文件内容无效或超过 10 MiB')
  82. const sha256=createHash('sha256').update(bytes).digest('hex')
  83. if(input.sha256!==sha256)throw new Error('传输文件校验失败,未写入目标文件')
  84. const {target}=await resolveWithin(root,input.path,'write')
  85. await mkdir(join(target,'..'),{recursive:true})
  86. const temporary=join(target,'..','.yaoyao-transfer-'+randomUUID())
  87. const handle=await open(temporary,'wx+',0o600)
  88. try{
  89. await handle.writeFile(bytes);await handle.sync()
  90. const stored=Buffer.alloc(bytes.length);let offset=0
  91. while(offset<stored.length){const {bytesRead}=await handle.read(stored,offset,stored.length-offset,offset);if(!bytesRead)break;offset+=bytesRead}
  92. if(offset!==bytes.length||createHash('sha256').update(stored).digest('hex')!==sha256)throw new Error('目标文件写入校验失败')
  93. await resolveWithin(root,input.path,'write')
  94. if(input.overwrite)await rename(temporary,target)
  95. else await link(temporary,target).catch(error=>{if(error.code==='EEXIST')throw new Error('目标文件已存在,未覆盖;只有用户要求覆盖时才设置 overwrite=true');throw error})
  96. return {path:input.path,size:bytes.length,sha256}
  97. }finally{await handle.close();await unlink(temporary).catch(error=>{if(error.code!=='ENOENT')throw error})}
  98. }
  99. export async function execHostShell(root,input,{signal}={}){
  100. const command=String(input?.command??'')
  101. if(!command.trim())throw new Error('命令为空')
  102. const timeout=Math.min(Math.max(Number(input?.timeoutMs)||60000,1000),SHELL_TIMEOUT_MAX)
  103. let cwd
  104. try{cwd=resolve(String(root),String(input?.cwd??'').replace(process.platform==='win32'?/^~[\\/]/:/^~\//,'').replace(/^~$/,''))}catch{cwd=String(root)}
  105. if(process.platform==='win32')return windowsShell(HOST_SHELL,command,{cwd,timeout,signal,limit:SHELL_OUTPUT_LIMIT})
  106. return new Promise(done=>{
  107. execFile(HOST_SHELL,['-c',command],{cwd,timeout,signal,killSignal:'SIGKILL',maxBuffer:1024*1024,env:{...process.env,TERM:'dumb'}},(error,stdout,stderr)=>{
  108. const clip=value=>Buffer.from(value??'','utf8').subarray(0,SHELL_OUTPUT_LIMIT).toString('utf8')
  109. done({command,exitCode:error?typeof error.code==='number'?error.code:1:0,timedOut:error?.killed===true&&error?.signal==='SIGKILL',stdout:clip(stdout),stderr:clip(stderr)})
  110. })
  111. })
  112. }