host-files.mjs 4.2 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889
  1. import {readdir,readFile,writeFile,mkdir,stat,realpath} from 'node:fs/promises'
  2. import {join,resolve,sep} from 'node:path'
  3. import {homedir} from 'node:os'
  4. import {execFile} from 'node:child_process'
  5. export const FILE_READ_LIMIT=12*1024*1024
  6. export const FILE_WRITE_LIMIT=10*1024*1024
  7. export const FILE_LIST_LIMIT=500
  8. export const SHELL_OUTPUT_LIMIT=512*1024
  9. export const SHELL_TIMEOUT_MAX=120000
  10. /** Resolve a user-supplied path inside an allowed root, refusing escapes
  11. * through .., absolute outsiders and symlinks pointing out of the root. */
  12. export async function resolveWithin(root,input,kind='target'){
  13. const base=await realpath(root)
  14. const raw=String(input??'').replace(/^~(?=\/|$)/,'')
  15. if(/\u0000/.test(raw))throw new Error('路径无效')
  16. const target=resolve(base,raw)
  17. let real=target
  18. try{real=await realpath(target)}catch(error){
  19. if(kind==='existing')throw new Error('文件或目录不存在')
  20. if(kind==='write'){
  21. let directory=join(target,'..')
  22. for(;;){
  23. try{
  24. const parent=await realpath(directory)
  25. if(parent!==base&&!parent.startsWith(base+sep))throw new Error('路径超出允许范围')
  26. break
  27. }catch(failure){
  28. if(failure.message==='路径超出允许范围')throw failure
  29. const next=join(directory,'..')
  30. if(directory===next)throw error
  31. directory=next
  32. }
  33. }
  34. return {base,target}
  35. }
  36. }
  37. if(real!==base&&!real.startsWith(base+sep))throw new Error('路径超出允许范围')
  38. return {base,target}
  39. }
  40. export async function listHostFiles(root,input){
  41. const {target}=await resolveWithin(root,input,'existing')
  42. const info=await stat(target).catch(()=>{throw new Error('文件或目录不存在')})
  43. if(!info.isDirectory())throw new Error('不是目录')
  44. const entries=await readdir(target,{withFileTypes:true})
  45. const limited=entries.slice(0,FILE_LIST_LIMIT)
  46. const rows=await Promise.all(limited.map(async entry=>{
  47. const full=join(target,entry.name)
  48. const details=await stat(full).then(value=>({size:value.size,mtime:Math.round(value.mtimeMs)})).catch(()=>({size:0,mtime:0}))
  49. return {name:entry.name,type:entry.isDirectory()?'directory':entry.isSymbolicLink()?'symlink':'file',...details}
  50. }))
  51. return {path:String(input),entries:rows,truncated:entries.length>FILE_LIST_LIMIT}
  52. }
  53. export async function readHostFile(root,input){
  54. const {target}=await resolveWithin(root,input,'existing')
  55. const info=await stat(target).catch(()=>{throw new Error('文件不存在')})
  56. if(!info.isFile())throw new Error('不是文件')
  57. if(info.size>FILE_READ_LIMIT)throw new Error(`文件超过读取上限(${Math.round(FILE_READ_LIMIT/1024/1024)} MB)`)
  58. const bytes=await readFile(target)
  59. return {path:String(input),size:bytes.length,data:bytes.toString('base64')}
  60. }
  61. export async function writeHostFile(root,input){
  62. const bytes=Buffer.from(String(input?.data??''),'base64')
  63. if(!bytes.length)throw new Error('文件内容为空')
  64. if(bytes.length>FILE_WRITE_LIMIT)throw new Error(`内容超过写入上限(${Math.round(FILE_WRITE_LIMIT/1024/1024)} MB)`)
  65. if(bytes.toString('base64')!==String(input?.data??''))throw new Error('文件内容必须是 base64')
  66. const {target}=await resolveWithin(root,input?.path,'write')
  67. await mkdir(join(target,'..'),{recursive:true})
  68. await writeFile(target,bytes,{mode:0o600})
  69. return {path:String(input?.path),size:bytes.length}
  70. }
  71. export async function execHostShell(root,input){
  72. const command=String(input?.command??'')
  73. if(!command.trim())throw new Error('命令为空')
  74. const timeout=Math.min(Math.max(Number(input?.timeoutMs)||60000,1000),SHELL_TIMEOUT_MAX)
  75. let cwd
  76. try{cwd=resolve(String(root),String(input?.cwd??'').replace(/^~(?=\/|$)/,''))}catch{cwd=String(root)}
  77. return new Promise(done=>{
  78. execFile('/bin/zsh',['-c',command],{cwd,timeout,killSignal:'SIGKILL',maxBuffer:1024*1024,env:{...process.env,TERM:'dumb'}},(error,stdout,stderr)=>{
  79. const clip=value=>Buffer.from(value??'','utf8').subarray(0,SHELL_OUTPUT_LIMIT).toString('utf8')
  80. 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)})
  81. })
  82. })
  83. }