import { describe, test, expect } from 'bun:test'; import * as fc from 'fast-check'; import { validatePath } from '../../src/index.js'; describe('validatePath — happy path', () => { test('accepts simple absolute paths', () => { expect(validatePath('/foo')).toEqual({ ok: true, normalized: '/foo' }); expect(validatePath('/foo/bar/baz.txt')).toEqual({ ok: true, normalized: '/foo/bar/baz.txt', }); expect(validatePath('/')).toEqual({ ok: true, normalized: '/' }); }); test('normalizes redundant slashes and dots', () => { expect(validatePath('//foo//bar/./baz/').normalized).toBe('/foo/bar/baz'); expect(validatePath('/./foo').normalized).toBe('/foo'); }); test('UTF-8 paths are accepted', () => { expect(validatePath('/Документы/файл.txt').normalized).toBe('/Документы/файл.txt'); expect(validatePath('/絵文字 😀/foo').normalized).toBe('/絵文字 😀/foo'); }); }); describe('validatePath — security', () => { test('rejects raw `..` segments', () => { expect(validatePath('/../etc/passwd').ok).toBe(false); expect(validatePath('/foo/../etc').ok).toBe(false); expect(validatePath('/..').ok).toBe(false); }); test('rejects percent-encoded `..`', () => { expect(validatePath('/%2e%2e/etc').ok).toBe(false); expect(validatePath('/foo/%2E%2E/etc').ok).toBe(false); }); test('rejects forbidden control bytes', () => { expect(validatePath('/foo\x00bar').ok).toBe(false); expect(validatePath('/foo\r\nbar').ok).toBe(false); expect(validatePath('/foo\x7f').ok).toBe(false); expect(validatePath('/foo\x01').ok).toBe(false); }); test('rejects backslashes (Windows-style)', () => { expect(validatePath('/foo\\bar').ok).toBe(false); }); test('rejects relative paths', () => { expect(validatePath('foo').ok).toBe(false); expect(validatePath('./foo').ok).toBe(false); expect(validatePath('').ok).toBe(false); }); test('rejects over-length paths', () => { expect(validatePath('/' + 'a'.repeat(4096)).ok).toBe(false); expect(validatePath('/foobar', { maxLength: 5 }).ok).toBe(false); expect(validatePath('/abc', { maxLength: 5 }).ok).toBe(true); }); }); describe('validatePath — rootScope', () => { test('accepts paths inside scope', () => { expect( validatePath('/srv/data/foo', { rootScope: '/srv/data' }).ok, ).toBe(true); expect(validatePath('/srv/data', { rootScope: '/srv/data' }).ok).toBe(true); }); test('rejects paths outside scope', () => { expect(validatePath('/etc/passwd', { rootScope: '/srv/data' }).ok).toBe(false); expect(validatePath('/srv/dataX', { rootScope: '/srv/data' }).ok).toBe(false); // Boundary check: /srv/database is NOT inside /srv/data expect(validatePath('/srv/database/x', { rootScope: '/srv/data' }).ok).toBe(false); }); }); describe('validatePath — extra hook', () => { test('extra reject takes precedence', () => { const result = validatePath('/secret/foo', { extra: (p) => (p.includes('secret') ? 'reject' : 'allow'), }); expect(result.ok).toBe(false); }); test('extra allow is the default', () => { expect( validatePath('/foo', { extra: () => 'allow', }).ok, ).toBe(true); }); }); describe('validatePath — property-based', () => { test('any string with a forbidden control byte is rejected', () => { fc.assert( fc.property( fc.string({ minLength: 1, maxLength: 100 }), fc.constantFrom('\x00', '\x07', '\x0a', '\x0d', '\x7f', '\\'), (prefix, bad) => { const p = `/${prefix}${bad}`; expect(validatePath(p).ok).toBe(false); }, ), { numRuns: 200 }, ); }); test('any path inside rootScope normalizes within rootScope', () => { fc.assert( fc.property( fc.array(fc.string({ minLength: 1, maxLength: 20 }).filter( (s) => /^[A-Za-z0-9_-]+$/.test(s), ), { minLength: 1, maxLength: 5 }), (segments) => { const root = '/srv'; const path = `${root}/${segments.join('/')}`; const r = validatePath(path, { rootScope: root }); if (r.ok) { expect(r.normalized.startsWith(root)).toBe(true); } }, ), { numRuns: 200 }, ); }); });