import { describe, it, expect } from 'vitest'; import { guardPath, guardPathUnsafe, SandboxEscapeError } from './path-guard.js'; import path from 'node:path'; import os from 'node:os'; import fs from 'node:fs'; describe('guardPathUnsafe', () => { const sandbox = '/tmp/test-sandbox'; it('allows paths inside sandbox', () => { const result = guardPathUnsafe('foo/bar.txt', sandbox); expect(result).toBe(path.resolve(sandbox, 'foo/bar.txt')); }); it('allows sandbox root itself', () => { const result = guardPathUnsafe('.', sandbox); expect(result).toBe(path.resolve(sandbox)); }); it('rejects path traversal with ../', () => { expect(() => guardPathUnsafe('../escape.txt', sandbox)).toThrow(SandboxEscapeError); }); it('rejects absolute path outside sandbox', () => { expect(() => guardPathUnsafe('/etc/passwd', sandbox)).toThrow(SandboxEscapeError); }); it('rejects deeply nested traversal', () => { expect(() => guardPathUnsafe('a/b/../../../../../../etc/passwd', sandbox)).toThrow( SandboxEscapeError, ); }); it('rejects path that starts with sandbox name but is sibling', () => { expect(() => guardPathUnsafe('/tmp/test-sandbox-evil/file.txt', sandbox)).toThrow( SandboxEscapeError, ); }); it('returns the resolved absolute path for nested paths', () => { const result = guardPathUnsafe('deep/nested/file.ts', sandbox); expect(result).toBe('/tmp/test-sandbox/deep/nested/file.ts'); }); it('SandboxEscapeError includes the user path and sandbox in message', () => { let caught: unknown; try { guardPathUnsafe('../escape.txt', sandbox); } catch (err) { caught = err; } expect(caught).toBeInstanceOf(SandboxEscapeError); const e = caught as SandboxEscapeError; expect(e.userPath).toBe('../escape.txt'); expect(e.sandboxDir).toBe(sandbox); expect(e.message).toContain('Path escape attempt blocked'); }); }); describe('guardPath', () => { let tmpDir: string; it('allows an existing path inside a real temp sandbox', () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'path-guard-test-')); try { const subdir = path.join(tmpDir, 'subdir'); fs.mkdirSync(subdir); const result = guardPath('subdir', tmpDir); expect(result).toBe(subdir); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); it('allows sandbox root itself', () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'path-guard-test-')); try { const result = guardPath('.', tmpDir); // realpathSync resolves the tmpdir symlinks (macOS /var -> /private/var) const realTmp = fs.realpathSync.native(tmpDir); expect(result).toBe(realTmp); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); it('rejects path traversal with ../ on existing sandbox', () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'path-guard-test-')); try { expect(() => guardPath('../escape', tmpDir)).toThrow(SandboxEscapeError); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); it('rejects absolute path outside sandbox', () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'path-guard-test-')); try { expect(() => guardPath('/etc/passwd', tmpDir)).toThrow(SandboxEscapeError); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); });