From f7c5d57d2192d9d8b8199c4b33cb208afb8d396d Mon Sep 17 00:00:00 2001 From: Ethan Arrowood Date: Mon, 30 Mar 2026 18:54:19 -0600 Subject: [PATCH 1/3] fix: serve directory index when path segment contains a dot path.extname() returns a non-empty string for segments like '4.6', causing serve-handler to treat them as file requests. If the early lstat reveals a directory, clear stats so findRelated can fall back to index.html or the clean-URL .html sibling. Fixes: paths like /docs/4.6 returning 404 when directoryListing is disabled and cleanUrls is enabled. --- src/index.js | 6 ++++++ test/fixtures/4.6/index.html | 1 + test/integration.test.js | 37 ++++++++++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+) create mode 100644 test/fixtures/4.6/index.html diff --git a/src/index.js b/src/index.js index 564f012..76ddd35 100644 --- a/src/index.js +++ b/src/index.js @@ -608,6 +608,12 @@ module.exports = async (request, response, config = {}, methods = {}) => { if (path.extname(relativePath) !== '') { try { stats = await handlers.lstat(absolutePath); + // If the path looks like it has an extension but actually resolves to a + // directory (e.g. /docs/4.6 where path.extname returns '.6'), clear stats + // so findRelated can fall back to index.html or the .html sibling. + if (stats && stats.isDirectory()) { + stats = null; + } } catch (err) { if (err.code !== 'ENOENT' && err.code !== 'ENOTDIR') { return internalError(absolutePath, response, acceptsJSON, current, handlers, config, err); diff --git a/test/fixtures/4.6/index.html b/test/fixtures/4.6/index.html new file mode 100644 index 0000000..ac8fa94 --- /dev/null +++ b/test/fixtures/4.6/index.html @@ -0,0 +1 @@ +Version 4.6 diff --git a/test/integration.test.js b/test/integration.test.js index cebc479..156e571 100644 --- a/test/integration.test.js +++ b/test/integration.test.js @@ -1375,3 +1375,40 @@ test('etag header is set', async () => { '"ba114dbc69e41e180362234807f093c3c4628f90"' ); }); + +test('serve index.html for directory whose name contains a dot (e.g. /4.6)', async () => { + const target = '4.6'; + const index = path.join(fixturesFull, target, 'index.html'); + + const url = await getUrl({ + cleanUrls: true, + directoryListing: false, + trailingSlash: false + }); + + const response = await fetch(`${url}/${target}`); + const content = await fs.readFile(index, 'utf8'); + const text = await response.text(); + + expect(response.status).toBe(200); + expect(text).toBe(content); +}); + +test('redirect /4.6/ to /4.6 when trailingSlash is false', async () => { + const target = '4.6'; + + const url = await getUrl({ + cleanUrls: true, + directoryListing: false, + trailingSlash: false + }); + + const response = await fetch(`${url}/${target}/`, { + redirect: 'manual', + follow: 0 + }); + + const location = response.headers.get('location'); + expect(response.status).toBe(301); + expect(location).toBe(`${url}/${target}`); +}); From fcbc82deedb1b9bf3c48e4e5d698dabda83af5d4 Mon Sep 17 00:00:00 2001 From: Ethan Arrowood Date: Mon, 30 Mar 2026 19:02:04 -0600 Subject: [PATCH 2/3] test: expand dotted-path fixture coverage - Rename 4.6/ fixture to 1.4/ (more generic versioning example) - Add 1.2.html to cover cleanUrls serving a .html file whose name contains a dot (requested as /1.2) - Add 1.3/ + 1.3.html to cover the case where both a dotted directory and a same-named .html sibling exist; directory index.html wins --- test/fixtures/1.2.html | 1 + test/fixtures/1.3.html | 1 + test/fixtures/1.3/index.html | 1 + test/fixtures/{4.6 => 1.4}/index.html | 0 test/integration.test.js | 38 ++++++++++++++++++++++++--- 5 files changed, 37 insertions(+), 4 deletions(-) create mode 100644 test/fixtures/1.2.html create mode 100644 test/fixtures/1.3.html create mode 100644 test/fixtures/1.3/index.html rename test/fixtures/{4.6 => 1.4}/index.html (100%) diff --git a/test/fixtures/1.2.html b/test/fixtures/1.2.html new file mode 100644 index 0000000..04e7aa6 --- /dev/null +++ b/test/fixtures/1.2.html @@ -0,0 +1 @@ +Version 1.2 diff --git a/test/fixtures/1.3.html b/test/fixtures/1.3.html new file mode 100644 index 0000000..8816432 --- /dev/null +++ b/test/fixtures/1.3.html @@ -0,0 +1 @@ +Version 1.3 html file diff --git a/test/fixtures/1.3/index.html b/test/fixtures/1.3/index.html new file mode 100644 index 0000000..ec9eff0 --- /dev/null +++ b/test/fixtures/1.3/index.html @@ -0,0 +1 @@ +Version 1.3 directory index diff --git a/test/fixtures/4.6/index.html b/test/fixtures/1.4/index.html similarity index 100% rename from test/fixtures/4.6/index.html rename to test/fixtures/1.4/index.html diff --git a/test/integration.test.js b/test/integration.test.js index 156e571..896a4ac 100644 --- a/test/integration.test.js +++ b/test/integration.test.js @@ -1376,8 +1376,8 @@ test('etag header is set', async () => { ); }); -test('serve index.html for directory whose name contains a dot (e.g. /4.6)', async () => { - const target = '4.6'; +test('serve index.html for directory whose name contains a dot (e.g. /1.4)', async () => { + const target = '1.4'; const index = path.join(fixturesFull, target, 'index.html'); const url = await getUrl({ @@ -1394,8 +1394,8 @@ test('serve index.html for directory whose name contains a dot (e.g. /4.6)', asy expect(text).toBe(content); }); -test('redirect /4.6/ to /4.6 when trailingSlash is false', async () => { - const target = '4.6'; +test('redirect /1.4/ to /1.4 when trailingSlash is false', async () => { + const target = '1.4'; const url = await getUrl({ cleanUrls: true, @@ -1412,3 +1412,33 @@ test('redirect /4.6/ to /4.6 when trailingSlash is false', async () => { expect(response.status).toBe(301); expect(location).toBe(`${url}/${target}`); }); + +test('serve 1.2.html via cleanUrls when requested as /1.2', async () => { + const url = await getUrl({ + cleanUrls: true, + directoryListing: false, + trailingSlash: false + }); + + const content = await fs.readFile(path.join(fixturesFull, '1.2.html'), 'utf8'); + const response = await fetch(`${url}/1.2`); + const text = await response.text(); + + expect(response.status).toBe(200); + expect(text).toBe(content); +}); + +test('prefer directory index.html over .html sibling when both 1.3/ and 1.3.html exist', async () => { + const url = await getUrl({ + cleanUrls: true, + directoryListing: false, + trailingSlash: false + }); + + const content = await fs.readFile(path.join(fixturesFull, '1.3', 'index.html'), 'utf8'); + const response = await fetch(`${url}/1.3`); + const text = await response.text(); + + expect(response.status).toBe(200); + expect(text).toBe(content); +}); From aa7030d343298f3dbc3e39abd1cb1c3c02a7e1f2 Mon Sep 17 00:00:00 2001 From: Ethan Arrowood Date: Mon, 30 Mar 2026 19:03:32 -0600 Subject: [PATCH 3/3] fix: update 1.4 references (fixture content and inline comment) --- src/index.js | 2 +- test/fixtures/1.4/index.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/index.js b/src/index.js index 76ddd35..4ab8997 100644 --- a/src/index.js +++ b/src/index.js @@ -609,7 +609,7 @@ module.exports = async (request, response, config = {}, methods = {}) => { try { stats = await handlers.lstat(absolutePath); // If the path looks like it has an extension but actually resolves to a - // directory (e.g. /docs/4.6 where path.extname returns '.6'), clear stats + // directory (e.g. /docs/1.4 where path.extname returns '.4'), clear stats // so findRelated can fall back to index.html or the .html sibling. if (stats && stats.isDirectory()) { stats = null; diff --git a/test/fixtures/1.4/index.html b/test/fixtures/1.4/index.html index ac8fa94..e3def96 100644 --- a/test/fixtures/1.4/index.html +++ b/test/fixtures/1.4/index.html @@ -1 +1 @@ -Version 4.6 +Version 1.4