From ab834ad7f75068c74dfb0d1220931c7b67cd0705 Mon Sep 17 00:00:00 2001 From: tada5hi Date: Sun, 8 Nov 2026 12:28:59 +0100 Subject: [PATCH 1/4] fix(assets): wait for the directory scan before serving requests The scan was started without being awaited, so a request arriving before it finished got a 404, and a missing directory caused an unhandled rejection. Await the scan in the handler and report a failure to the request. --- packages/assets/src/handler.ts | 7 +++++- packages/assets/src/utils/directory.ts | 31 ++++++++++-------------- packages/assets/src/utils/scan-files.ts | 6 ++--- packages/assets/test/unit/module.spec.ts | 30 +++++++++++++++-------- 4 files changed, 42 insertions(+), 32 deletions(-) diff --git a/packages/assets/src/handler.ts b/packages/assets/src/handler.ts index 5fba5a2f..df31c8d8 100644 --- a/packages/assets/src/handler.ts +++ b/packages/assets/src/handler.ts @@ -34,9 +34,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 be323cb9..4fa59b96 100644 --- a/packages/assets/src/utils/directory.ts +++ b/packages/assets/src/utils/directory.ts @@ -2,27 +2,22 @@ import fs from 'node:fs'; import path from 'node:path'; import type { ReadDirectoryCallback } from '../type'; -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); + const stats = await fs.promises.stat(filePath); - fs.promises.readdir(dir) - .then((arr) => { - for (const element of arr) { - abs = path.join(dir, element); - stats = fs.statSync(abs); - - 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); + } + } } diff --git a/packages/assets/src/utils/scan-files.ts b/packages/assets/src/utils/scan-files.ts index 6ce2a423..3f22ee62 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 '../type'; 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 868c42db..1ab60297 100644 --- a/packages/assets/test/unit/module.spec.ts +++ b/packages/assets/test/unit/module.spec.ts @@ -8,8 +8,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', () => { @@ -18,8 +16,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'); @@ -54,8 +50,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'); @@ -68,8 +62,6 @@ describe('src/module', () => { router.use(assets(directoryPath)); - await tick(); - const response = await router.fetch(createTestRequest('/file.bar')); expect(response.status).toEqual(404); @@ -80,8 +72,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'); @@ -94,4 +84,24 @@ 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); + }); }); From bf8736e4ebbe3f4febaaa0d976fbc9eae0ede086 Mon Sep 17 00:00:00 2001 From: tada5hi Date: Sun, 8 Nov 2026 12:37:13 +0100 Subject: [PATCH 2/4] fix(assets): skip entries that disappear during the directory scan A dangling symlink or a file removed mid-scan rejected the whole scan, so every request failed. Skip those entries and keep failing when the root directory cannot be read. --- packages/assets/src/utils/directory.ts | 18 +++++++++++++----- packages/assets/test/unit/module.spec.ts | 21 +++++++++++++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/packages/assets/src/utils/directory.ts b/packages/assets/src/utils/directory.ts index 4fa59b96..a72c7038 100644 --- a/packages/assets/src/utils/directory.ts +++ b/packages/assets/src/utils/directory.ts @@ -12,12 +12,20 @@ export async function readDirectory( for (const element of elements) { const filePath = path.join(directoryPath, element); - const stats = await fs.promises.stat(filePath); - if (stats.isDirectory()) { - await readDirectory(filePath, callback, path.join(prefix, element)); - } else { - callback(path.join(prefix, element), filePath, stats); + try { + const stats = await fs.promises.stat(filePath); + + 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/test/unit/module.spec.ts b/packages/assets/test/unit/module.spec.ts index 1ab60297..a0e0e7a5 100644 --- a/packages/assets/test/unit/module.spec.ts +++ b/packages/assets/test/unit/module.spec.ts @@ -1,5 +1,7 @@ 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'; @@ -104,4 +106,23 @@ describe('src/module', () => { 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 }); + } + }); }); From 095dc95c2d9e3d0fc841c5e7da69e64cf31ddf79 Mon Sep 17 00:00:00 2001 From: tada5hi Date: Sun, 8 Nov 2026 12:43:10 +0100 Subject: [PATCH 3/4] fix(assets): confine lookups to the directory when scan is disabled With scan disabled the request path was joined onto the directory without a check, so a path containing parent segments could resolve outside it and every consumer needed its own guard. Skip candidates that resolve outside the directory. --- packages/assets/src/utils/lookup.ts | 12 ++++++++++++ packages/assets/test/unit/module.spec.ts | 22 ++++++++++++++++++++++ 2 files changed, 34 insertions(+) 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/test/unit/module.spec.ts b/packages/assets/test/unit/module.spec.ts index eba50a41..7c26329b 100644 --- a/packages/assets/test/unit/module.spec.ts +++ b/packages/assets/test/unit/module.spec.ts @@ -4,6 +4,8 @@ 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}`; @@ -126,4 +128,24 @@ describe('src/module', () => { 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 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(); + }); + }); }); From 0ad7df91515dae8767c9f42d8105e264a77574b3 Mon Sep 17 00:00:00 2001 From: tada5hi Date: Sun, 8 Nov 2026 12:44:18 +0100 Subject: [PATCH 4/4] fix(assets): hide dotfiles when scan is disabled The dotfile filter was only added when ignores were passed, so with scan disabled a request for a dotfile was served even though dotFiles defaults to false. Always apply it, and keep .well-known servable as the scan does. --- packages/assets/src/utils/options.ts | 14 ++++++-------- packages/assets/test/unit/module.spec.ts | 23 +++++++++++++++++++++++ 2 files changed, 29 insertions(+), 8 deletions(-) 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/test/unit/module.spec.ts b/packages/assets/test/unit/module.spec.ts index 7c26329b..eab040a4 100644 --- a/packages/assets/test/unit/module.spec.ts +++ b/packages/assets/test/unit/module.spec.ts @@ -132,6 +132,29 @@ describe('src/module', () => { 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);