snapshot-workspace-parent.spec.ts 3.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
  1. import { existsSync } from 'node:fs'
  2. import { chmod, mkdir, mkdtemp, readFile, rm, stat, symlink } from 'node:fs/promises'
  3. import { homedir, tmpdir } from 'node:os'
  4. import { dirname, join, parse } from 'node:path'
  5. import { Context } from '@deepseek-ai/cordis'
  6. import { SandboxedFileSystem } from '@deepseek-ai/dsh-fs-sandbox'
  7. import { canonicalPath } from '@deepseek-ai/dsh-sandbox'
  8. import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
  9. import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  10. import { describe, expect, it } from 'vitest'
  11. import { assertWorkspaceOutsideTemp, outsideTempWorkspaceParent } from './snapshot-workspace-parent.ts'
  12. // Host disk exhaustion is not simulated: placement and the real write fence are the regression oracles.
  13. describe('snapshot workspace parent', () => {
  14. // Windows directory permissions and root bypass do not enforce POSIX write bits.
  15. it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)('uses home when the temp parent is not writable', async () => {
  16. const base = await mkdtemp(join(outsideTempWorkspaceParent(), '.dsh-snapshot-readonly-'))
  17. try {
  18. const temporary = join(base, '_temp')
  19. await mkdir(temporary)
  20. await chmod(base, 0o500)
  21. expect(outsideTempWorkspaceParent(temporary)).toBe(homedir())
  22. } finally {
  23. await chmod(base, 0o700)
  24. await rm(base, { recursive: true, force: true })
  25. }
  26. })
  27. it('uses home when a temp sibling would require a system directory or inherit its grant', () => {
  28. expect(outsideTempWorkspaceParent('/tmp')).toBe(homedir())
  29. expect(outsideTempWorkspaceParent(parse(tmpdir()).root)).toBe(homedir())
  30. expect(outsideTempWorkspaceParent(join(canonicalPath('/tmp'), 'runner', '_temp'))).toBe(homedir())
  31. })
  32. it('rejects automatically writable temporary workspaces, including symlink aliases', async () => {
  33. const root = await mkdtemp(join(tmpdir(), 'dsh-snapshot-parent-'))
  34. try {
  35. expect(() => { assertWorkspaceOutsideTemp(root) }).toThrow('must be outside temporary writable root')
  36. const alias = join(root, 'alias')
  37. await symlink(tmpdir(), alias, process.platform === 'win32' ? 'junction' : 'dir')
  38. expect(() => { assertWorkspaceOutsideTemp(alias) }).toThrow('must be outside temporary writable root')
  39. } finally {
  40. await rm(root, { recursive: true, force: true })
  41. }
  42. })
  43. it('allows the allocated workspace but denies sibling writes and cleans the complete tree', async () => {
  44. const base = await mkdtemp(join(outsideTempWorkspaceParent(), 'dsh-snapshot-parent-'))
  45. const ctx = new Context()
  46. const fibers: Awaited<ReturnType<Context['plugin']>>[] = []
  47. try {
  48. const temporary = join(base, '_temp')
  49. await mkdir(temporary)
  50. expect(outsideTempWorkspaceParent(temporary)).toBe(canonicalPath(base))
  51. const workspace = await mkdtemp(join(outsideTempWorkspaceParent(temporary), 'workspace-'))
  52. const outside = join(base, 'outside.txt')
  53. assertWorkspaceOutsideTemp(workspace)
  54. expect(dirname(workspace)).toBe(canonicalPath(base))
  55. expect((await stat(workspace)).dev).toBe((await stat(temporary)).dev)
  56. fibers.push(await ctx.plugin(SessionProjectionRegistry))
  57. fibers.push(await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: workspace }))
  58. fibers.push(await ctx.plugin(SandboxedFileSystem, { cwd: workspace }))
  59. const inside = join(workspace, 'inside.txt')
  60. await ctx.fs.writeText(await ctx.fs.resolve(inside), 'inside')
  61. expect(await readFile(inside, 'utf8')).toBe('inside')
  62. await expect(ctx.fs.writeText(await ctx.fs.resolve(outside), 'outside'))
  63. .rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' })
  64. expect(existsSync(outside)).toBe(false)
  65. } finally {
  66. try {
  67. for (const fiber of fibers.reverse()) await fiber.dispose()
  68. } finally {
  69. await rm(base, { recursive: true, force: true })
  70. }
  71. }
  72. expect(existsSync(base)).toBe(false)
  73. })
  74. })