diff --git a/lib/handler.js b/lib/handler.js index ebc19a1..3e75038 100644 --- a/lib/handler.js +++ b/lib/handler.js @@ -8,21 +8,14 @@ import { getContentType, typeForFilePath } from './content-type.js'; import { getLocalPath, isSubpath } from './fs-utils.js'; import { dirListPage, errorPage } from './pages.js'; import { PathMatcher } from './path-matcher.js'; -import { headerCase } from './utils.js'; +import { headerCase, trimSlash } from './utils.js'; /** +@typedef {import('node:http').IncomingMessage & {originalUrl?: string}} Request +@typedef {import('node:http').ServerResponse} Response @typedef {import('./types.d.ts').FSLocation} FSLocation @typedef {import('./types.d.ts').ResMetaData} ResMetaData @typedef {import('./types.d.ts').ServerOptions} ServerOptions -*/ - -/** -@typedef {{ - req: import('node:http').IncomingMessage; - res: import('node:http').ServerResponse; - resolver: import('./resolver.js').FileResolver; - options: ServerOptions & {_noStream?: boolean} -}} ReqHandlerConfig @typedef {{ body?: string | Buffer | import('node:fs').ReadStream; contentType?: string; @@ -30,6 +23,7 @@ import { headerCase } from './utils.js'; statSize?: number; }} SendPayload */ + export class RequestHandler { #req; #res; @@ -38,25 +32,33 @@ export class RequestHandler { /** @type {ResMetaData['timing']} */ timing = { start: Date.now() }; - /** @type {string} */ - urlPath = ''; + /** @type {string | null} */ + urlPath = null; /** @type {FSLocation | null} */ file = null; - /** - Error that may be logged to the terminal - @type {Error | string | undefined} - */ + /** @type {Error | string | undefined} */ error; - /** @param {ReqHandlerConfig} config */ + /** + @param {{ + req: Request; + res: Response; + resolver: import('./resolver.js').FileResolver; + options: ServerOptions & {_noStream?: boolean}; + }} config + */ constructor({ req, res, resolver, options }) { this.#req = req; this.#res = res; this.#resolver = resolver; this.#options = options; - if (typeof req.url === 'string') { - this.urlPath = req.url.split(/[\?\#]/)[0]; + + try { + this.urlPath = extractUrlPath(req.url ?? ''); + } catch(/** @type {any} */ err) { + this.error = err; } + res.on('close', () => { this.timing.close = Date.now(); }); @@ -97,9 +99,13 @@ export class RequestHandler { return this.#send(); } - const { status, urlPath, file = null } = await this.#resolver.find(this.urlPath); + if (this.urlPath == null) { + this.status = 400; + return this.#sendErrorPage(); + } + + const { status, file } = await this.#resolver.find(trimSlash(this.urlPath)); this.status = status; - this.urlPath = urlPath; this.file = file; // found a file to serve @@ -169,14 +175,18 @@ export class RequestHandler { cors: false, headers: [], }); - if (this.method === 'OPTIONS') { this.status = 204; return this.#send(); } - const items = await this.#resolver.index(filePath); - const body = await dirListPage({ urlPath: this.urlPath, filePath, items }, this.#options); + const body = await dirListPage({ + root: this.#options.root, + ext: this.#options.ext, + urlPath: this.urlPath ?? '', + filePath, + items, + }); return this.#send({ body, isText: true }); } @@ -186,15 +196,15 @@ export class RequestHandler { cors: this.#options.cors, headers: [], }); - if (this.method === 'OPTIONS') { return this.#send(); } - - return this.#send({ - body: await errorPage({ status: this.status, urlPath: this.urlPath }), - isText: true, + const body = await errorPage({ + status: this.status, + url: this.#req.url ?? '', + urlPath: this.urlPath, }); + return this.#send({ body, isText: true }); } /** @type {(payload?: SendPayload) => void} */ @@ -315,6 +325,7 @@ export class RequestHandler { return { status: this.status, method: this.method, + url: this.#req.url ?? '', urlPath: this.urlPath, localPath: this.localPath, timing: structuredClone(this.timing), @@ -373,3 +384,26 @@ function parseHeaderNames(input = '') { .map((h) => h.trim()) .filter(isHeader); } + +/** @type {(url: string) => string} */ +export function extractUrlPath(url) { + if (url === '*') return url; + const path = new URL(url, 'http://localhost/').pathname || '/'; + if (!isValidUrlPath(path)) { + throw new Error(`Invalid URL path: '${path}'`) + } + return path; +} + +/** @type {(urlPath: string) => boolean} */ +export function isValidUrlPath(urlPath) { + if (urlPath === '/') return true; + if (!urlPath.startsWith('/') || urlPath.includes('//')) return false; + for (const s of trimSlash(urlPath).split('/')) { + const d = decodeURIComponent(s); + if (d === '.' || d === '..') return false; + if (s.includes('?') || s.includes('#')) return false; + if (d.includes('/') || d.includes('\\')) return false; + } + return true; +} diff --git a/lib/logger.js b/lib/logger.js index af1054e..eec74ba 100644 --- a/lib/logger.js +++ b/lib/logger.js @@ -116,7 +116,7 @@ class Logger { } /** @type {(data: import('./types.d.ts').ResMetaData) => string} */ -export function requestLogLine({ status, method, urlPath, localPath, timing, error }) { +export function requestLogLine({ status, method, url, urlPath, localPath, timing, error }) { const { start, close } = timing; const { style: _, brackets } = color; @@ -124,8 +124,8 @@ export function requestLogLine({ status, method, urlPath, localPath, timing, err const timestamp = start ? new Date(start).toTimeString().split(' ')[0]?.padStart(8) : undefined; const duration = start && close ? Math.ceil(close - start) : undefined; - let displayPath = _(urlPath, 'cyan'); - if (isSuccess && localPath != null) { + let displayPath = _(urlPath ?? url, 'cyan'); + if (isSuccess && urlPath != null && localPath != null) { const basePath = urlPath.length > 1 ? trimSlash(urlPath, { end: true }) : urlPath; const suffix = pathSuffix(basePath, `/${fwdSlash(localPath)}`); if (suffix) { diff --git a/lib/pages.js b/lib/pages.js index adafd9a..7318291 100644 --- a/lib/pages.js +++ b/lib/pages.js @@ -5,7 +5,6 @@ import { clamp, escapeHtml, trimSlash } from './utils.js'; /** @typedef {import('./types.d.ts').FSLocation} FSLocation -@typedef {import('./types.d.ts').ServerOptions} ServerOptions */ /** @@ -32,11 +31,10 @@ ${body} } /** -@param {{ status: number, urlPath: string }} data -@returns {Promise} +@param {{ status: number; url: string; urlPath: string | null }} data */ -export function errorPage({ status, urlPath }) { - const displayPath = decodeURIPathSegments(urlPath); +export function errorPage({ status, url, urlPath }) { + const displayPath = decodeURIPathSegments(urlPath ?? url); const pathHtml = `${html(nl2sp(displayPath))}`; const page = (title = '', desc = '') => { @@ -45,6 +43,8 @@ export function errorPage({ status, urlPath }) { }; switch (status) { + case 400: + return page('400: Bad request', `Invalid request for ${pathHtml}`); case 403: return page('403: Forbidden', `Could not access ${pathHtml}`); case 404: @@ -59,12 +59,10 @@ export function errorPage({ status, urlPath }) { } /** -@param {{ urlPath: string; filePath: string; items: FSLocation[] }} data -@param {Pick} options -@returns {Promise} +@param {{ root: string; urlPath: string; filePath: string; items: FSLocation[]; ext: string[] }} data */ -export function dirListPage({ urlPath, filePath, items }, options) { - const rootName = basename(options.root); +export function dirListPage({ root, urlPath, filePath, items, ext }) { + const rootName = basename(root); const trimmedUrl = trimSlash(urlPath); const baseUrl = trimmedUrl ? `/${trimmedUrl}/` : '/'; @@ -89,14 +87,15 @@ export function dirListPage({ urlPath, filePath, items }, options) { Index of ${renderBreadcrumbs(displayPath)}
    -${sorted.map((item) => renderListItem(item, { ext: options.ext, parentPath })).join('\n')} +${sorted.map((item) => renderListItem(item, { ext, parentPath })).join('\n')}
`.trim(), }); } /** -@type {(item: FSLocation, options: { ext: ServerOptions['ext']; parentPath: string }) => string} +@param {FSLocation} item +@param {{ ext: string[]; parentPath: string }} options */ function renderListItem(item, { ext, parentPath }) { const isDir = isDirLike(item); diff --git a/lib/resolver.js b/lib/resolver.js index c6dac3c..19f5cac 100644 --- a/lib/resolver.js +++ b/lib/resolver.js @@ -2,7 +2,7 @@ import { isAbsolute, join } from 'node:path'; import { getIndex, getKind, getLocalPath, getRealpath, isReadable, isSubpath } from './fs-utils.js'; import { PathMatcher } from './path-matcher.js'; -import { fwdSlash, trimSlash } from './utils.js'; +import { trimSlash } from './utils.js'; /** @typedef {import('./types.d.ts').FSLocation} FSLocation @@ -39,13 +39,15 @@ export class FileResolver { this.#excludeMatcher = new PathMatcher(options.exclude ?? [], { caseSensitive: true }); } - /** @param {string} url */ - async find(url) { - const { urlPath, filePath: targetPath } = resolveUrlPath(this.#root, url); - - /** @type {{status: number; urlPath: string; file?: FSLocation}} */ - const result = { status: 404, urlPath }; + /** @param {string} relativePath */ + async find(relativePath) { + const result = { + status: 404, + /** @type {FSLocation | null} */ + file: null, + }; + const targetPath = this.resolvePath(relativePath); if (targetPath == null) { return result; } @@ -144,13 +146,10 @@ export class FileResolver { return this.#excludeMatcher.test(localPath) === false; } - /** @type {(urlPath: string | null) => string | null} */ - urlToTargetPath(urlPath) { - if (urlPath && urlPath.startsWith('/')) { - const filePath = join(this.#root, decodeURIComponent(urlPath)); - return trimSlash(filePath, { end: true }); - } - return null; + /** @type {(relativePath: string) => string | null} */ + resolvePath(relativePath) { + const filePath = join(this.#root, relativePath); + return this.withinRoot(filePath) ? trimSlash(filePath, { end: true }) : null; } /** @type {(filePath: string) => boolean} */ @@ -158,28 +157,3 @@ export class FileResolver { return isSubpath(this.#root, filePath); } } - -/** @type {(urlPath: string) => boolean} */ -export function isValidUrlPath(urlPath) { - if (urlPath === '/') return true; - if (!urlPath.startsWith('/') || urlPath.includes('//')) return false; - for (const s of trimSlash(urlPath).split('/')) { - const d = decodeURIComponent(s); - if (d === '.' || d === '..') return false; - if (s.includes('?') || s.includes('#')) return false; - if (d.includes('/') || d.includes('\\')) return false; - } - return true; -} - -/** @type {(root: url, url: string) => {urlPath: string; filePath: string | null}} */ -export function resolveUrlPath(root, url) { - try { - const urlPath = fwdSlash(new URL(url, 'http://localhost/').pathname) ?? '/'; - const filePath = isValidUrlPath(urlPath) - ? trimSlash(join(root, decodeURIComponent(urlPath)), { end: true }) - : null; - return { urlPath, filePath }; - } catch {} - return { urlPath: url, filePath: null }; -} diff --git a/lib/types.d.ts b/lib/types.d.ts index 5370bff..e5da4e1 100644 --- a/lib/types.d.ts +++ b/lib/types.d.ts @@ -21,7 +21,8 @@ export interface OptionSpec { export interface ResMetaData { method: string; status: number; - urlPath: string; + url: string; + urlPath: string | null; localPath: string | null; timing: { start: number; send?: number; close?: number }; error?: Error | string; diff --git a/test/handler.test.js b/test/handler.test.js index 4916cbc..606f9fa 100644 --- a/test/handler.test.js +++ b/test/handler.test.js @@ -3,8 +3,8 @@ import { IncomingMessage, ServerResponse } from 'node:http'; import { Duplex } from 'node:stream'; import { after, suite, test } from 'node:test'; +import { extractUrlPath, fileHeaders, isValidUrlPath, RequestHandler } from '../lib/handler.js'; import { FileResolver } from '../lib/resolver.js'; -import { fileHeaders, RequestHandler } from '../lib/handler.js'; import { fsFixture, getBlankOptions, getDefaultOptions, platformSlash } from './shared.js'; /** @@ -76,6 +76,65 @@ function withHeaderRules(rules, blockList) { return (filePath) => fileHeaders(filePath, rules, blockList); } +suite('isValidUrlPath', () => { + /** @type {(urlPath: string, expected: boolean) => void} */ + const check = (urlPath, expected = true) => strictEqual(isValidUrlPath(urlPath), expected); + + test('rejects invalid paths', () => { + check('', false); + check('anything', false); + check('https://example.com/hello', false); + check('/hello?', false); + check('/hello#intro', false); + check('/hello//world', false); + check('/hello\\world', false); + check('/..', false); + check('/%2E%2E/etc', false); + check('/_%2F_%2F_', false); + check('/_%5C_%5C_', false); + check('/_%2f_%5c_', false); + }); + + test('accepts valid url paths', () => { + check('/', true); + check('/hello/world', true); + check('/YES!/YES!!/THE TIGER IS OUT!', true); + check('/.well-known/security.txt', true); + check('/cool..story', true); + check('/%20%20%20%20spaces%0A%0Aand%0A%0Alinebreaks%0A%0A%20%20%20%20', true); + check( + '/%E5%BA%A7%E9%96%93%E5%91%B3%E5%B3%B6%E3%81%AE%E5%8F%A4%E5%BA%A7%E9%96%93%E5%91%B3%E3%83%93%E3%83%BC%E3%83%81%E3%80%81%E6%B2%96%E7%B8%84%E7%9C%8C%E5%B3%B6%E5%B0%BB%E9%83%A1%E5%BA%A7%E9%96%93%E5%91%B3%E6%9D%91', + true, + ); + }); +}); + +suite('extractUrlPath', () => { + /** @type {(url: string, expected: string | null) => void} */ + const checkUrl = (url, expected) => strictEqual(extractUrlPath(url), expected); + + test('extracts URL pathname', () => { + checkUrl('https://example.com/hello/world', '/hello/world'); + checkUrl('/hello/world?cool=test', '/hello/world'); + checkUrl('/hello/world#right', '/hello/world'); + }); + + test('keeps percent encoding', () => { + checkUrl('/Super%3F%20%C3%89patant%21/', '/Super%3F%20%C3%89patant%21/'); + checkUrl('/%E3%82%88%E3%81%86%E3%81%93%E3%81%9D', '/%E3%82%88%E3%81%86%E3%81%93%E3%81%9D'); + }); + + test('resolves double-dots and slashes', () => { + // `new URL` treats backslashes as forward slashes + checkUrl('/a\\b', '/a/b'); + checkUrl('/a\\.\\b', '/a/b'); + checkUrl('/\\foo/', '/'); + // double dots are resolved + checkUrl('/../bar', '/bar'); + checkUrl('/%2E%2E/bar', '/bar'); + }); +}); + suite('fileHeaders', () => { test('headers without include patterns are added for all responses', () => { const headers = withHeaderRules([ diff --git a/test/logger.test.js b/test/logger.test.js index d072eff..a25d7e5 100644 --- a/test/logger.test.js +++ b/test/logger.test.js @@ -53,12 +53,13 @@ suite('ColorUtils', () => { suite('responseLogLine', () => { /** - @param {Omit} data + @param {Omit} data @param {string} expected */ const matchLogLine = (data, expected) => { const rawLine = requestLogLine({ timing: { start: Date.now() }, + url: `http://localhost:8080${data.urlPath}`, ...data, }); const line = stripStyle(rawLine).replace(/^\d{2}:\d{2}:\d{2} /, ''); diff --git a/test/pages.test.js b/test/pages.test.js index 2f7c8d6..bb9f6db 100644 --- a/test/pages.test.js +++ b/test/pages.test.js @@ -37,10 +37,13 @@ suite('dirListPage', () => { const serverOptions = { root: loc.path(), ext: ['.html'] }; /** - @type {(data: Parameters[0]) => Promise} + @type {(data: Omit[0], 'root' | 'ext'>) => Promise} */ async function dirListDoc(data) { - const html = await dirListPage(data, serverOptions); + const html = await dirListPage({ + ...serverOptions, + ...data, + }); return parseHTML(html).document; } @@ -136,27 +139,43 @@ suite('dirListPage', () => { suite('errorPage', () => { /** - @type {(data: { status: number; urlPath: string }) => Promise} + @type {(data: { status: number; urlPath: string | null }) => Promise} */ async function errorDoc(data) { - const html = await errorPage(data); + const html = await errorPage({ ...data, url: data.urlPath ?? '' }); return parseHTML(html).document; } test('same generic error page for unknown status', async () => { - const html1 = await errorPage({ status: 0, urlPath: '/error' }); - const html2 = await errorPage({ status: 200, urlPath: '/some/other/path' }); + const html1 = await errorPage({ + status: 0, + url: '/error', + urlPath: '/error', + }); + const html2 = await errorPage({ + status: 200, + url: '/some/other/path', + urlPath: '/some/other/path', + }); strictEqual(html1, html2); }); test('generic error page', async () => { - const doc = await errorDoc({ status: 400, urlPath: '/error' }); + const doc = await errorDoc({ status: 499, urlPath: '/error' }); checkTemplate(doc, { title: 'Error', desc: 'Something went wrong', }); }); + test('400 error page', async () => { + const doc = await errorDoc({ status: 400, urlPath: null }); + checkTemplate(doc, { + title: '400: Bad request', + desc: 'Invalid request for ', + }); + }); + test('404 error page', async () => { const doc = await errorDoc({ status: 404, urlPath: '/does/not/exist' }); checkTemplate(doc, { diff --git a/test/resolver.test.js b/test/resolver.test.js index bb723f3..bfb4da3 100644 --- a/test/resolver.test.js +++ b/test/resolver.test.js @@ -1,9 +1,9 @@ import { deepStrictEqual, strictEqual, throws } from 'node:assert'; import { after, suite, test } from 'node:test'; -import { FileResolver, isValidUrlPath, resolveUrlPath } from '../lib/resolver.js'; -import { fsFixture, getDefaultOptions, loc, platformSlash } from './shared.js'; import { getLocalPath } from '../lib/fs-utils.js'; +import { FileResolver } from '../lib/resolver.js'; +import { fsFixture, getDefaultOptions, loc, platformSlash } from './shared.js'; suite('FileResolver.#root', () => { const { path } = loc; @@ -172,10 +172,8 @@ suite('FileResolver.find', async () => { const resolver = new FileResolver(minimalOptions); for (const localPath of ['.htpasswd', 'page2.htm', 'section/page.md']) { - const url = `/${localPath}`; - deepStrictEqual(await resolver.find(url), { + deepStrictEqual(await resolver.find(localPath), { status: 200, - urlPath: url, file: file(localPath), }); } @@ -184,11 +182,9 @@ suite('FileResolver.find', async () => { test('finds folder with exact path', async () => { const resolver = new FileResolver({ ...minimalOptions, dirList: true }); - for (const urlPath of ['/section', '/section/']) { - const result = await resolver.find(urlPath); - deepStrictEqual(result, { + for (const localPath of ['section', '/section/']) { + deepStrictEqual(await resolver.find(localPath), { status: 200, - urlPath, file: dir('section'), }); } @@ -197,10 +193,10 @@ suite('FileResolver.find', async () => { test('non-existing paths have a 404 status', async () => { const resolver = new FileResolver(minimalOptions); - for (const urlPath of ['/README.md', '/section/other-page']) { - deepStrictEqual(await resolver.find(urlPath), { - urlPath, + for (const localPath of ['README.md', 'section/other-page']) { + deepStrictEqual(await resolver.find(localPath), { status: 404, + file: null, }); } }); @@ -214,31 +210,29 @@ suite('FileResolver.find', async () => { }; // non-existing files are always a 404 - await check('/doesnt-exist', '404 null'); - await check('/.doesnt-exist', '404 null'); + await check('doesnt-exist', '404 null'); + await check('.doesnt-exist', '404 null'); // existing dotfiles are excluded by default pattern - await check('/.env', '404 .env'); - await check('/.htpasswd', '404 .htpasswd'); - await check('/section/.gitignore', '404 section/.gitignore'); + await check('.env', '404 .env'); + await check('.htpasswd', '404 .htpasswd'); + await check('section/.gitignore', '404 section/.gitignore'); // Except the .well-known folder, allowed by default - await check('/.well-known', '200 .well-known'); - await check('/.well-known/security.txt', '200 .well-known/security.txt'); + await check('.well-known', '200 .well-known'); + await check('.well-known/security.txt', '200 .well-known/security.txt'); }); test('default options resolve index.html', async () => { const resolver = new FileResolver(defaultOptions); - deepStrictEqual(await resolver.find('/'), { - urlPath: '/', + deepStrictEqual(await resolver.find(''), { status: 200, file: file('index.html'), }); - for (const urlPath of ['/section', '/section/']) { - deepStrictEqual(await resolver.find(urlPath), { - urlPath, + for (const localPath of ['section', '/section/']) { + deepStrictEqual(await resolver.find(localPath), { status: 200, file: file('section/index.html'), }); @@ -249,21 +243,18 @@ suite('FileResolver.find', async () => { const resolver = new FileResolver(defaultOptions); // adds .html - for (const fileLike of ['index', 'page1', 'section/index']) { - const urlPath = `/${fileLike}`; - deepStrictEqual(await resolver.find(urlPath), { - urlPath, + for (const localPath of ['index', 'page1', 'section/index']) { + deepStrictEqual(await resolver.find(localPath), { status: 200, - file: file(`${fileLike}.html`), + file: file(`${localPath}.html`), }); } // doesn't add other extensions for (const localPath of ['about', 'page2', 'section/page']) { - const urlPath = `/${localPath}`; - deepStrictEqual(await resolver.find(urlPath), { - urlPath, + deepStrictEqual(await resolver.find(localPath), { status: 404, + file: null, }); } }); @@ -308,73 +299,3 @@ suite('FileResolver.index', async () => { ]); }); }); - -suite('isValidUrlPath', () => { - /** @type {(urlPath: string, expected: boolean) => void} */ - const check = (urlPath, expected = true) => strictEqual(isValidUrlPath(urlPath), expected); - - test('rejects invalid paths', () => { - check('', false); - check('anything', false); - check('https://example.com/hello', false); - check('/hello?', false); - check('/hello#intro', false); - check('/hello//world', false); - check('/hello\\world', false); - check('/..', false); - check('/%2E%2E/etc', false); - check('/_%2F_%2F_', false); - check('/_%5C_%5C_', false); - check('/_%2f_%5c_', false); - }); - - test('accepts valid url paths', () => { - check('/', true); - check('/hello/world', true); - check('/YES!/YES!!/THE TIGER IS OUT!', true); - check('/.well-known/security.txt', true); - check('/cool..story', true); - check('/%20%20%20%20spaces%0A%0Aand%0A%0Alinebreaks%0A%0A%20%20%20%20', true); - check( - '/%E5%BA%A7%E9%96%93%E5%91%B3%E5%B3%B6%E3%81%AE%E5%8F%A4%E5%BA%A7%E9%96%93%E5%91%B3%E3%83%93%E3%83%BC%E3%83%81%E3%80%81%E6%B2%96%E7%B8%84%E7%9C%8C%E5%B3%B6%E5%B0%BB%E9%83%A1%E5%BA%A7%E9%96%93%E5%91%B3%E6%9D%91', - true, - ); - }); -}); - -suite('resolveUrlPath', () => { - const { path } = loc; - - /** @type {(url: string, expected: string | null) => void} */ - const checkUrl = (url, expected) => strictEqual(resolveUrlPath(path(), url).urlPath, expected); - - /** @type {(url: string, expected: string | null) => void} */ - const checkPath = (url, expected) => strictEqual(resolveUrlPath(path(), url).filePath, expected); - - test('extracts URL pathname', () => { - checkUrl('https://example.com/hello/world', '/hello/world'); - checkUrl('/hello/world?cool=test', '/hello/world'); - checkUrl('/hello/world#right', '/hello/world'); - }); - - test('keeps percent encoding', () => { - checkUrl('/Super%3F%20%C3%89patant%21/', '/Super%3F%20%C3%89patant%21/'); - checkUrl('/%E3%82%88%E3%81%86%E3%81%93%E3%81%9D', '/%E3%82%88%E3%81%86%E3%81%93%E3%81%9D'); - }); - - test('resolves double-dots and slashes', () => { - // `new URL` treats backslashes as forward slashes - checkUrl('/a\\b', '/a/b'); - checkUrl('/a\\.\\b', '/a/b'); - checkUrl('/\\foo/', '/'); - // double dots are resolved - checkUrl('/../bar', '/bar'); - checkUrl('/%2E%2E/bar', '/bar'); - }); - - test('resolves to valid file path', () => { - checkPath('/', path()); - checkPath('https://example.com/hello/world', path`hello/world`); - checkPath('/a/b/../d/./e/f', path`a/d/e/f`); - }); -});