diff --git a/packages/assets/src/handler.ts b/packages/assets/src/handler.ts index d42b6b33..6eda4327 100644 --- a/packages/assets/src/handler.ts +++ b/packages/assets/src/handler.ts @@ -35,9 +35,14 @@ export function createCoreHandler(directory: string, input: OptionsInput = {}) : const stack : Record = {}; - scanFiles(stack, options); + const scanned = scanFiles(stack, options); + + // A failure is reported to requests, which may never arrive. + scanned.catch(() => {}); return async (event: IAppEvent) => { + await scanned; + let requestPath = event.path; const { mountPath } = event; diff --git a/packages/assets/src/utils/directory.ts b/packages/assets/src/utils/directory.ts index 97609e64..0bd14c92 100644 --- a/packages/assets/src/utils/directory.ts +++ b/packages/assets/src/utils/directory.ts @@ -2,27 +2,30 @@ import fs from 'node:fs'; import path from 'node:path'; import type { ReadDirectoryCallback } from '../types'; -export function readDirectory( +export async function readDirectory( dir: string, callback: ReadDirectoryCallback, prefix = '', -) { - dir = path.resolve('.', dir); +) : Promise { + const directoryPath = path.resolve('.', dir); + const elements = await fs.promises.readdir(directoryPath); - let abs : string; - let stats : fs.Stats; + for (const element of elements) { + const filePath = path.join(directoryPath, element); - fs.promises.readdir(dir) - .then((arr) => { - for (const element of arr) { - abs = path.join(dir, element); - stats = fs.statSync(abs); + try { + const stats = await fs.promises.stat(filePath); - if (stats.isDirectory()) { - readDirectory(abs, callback, path.join(prefix, element)); - } else { - callback(path.join(prefix, element), abs, stats); - } + if (stats.isDirectory()) { + await readDirectory(filePath, callback, path.join(prefix, element)); + } else { + callback(path.join(prefix, element), filePath, stats); } - }); + } catch (error) { + // Dangling symlinks and entries removed during the scan cannot be served. + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + } } diff --git a/packages/assets/src/utils/lookup.ts b/packages/assets/src/utils/lookup.ts index 9cedf94b..9592718f 100644 --- a/packages/assets/src/utils/lookup.ts +++ b/packages/assets/src/utils/lookup.ts @@ -30,6 +30,14 @@ function withLeadingSlash(input: string) { return input; } +function isInsideDirectory(filePath: string, directoryPath: string) : boolean { + const relative = path.relative(directoryPath, filePath); + + return relative !== '..' && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative); +} + const lookupPath = async ( requestPath: string, options: Options, @@ -83,6 +91,10 @@ const lookupPath = async ( for (const relativeFilePath of relativeFilePaths) { filePath = path.join(options.directoryPath, relativeFilePath); + if (!isInsideDirectory(filePath, options.directoryPath)) { + continue; + } + try { const stats = await fs.promises.stat(filePath); diff --git a/packages/assets/src/utils/options.ts b/packages/assets/src/utils/options.ts index 4c797ed2..b4581335 100644 --- a/packages/assets/src/utils/options.ts +++ b/packages/assets/src/utils/options.ts @@ -33,15 +33,13 @@ export function buildOptions(input: OptionsInput) : Options { const ignores : RegExp[] = []; - if (typeof input.ignores !== 'undefined') { - if (!input.dotFiles) { - ignores.push(/\/\.\w/); - } + if (!input.dotFiles) { + ignores.push(/\/\.(?!well-known(?:\/|$))\w/); + } - if (Array.isArray(input.ignores)) { - for (let i = 0; i < input.ignores.length; i++) { - ignores.push(new RegExp(input.ignores[i]!, 'i')); - } + if (Array.isArray(input.ignores)) { + for (let i = 0; i < input.ignores.length; i++) { + ignores.push(new RegExp(input.ignores[i]!, 'i')); } } diff --git a/packages/assets/src/utils/scan-files.ts b/packages/assets/src/utils/scan-files.ts index 8e5f24df..c8354e57 100644 --- a/packages/assets/src/utils/scan-files.ts +++ b/packages/assets/src/utils/scan-files.ts @@ -2,15 +2,15 @@ import type { FileInfo, Options } from '../types'; import { readDirectory } from './directory'; import { isRegexMatch } from './regex'; -export function scanFiles( +export async function scanFiles( stack: Record, options: Options, -) { +) : Promise { if (!options.scan) { return; } - readDirectory(options.directoryPath, (relativePath, filePath, stats) => { + await readDirectory(options.directoryPath, (relativePath, filePath, stats) => { if ( !(/\.well-known[\\+/]/.test(relativePath)) && !options.dotFiles && diff --git a/packages/assets/test/unit/module.spec.ts b/packages/assets/test/unit/module.spec.ts index b7f62e3c..eab040a4 100644 --- a/packages/assets/test/unit/module.spec.ts +++ b/packages/assets/test/unit/module.spec.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from 'vitest'; import { App } from 'routup'; +import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { assets } from '../../src'; +import { lookup } from '../../src/utils/lookup'; +import { buildOptions } from '../../src/utils/options'; function createTestRequest(url: string, options?: RequestInit): Request { const fullUrl = url.startsWith('http') ? url : `http://localhost${url}`; @@ -9,10 +13,6 @@ function createTestRequest(url: string, options?: RequestInit): Request { return new Request(fullUrl, options); } -const tick = () => new Promise((resolve) => { - setTimeout(resolve, 10); -}); - const directoryPath = path.join(__dirname, '..', 'data'); describe('src/module', () => { @@ -21,8 +21,6 @@ describe('src/module', () => { router.use(assets(directoryPath)); - await tick(); - const response = await router.fetch(createTestRequest('/file.txt')); expect(await response.text()).toEqual('foo\n'); @@ -57,8 +55,6 @@ describe('src/module', () => { router.use(assets(directoryPath)); - await tick(); - const response = await router.fetch(createTestRequest('/file.js')); expect(await response.text()).toEqual('console.log(\'foo\');\n'); @@ -71,8 +67,6 @@ describe('src/module', () => { router.use(assets(directoryPath)); - await tick(); - const response = await router.fetch(createTestRequest('/file.bar')); expect(response.status).toEqual(404); @@ -83,8 +77,6 @@ describe('src/module', () => { router.use(assets(directoryPath, { fallback: true })); - await tick(); - let response = await router.fetch(createTestRequest('/html')); expect(await response.text()).toEqual('foo\n'); @@ -97,4 +89,86 @@ describe('src/module', () => { expect(response.headers.get('content-type')).toEqual('text/html; charset=utf-8'); expect(response.headers.get('content-length')).toEqual('4'); }); + + it('should serve a nested file on the first request', async () => { + const router = new App(); + + router.use(assets(directoryPath)); + + const response = await router.fetch(createTestRequest('/html/index.html')); + + expect(response.status).toEqual(200); + }); + + it('should report a failed directory scan to the request', async () => { + const router = new App(); + + router.use(assets(path.join(directoryPath, 'missing'))); + + const response = await router.fetch(createTestRequest('/file.txt')); + + expect(response.status).toEqual(500); + }); + + it('should skip entries that cannot be resolved during the scan', async () => { + const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'routup-assets-')); + + try { + await fs.promises.writeFile(path.join(directory, 'file.txt'), 'foo'); + await fs.promises.symlink(path.join(directory, 'missing'), path.join(directory, 'dangling')); + + const router = new App(); + + router.use(assets(directory)); + + const response = await router.fetch(createTestRequest('/file.txt')); + + expect(response.status).toEqual(200); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + describe('without a scan', () => { + const options = buildOptions({ directoryPath: path.join(directoryPath, 'html'), scan: false }); + + it('should only serve dotfiles from .well-known unless enabled', async () => { + const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'routup-assets-')); + + try { + await fs.promises.mkdir(path.join(directory, '.well-known')); + await fs.promises.writeFile(path.join(directory, '.env'), 'foo'); + await fs.promises.writeFile(path.join(directory, '.well-known', 'file.txt'), 'foo'); + + const hidden = buildOptions({ directoryPath: directory, scan: false }); + const visible = buildOptions({ + directoryPath: directory, + scan: false, + dotFiles: true, + }); + + expect(await lookup('/.env', hidden)).toBeUndefined(); + expect(await lookup('/.well-known/file.txt', hidden)).toBeDefined(); + expect(await lookup('/.env', visible)).toBeDefined(); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('should find a file inside the directory', async () => { + const fileInfo = await lookup('/index.html', options); + + expect(fileInfo?.filePath).toEqual(path.join(directoryPath, 'html', 'index.html')); + }); + + it('should not find a file outside the directory', async () => { + expect(await lookup('/../file.txt', options)).toBeUndefined(); + }); + + it('should not find a sibling directory sharing the directory prefix', async () => { + const sibling = buildOptions({ directoryPath: path.join(directoryPath, 'ht'), scan: false }); + + expect(await lookup('/../html/index.html', sibling)).toBeUndefined(); + }); + }); });