host-files.mjs 6.1 KB

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