koa-classic-server 2.5.2 โ†’ 2.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,624 @@
1
+ /**
2
+ * DT_UNKNOWN filesystem support tests for koa-classic-server
3
+ *
4
+ * Context: On filesystems where readdir({ withFileTypes: true }) returns dirents
5
+ * with DT_UNKNOWN (UV_DIRENT_UNKNOWN = 0), all dirent.is*() methods return false.
6
+ * This occurs on:
7
+ * - NixOS with buildFHSEnv (chroot-like environment for Playwright e2e tests)
8
+ * - overlayfs (used by Docker for image layers)
9
+ * - some FUSE filesystems (sshfs, s3fs, rclone mount)
10
+ * - NFS (some implementations don't support d_type)
11
+ * - ecryptfs (encrypted home directories on Linux)
12
+ *
13
+ * The fix adds a stat() fallback in isFileOrSymlinkToFile(), isDirOrSymlinkToDir(),
14
+ * and show_dir() when the dirent type is unknown (type 0).
15
+ *
16
+ * On standard filesystems (ext4, btrfs, xfs, APFS, NTFS), d_type is always filled
17
+ * correctly, so the stat() fallback is never reached โ€” zero overhead.
18
+ */
19
+
20
+ const fs = require('fs');
21
+ const os = require('os');
22
+ const path = require('path');
23
+ const Koa = require('koa');
24
+ const supertest = require('supertest');
25
+ const koaClassicServer = require('../index.cjs');
26
+
27
+ describe('DT_UNKNOWN filesystem support (NixOS buildFHSEnv, overlayfs, FUSE)', () => {
28
+ let tmpDir;
29
+
30
+ beforeAll(() => {
31
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kcs-dt-unknown-test-'));
32
+
33
+ // Create real files and directories that stat() can resolve
34
+ fs.writeFileSync(
35
+ path.join(tmpDir, 'index.html'),
36
+ '<html><head><title>DT_UNKNOWN Index</title></head><body>Hello from DT_UNKNOWN</body></html>'
37
+ );
38
+ fs.writeFileSync(
39
+ path.join(tmpDir, 'index.ejs'),
40
+ '<html><head><title>EJS DT_UNKNOWN</title></head><body><h1>EJS Works</h1></body></html>'
41
+ );
42
+ fs.writeFileSync(
43
+ path.join(tmpDir, 'style.css'),
44
+ 'body { color: blue; }'
45
+ );
46
+ fs.writeFileSync(
47
+ path.join(tmpDir, 'readme.txt'),
48
+ 'This is a readme file.'
49
+ );
50
+ fs.mkdirSync(path.join(tmpDir, 'subdir'));
51
+ fs.writeFileSync(
52
+ path.join(tmpDir, 'subdir', 'nested.txt'),
53
+ 'nested content'
54
+ );
55
+ });
56
+
57
+ afterAll(() => {
58
+ fs.rmSync(tmpDir, { recursive: true, force: true });
59
+ });
60
+
61
+ /**
62
+ * Helper: create a Dirent with DT_UNKNOWN (type 0).
63
+ * Node.js 18+: new fs.Dirent(name, 0)
64
+ */
65
+ function createUnknownDirent(name) {
66
+ return new fs.Dirent(name, 0);
67
+ }
68
+
69
+ /**
70
+ * Helper: mock fs.promises.readdir to return DT_UNKNOWN dirents for a specific directory.
71
+ * stat() continues to work normally, so the fallback can resolve actual types.
72
+ */
73
+ function mockReaddirWithDtUnknown(targetDir, fileNames) {
74
+ const originalReaddir = fs.promises.readdir;
75
+ const spy = jest.spyOn(fs.promises, 'readdir').mockImplementation(async (dirPath, options) => {
76
+ const resolvedTarget = path.resolve(targetDir);
77
+ const resolvedDir = path.resolve(dirPath);
78
+ if (resolvedDir === resolvedTarget && options && options.withFileTypes) {
79
+ // Return DT_UNKNOWN dirents for all entries
80
+ return fileNames.map(name => createUnknownDirent(name));
81
+ }
82
+ // Fall through to original for other directories
83
+ return originalReaddir.call(fs.promises, dirPath, options);
84
+ });
85
+ return spy;
86
+ }
87
+
88
+ // =========================================================================
89
+ // 1. isFileOrSymlinkToFile with DT_UNKNOWN
90
+ // =========================================================================
91
+ describe('isFileOrSymlinkToFile with DT_UNKNOWN', () => {
92
+ test('should return true for DT_UNKNOWN entry pointing to a regular file', async () => {
93
+ // Use findIndexFile as a proxy to test isFileOrSymlinkToFile behavior
94
+ // When index.html has type 0, it should still be found via stat() fallback
95
+ const spy = mockReaddirWithDtUnknown(tmpDir, ['index.html', 'style.css', 'subdir']);
96
+
97
+ const app = new Koa();
98
+ app.use(koaClassicServer(tmpDir, {
99
+ index: ['index.html'],
100
+ showDirContents: true
101
+ }));
102
+ const server = app.listen();
103
+ const request = supertest(server);
104
+
105
+ try {
106
+ const res = await request.get('/');
107
+ expect(res.status).toBe(200);
108
+ expect(res.text).toContain('DT_UNKNOWN Index');
109
+ expect(res.text).not.toContain('Index of');
110
+ } finally {
111
+ server.close();
112
+ spy.mockRestore();
113
+ }
114
+ });
115
+
116
+ test('should return false for DT_UNKNOWN entry pointing to a directory', async () => {
117
+ // A directory called "subdir" with DT_UNKNOWN should NOT match as an index file
118
+ const spy = mockReaddirWithDtUnknown(tmpDir, ['subdir']);
119
+
120
+ const app = new Koa();
121
+ app.use(koaClassicServer(tmpDir, {
122
+ index: ['subdir'],
123
+ showDirContents: true
124
+ }));
125
+ const server = app.listen();
126
+ const request = supertest(server);
127
+
128
+ try {
129
+ const res = await request.get('/');
130
+ expect(res.status).toBe(200);
131
+ // Should show directory listing since "subdir" is a directory, not a file
132
+ expect(res.text).toContain('Index of');
133
+ } finally {
134
+ server.close();
135
+ spy.mockRestore();
136
+ }
137
+ });
138
+
139
+ test('should return false for DT_UNKNOWN entry pointing to nothing (broken)', async () => {
140
+ // Mock readdir to include a non-existent file
141
+ const spy = mockReaddirWithDtUnknown(tmpDir, ['nonexistent.html', 'index.html']);
142
+
143
+ const app = new Koa();
144
+ app.use(koaClassicServer(tmpDir, {
145
+ index: ['nonexistent.html'],
146
+ showDirContents: true
147
+ }));
148
+ const server = app.listen();
149
+ const request = supertest(server);
150
+
151
+ try {
152
+ const res = await request.get('/');
153
+ expect(res.status).toBe(200);
154
+ // nonexistent.html can't be stat'd, so should show directory listing
155
+ expect(res.text).toContain('Index of');
156
+ } finally {
157
+ server.close();
158
+ spy.mockRestore();
159
+ }
160
+ });
161
+ });
162
+
163
+ // =========================================================================
164
+ // 2. isDirOrSymlinkToDir with DT_UNKNOWN
165
+ // =========================================================================
166
+ describe('isDirOrSymlinkToDir with DT_UNKNOWN', () => {
167
+ test('should return true for DT_UNKNOWN entry pointing to a directory', async () => {
168
+ // Test that isDirOrSymlinkToDir resolves DT_UNKNOWN dirs correctly
169
+ // by checking that findIndexFile does NOT treat a directory as an index file
170
+ const spy = mockReaddirWithDtUnknown(tmpDir, ['subdir', 'index.html']);
171
+
172
+ const app = new Koa();
173
+ app.use(koaClassicServer(tmpDir, {
174
+ index: ['index.html'],
175
+ showDirContents: true
176
+ }));
177
+ const server = app.listen();
178
+ const request = supertest(server);
179
+
180
+ try {
181
+ const res = await request.get('/');
182
+ expect(res.status).toBe(200);
183
+ // index.html (file) should be served, not subdir (directory)
184
+ expect(res.text).toContain('DT_UNKNOWN Index');
185
+ expect(res.text).not.toContain('Index of');
186
+ } finally {
187
+ server.close();
188
+ spy.mockRestore();
189
+ }
190
+ });
191
+
192
+ test('should return false for DT_UNKNOWN entry pointing to a regular file', async () => {
193
+ // Files should NOT be treated as directories
194
+ // If only files exist and no index pattern matches, dir listing should show
195
+ const spy = mockReaddirWithDtUnknown(tmpDir, ['style.css', 'readme.txt']);
196
+
197
+ const app = new Koa();
198
+ app.use(koaClassicServer(tmpDir, {
199
+ index: ['index.html'],
200
+ showDirContents: true
201
+ }));
202
+ const server = app.listen();
203
+ const request = supertest(server);
204
+
205
+ try {
206
+ const res = await request.get('/');
207
+ expect(res.status).toBe(200);
208
+ // No index.html in the mocked readdir results, so directory listing
209
+ expect(res.text).toContain('Index of');
210
+ } finally {
211
+ server.close();
212
+ spy.mockRestore();
213
+ }
214
+ });
215
+ });
216
+
217
+ // =========================================================================
218
+ // 3. findIndexFile with DT_UNKNOWN entries
219
+ // =========================================================================
220
+ describe('findIndexFile with DT_UNKNOWN entries', () => {
221
+ test('should find index.html when all dirents have DT_UNKNOWN type', async () => {
222
+ const spy = mockReaddirWithDtUnknown(tmpDir, ['style.css', 'index.html', 'readme.txt', 'subdir']);
223
+
224
+ const app = new Koa();
225
+ app.use(koaClassicServer(tmpDir, {
226
+ index: ['index.html'],
227
+ showDirContents: true
228
+ }));
229
+ const server = app.listen();
230
+ const request = supertest(server);
231
+
232
+ try {
233
+ const res = await request.get('/');
234
+ expect(res.status).toBe(200);
235
+ expect(res.text).toContain('DT_UNKNOWN Index');
236
+ expect(res.text).not.toContain('Index of');
237
+ } finally {
238
+ server.close();
239
+ spy.mockRestore();
240
+ }
241
+ });
242
+
243
+ test('should find index.ejs via string pattern when type is unknown', async () => {
244
+ const spy = mockReaddirWithDtUnknown(tmpDir, ['index.ejs', 'style.css', 'subdir']);
245
+
246
+ const app = new Koa();
247
+ app.use(koaClassicServer(tmpDir, {
248
+ index: ['index.ejs'],
249
+ showDirContents: true
250
+ }));
251
+ const server = app.listen();
252
+ const request = supertest(server);
253
+
254
+ try {
255
+ const res = await request.get('/');
256
+ expect(res.status).toBe(200);
257
+ expect(res.text).toContain('EJS DT_UNKNOWN');
258
+ expect(res.text).not.toContain('Index of');
259
+ } finally {
260
+ server.close();
261
+ spy.mockRestore();
262
+ }
263
+ });
264
+
265
+ test('should find index.ejs via RegExp pattern when type is unknown', async () => {
266
+ const spy = mockReaddirWithDtUnknown(tmpDir, ['index.ejs', 'style.css', 'subdir']);
267
+
268
+ const app = new Koa();
269
+ app.use(koaClassicServer(tmpDir, {
270
+ index: [/index\.[eE][jJ][sS]/],
271
+ showDirContents: true
272
+ }));
273
+ const server = app.listen();
274
+ const request = supertest(server);
275
+
276
+ try {
277
+ const res = await request.get('/');
278
+ expect(res.status).toBe(200);
279
+ expect(res.text).toContain('EJS DT_UNKNOWN');
280
+ expect(res.text).not.toContain('Index of');
281
+ } finally {
282
+ server.close();
283
+ spy.mockRestore();
284
+ }
285
+ });
286
+
287
+ test('should not find index in directory with only subdirectories (all DT_UNKNOWN)', async () => {
288
+ const spy = mockReaddirWithDtUnknown(tmpDir, ['subdir']);
289
+
290
+ const app = new Koa();
291
+ app.use(koaClassicServer(tmpDir, {
292
+ index: ['index.html'],
293
+ showDirContents: true
294
+ }));
295
+ const server = app.listen();
296
+ const request = supertest(server);
297
+
298
+ try {
299
+ const res = await request.get('/');
300
+ expect(res.status).toBe(200);
301
+ // No file matches index.html, so directory listing should appear
302
+ expect(res.text).toContain('Index of');
303
+ } finally {
304
+ server.close();
305
+ spy.mockRestore();
306
+ }
307
+ });
308
+ });
309
+
310
+ // =========================================================================
311
+ // 4. show_dir with DT_UNKNOWN entries
312
+ // =========================================================================
313
+ describe('show_dir with DT_UNKNOWN entries', () => {
314
+ test('should list files with DT_UNKNOWN as their resolved type (FILE/DIR)', async () => {
315
+ const spy = mockReaddirWithDtUnknown(tmpDir, ['style.css', 'readme.txt', 'subdir']);
316
+
317
+ const app = new Koa();
318
+ app.use(koaClassicServer(tmpDir, {
319
+ index: [],
320
+ showDirContents: true
321
+ }));
322
+ const server = app.listen();
323
+ const request = supertest(server);
324
+
325
+ try {
326
+ const res = await request.get('/');
327
+ expect(res.status).toBe(200);
328
+ expect(res.text).toContain('Index of');
329
+ // All three entries should appear in the listing
330
+ expect(res.text).toContain('style.css');
331
+ expect(res.text).toContain('readme.txt');
332
+ expect(res.text).toContain('subdir');
333
+ // subdir should be resolved as DIR
334
+ expect(res.text).toMatch(/subdir[\s\S]*?DIR/);
335
+ } finally {
336
+ server.close();
337
+ spy.mockRestore();
338
+ }
339
+ });
340
+
341
+ test('should not skip entries or log "Unknown file type: 0"', async () => {
342
+ const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
343
+ const spy = mockReaddirWithDtUnknown(tmpDir, ['style.css', 'readme.txt', 'subdir']);
344
+
345
+ const app = new Koa();
346
+ app.use(koaClassicServer(tmpDir, {
347
+ index: [],
348
+ showDirContents: true
349
+ }));
350
+ const server = app.listen();
351
+ const request = supertest(server);
352
+
353
+ try {
354
+ const res = await request.get('/');
355
+ expect(res.status).toBe(200);
356
+ // All entries should be present (not skipped)
357
+ expect(res.text).toContain('style.css');
358
+ expect(res.text).toContain('readme.txt');
359
+ expect(res.text).toContain('subdir');
360
+ // Should NOT have logged "Unknown file type: 0"
361
+ const unknownTypeCalls = consoleSpy.mock.calls.filter(
362
+ call => call[0] === 'Unknown file type:' && call[1] === 0
363
+ );
364
+ expect(unknownTypeCalls).toHaveLength(0);
365
+ } finally {
366
+ server.close();
367
+ spy.mockRestore();
368
+ consoleSpy.mockRestore();
369
+ }
370
+ });
371
+
372
+ test('should show correct mime types for DT_UNKNOWN files', async () => {
373
+ const spy = mockReaddirWithDtUnknown(tmpDir, ['style.css', 'readme.txt']);
374
+
375
+ const app = new Koa();
376
+ app.use(koaClassicServer(tmpDir, {
377
+ index: [],
378
+ showDirContents: true
379
+ }));
380
+ const server = app.listen();
381
+ const request = supertest(server);
382
+
383
+ try {
384
+ const res = await request.get('/');
385
+ expect(res.status).toBe(200);
386
+ // CSS file should show text/css mime type
387
+ expect(res.text).toMatch(/style\.css[\s\S]*?text\/css/);
388
+ // TXT file should show text/plain mime type
389
+ expect(res.text).toMatch(/readme\.txt[\s\S]*?text\/plain/);
390
+ } finally {
391
+ server.close();
392
+ spy.mockRestore();
393
+ }
394
+ });
395
+
396
+ test('should show correct sizes for DT_UNKNOWN files', async () => {
397
+ const spy = mockReaddirWithDtUnknown(tmpDir, ['style.css', 'readme.txt']);
398
+
399
+ const app = new Koa();
400
+ app.use(koaClassicServer(tmpDir, {
401
+ index: [],
402
+ showDirContents: true
403
+ }));
404
+ const server = app.listen();
405
+ const request = supertest(server);
406
+
407
+ try {
408
+ const res = await request.get('/');
409
+ expect(res.status).toBe(200);
410
+ // Files should have size values (not '-' which would indicate skipped/broken)
411
+ // style.css is 21 bytes = "21 B"
412
+ expect(res.text).toMatch(/style\.css[\s\S]*?\d+\s*B/);
413
+ // readme.txt is 22 bytes = "22 B"
414
+ expect(res.text).toMatch(/readme\.txt[\s\S]*?\d+\s*B/);
415
+ } finally {
416
+ server.close();
417
+ spy.mockRestore();
418
+ }
419
+ });
420
+ });
421
+
422
+ // =========================================================================
423
+ // 5. Integration: full request with DT_UNKNOWN filesystem
424
+ // =========================================================================
425
+ describe('integration: full request with DT_UNKNOWN filesystem', () => {
426
+ test('GET / serves index file instead of directory listing', async () => {
427
+ const spy = mockReaddirWithDtUnknown(tmpDir, ['index.html', 'style.css', 'readme.txt', 'subdir']);
428
+
429
+ const app = new Koa();
430
+ app.use(koaClassicServer(tmpDir, {
431
+ index: ['index.html'],
432
+ showDirContents: true
433
+ }));
434
+ const server = app.listen();
435
+ const request = supertest(server);
436
+
437
+ try {
438
+ const res = await request.get('/');
439
+ expect(res.status).toBe(200);
440
+ expect(res.text).toContain('DT_UNKNOWN Index');
441
+ expect(res.text).toContain('Hello from DT_UNKNOWN');
442
+ expect(res.text).not.toContain('Index of');
443
+ } finally {
444
+ server.close();
445
+ spy.mockRestore();
446
+ }
447
+ });
448
+
449
+ test('GET /somefile.txt serves the file with 200', async () => {
450
+ // Direct file access uses stat() at the top level, so it works
451
+ // regardless of DT_UNKNOWN โ€” this verifies the direct path still works
452
+ const spy = mockReaddirWithDtUnknown(tmpDir, ['readme.txt', 'style.css', 'subdir']);
453
+
454
+ const app = new Koa();
455
+ app.use(koaClassicServer(tmpDir, {
456
+ index: [],
457
+ showDirContents: true
458
+ }));
459
+ const server = app.listen();
460
+ const request = supertest(server);
461
+
462
+ try {
463
+ const res = await request.get('/readme.txt');
464
+ expect(res.status).toBe(200);
465
+ expect(res.text).toContain('This is a readme file.');
466
+ } finally {
467
+ server.close();
468
+ spy.mockRestore();
469
+ }
470
+ });
471
+
472
+ test('directory listing shows all entries correctly', async () => {
473
+ const spy = mockReaddirWithDtUnknown(tmpDir, ['index.html', 'style.css', 'readme.txt', 'subdir', 'index.ejs']);
474
+
475
+ const app = new Koa();
476
+ app.use(koaClassicServer(tmpDir, {
477
+ index: [],
478
+ showDirContents: true
479
+ }));
480
+ const server = app.listen();
481
+ const request = supertest(server);
482
+
483
+ try {
484
+ const res = await request.get('/');
485
+ expect(res.status).toBe(200);
486
+ expect(res.text).toContain('Index of');
487
+
488
+ // All 5 entries should be listed
489
+ expect(res.text).toContain('index.html');
490
+ expect(res.text).toContain('style.css');
491
+ expect(res.text).toContain('readme.txt');
492
+ expect(res.text).toContain('subdir');
493
+ expect(res.text).toContain('index.ejs');
494
+
495
+ // subdir should show as DIR
496
+ expect(res.text).toMatch(/subdir[\s\S]*?DIR/);
497
+
498
+ // Files should have their correct mime types
499
+ expect(res.text).toMatch(/style\.css[\s\S]*?text\/css/);
500
+ expect(res.text).toMatch(/readme\.txt[\s\S]*?text\/plain/);
501
+ expect(res.text).toMatch(/index\.html[\s\S]*?text\/html/);
502
+ } finally {
503
+ server.close();
504
+ spy.mockRestore();
505
+ }
506
+ });
507
+
508
+ test('GET / with EJS template engine and DT_UNKNOWN still serves index', async () => {
509
+ const spy = mockReaddirWithDtUnknown(tmpDir, ['index.ejs', 'style.css', 'subdir']);
510
+
511
+ const app = new Koa();
512
+ app.use(koaClassicServer(tmpDir, {
513
+ index: ['index.ejs'],
514
+ showDirContents: true,
515
+ template: {
516
+ ext: ['ejs'],
517
+ render: async (ctx, next, filePath) => {
518
+ const content = await fs.promises.readFile(filePath, 'utf-8');
519
+ ctx.type = 'text/html';
520
+ ctx.body = content;
521
+ }
522
+ }
523
+ }));
524
+ const server = app.listen();
525
+ const request = supertest(server);
526
+
527
+ try {
528
+ const res = await request.get('/');
529
+ expect(res.status).toBe(200);
530
+ expect(res.headers['content-type']).toMatch(/html/);
531
+ expect(res.text).toContain('EJS DT_UNKNOWN');
532
+ expect(res.text).not.toContain('Index of');
533
+ } finally {
534
+ server.close();
535
+ spy.mockRestore();
536
+ }
537
+ });
538
+ });
539
+
540
+ // =========================================================================
541
+ // 6. Edge cases
542
+ // =========================================================================
543
+ describe('edge cases', () => {
544
+ test('mixed regular dirents and DT_UNKNOWN dirents work together', async () => {
545
+ // Only mock readdir for the specific directory, verify normal files
546
+ // still work alongside DT_UNKNOWN entries
547
+ const originalReaddir = fs.promises.readdir;
548
+ const spy = jest.spyOn(fs.promises, 'readdir').mockImplementation(async (dirPath, options) => {
549
+ const resolvedTarget = path.resolve(tmpDir);
550
+ const resolvedDir = path.resolve(dirPath);
551
+ if (resolvedDir === resolvedTarget && options && options.withFileTypes) {
552
+ // Mix: some regular (type 1), some DT_UNKNOWN (type 0)
553
+ return [
554
+ new fs.Dirent('index.html', 1), // Regular file
555
+ new fs.Dirent('style.css', 0), // DT_UNKNOWN
556
+ new fs.Dirent('subdir', 0), // DT_UNKNOWN
557
+ ];
558
+ }
559
+ return originalReaddir.call(fs.promises, dirPath, options);
560
+ });
561
+
562
+ const app = new Koa();
563
+ app.use(koaClassicServer(tmpDir, {
564
+ index: [],
565
+ showDirContents: true
566
+ }));
567
+ const server = app.listen();
568
+ const request = supertest(server);
569
+
570
+ try {
571
+ const res = await request.get('/');
572
+ expect(res.status).toBe(200);
573
+ expect(res.text).toContain('Index of');
574
+ // All entries should appear
575
+ expect(res.text).toContain('index.html');
576
+ expect(res.text).toContain('style.css');
577
+ expect(res.text).toContain('subdir');
578
+ } finally {
579
+ server.close();
580
+ spy.mockRestore();
581
+ }
582
+ });
583
+
584
+ test('DT_UNKNOWN Dirent has all is*() methods returning false', () => {
585
+ // Verify our test helper creates correct DT_UNKNOWN dirents
586
+ const d = createUnknownDirent('test.txt');
587
+ expect(d.isFile()).toBe(false);
588
+ expect(d.isDirectory()).toBe(false);
589
+ expect(d.isSymbolicLink()).toBe(false);
590
+ expect(d.isBlockDevice()).toBe(false);
591
+ expect(d.isCharacterDevice()).toBe(false);
592
+ expect(d.isFIFO()).toBe(false);
593
+ expect(d.isSocket()).toBe(false);
594
+
595
+ // Verify Symbol(type) is 0
596
+ const syms = Object.getOwnPropertySymbols(d);
597
+ expect(d[syms[0]]).toBe(0);
598
+ });
599
+
600
+ test('index priority is preserved with DT_UNKNOWN entries', async () => {
601
+ // When multiple index files exist, the first pattern should win
602
+ const spy = mockReaddirWithDtUnknown(tmpDir, ['index.ejs', 'index.html', 'style.css']);
603
+
604
+ const app = new Koa();
605
+ app.use(koaClassicServer(tmpDir, {
606
+ index: ['index.html', 'index.ejs'],
607
+ showDirContents: true
608
+ }));
609
+ const server = app.listen();
610
+ const request = supertest(server);
611
+
612
+ try {
613
+ const res = await request.get('/');
614
+ expect(res.status).toBe(200);
615
+ // index.html should win because it's first in the index array
616
+ expect(res.text).toContain('DT_UNKNOWN Index');
617
+ expect(res.text).not.toContain('EJS DT_UNKNOWN');
618
+ } finally {
619
+ server.close();
620
+ spy.mockRestore();
621
+ }
622
+ });
623
+ });
624
+ });
package/docs/CHANGELOG.md CHANGED
@@ -5,6 +5,109 @@ All notable changes to koa-classic-server will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [2.6.1] - 2026-03-04
9
+
10
+ ### ๐Ÿ› Bug Fix
11
+
12
+ #### Fixed DT_UNKNOWN Handling (type 0) on overlayfs, NFS, FUSE, NixOS buildFHSEnv, ecryptfs
13
+ - **Issue**: On filesystems where `readdir({ withFileTypes: true })` returns dirents with `DT_UNKNOWN` (type 0), all `dirent.is*()` methods return `false`. This caused three failures:
14
+ 1. `isFileOrSymlinkToFile()` missed valid files โ€” `findIndexFile()` returned empty results, so `GET /` showed a directory listing instead of rendering the index file
15
+ 2. `isDirOrSymlinkToDir()` missed valid directories โ€” directory type resolution failed
16
+ 3. `show_dir()` skipped entries with type 0, logging `"Unknown file type: 0"` โ€” directory listings appeared empty or partial
17
+ - **Affected environments**: overlayfs (Docker image layers), NFS (some implementations), FUSE filesystems (sshfs, s3fs, rclone mount), NixOS with buildFHSEnv, ecryptfs (encrypted home directories), and any filesystem that doesn't fill `d_type` in the kernel's `getdents64` syscall
18
+ - **Impact**: HIGH โ€” Server unusable on affected filesystems (index file not served, directory listing empty)
19
+ - **Fix**: Added `fs.promises.stat()` fallback in all three locations when none of the `dirent.is*()` type methods return `true` (i.e., type is genuinely unknown). On standard filesystems (ext4, btrfs, xfs, APFS, NTFS), `d_type` is always filled correctly, so the `stat()` fallback is never reached โ€” **zero performance overhead** on the fast path.
20
+ - **Code**:
21
+ - `isFileOrSymlinkToFile()` โ€” DT_UNKNOWN fallback via `stat().isFile()`
22
+ - `isDirOrSymlinkToDir()` โ€” DT_UNKNOWN fallback via `stat().isDirectory()`
23
+ - `show_dir()` โ€” Accept type 0 entries and resolve via `stat()` instead of skipping them
24
+ - **Reference**: Linux `man 2 getdents` โ€” *"Currently, only some filesystems have full support for returning the file type in d_type. All applications must properly handle a return of DT_UNKNOWN."*
25
+
26
+ ### ๐Ÿงช Testing
27
+ - Added `__tests__/dt-unknown.test.js` with 20 tests covering:
28
+ - `isFileOrSymlinkToFile` / `isDirOrSymlinkToDir` with DT_UNKNOWN dirents
29
+ - `findIndexFile` with all-unknown-type entries (string and RegExp patterns)
30
+ - `show_dir` rendering (resolved types, no skipped entries, correct MIME types and sizes)
31
+ - Full integration tests (index file serving, direct file access, complete directory listing)
32
+ - Edge cases (mixed regular + DT_UNKNOWN dirents, index priority, Dirent type 0 verification)
33
+ - Tests use `jest.spyOn(fs.promises, 'readdir')` to mock DT_UNKNOWN dirents via `new fs.Dirent(name, 0)` while keeping `fs.promises.stat()` working normally
34
+ - All 329 tests pass across 12 test suites (zero regressions)
35
+
36
+ ### ๐Ÿ“ฆ Package Changes
37
+ - **Version**: `2.6.0` โ†’ `2.6.1`
38
+ - **Semver**: Patch version bump (bug fix only, no API changes)
39
+
40
+ ---
41
+
42
+ ## [2.6.0] - 2026-03-01
43
+
44
+ ### ๐Ÿ“ฆ Dependency Upgrades
45
+
46
+ #### mime-types: ^2.1.35 โ†’ ^3.0.2 (Major)
47
+ - **Breaking change upstream**: New `mimeScore` algorithm for extension conflict resolution
48
+ - **Impact on this project**: Minimal โ€” the 11 changed MIME mappings affect only uncommon extensions
49
+ - **Notable mapping changes**:
50
+ - `.wav`: `audio/wave` โ†’ `audio/wav` (equivalent, all browsers accept both)
51
+ - `.js`: `application/javascript` โ†’ `text/javascript` (correct per RFC 9239)
52
+ - `.rtf`: `text/rtf` โ†’ `application/rtf` (marginal, rare usage)
53
+ - `.mp4`: Unchanged in v3.0.2 โ€” still resolves to `video/mp4`
54
+ - **Node.js requirement**: mime-types 3 requires Node.js >= 18
55
+
56
+ #### ejs: ^3.1.10 โ†’ ^4.0.0 (Major)
57
+ - **Breaking changes upstream**: None affecting this project
58
+ - EJS 4 removed deprecated `with()` statement support (this project never used it)
59
+ - EJS 4 added stricter `exports` map in package.json
60
+ - **API fully compatible**: `ejs.render()` and `ejs.renderFile()` work identically
61
+ - **Security**: EJS 3.x is EOL โ€” v4 resolves known CVEs in the 3.x line
62
+
63
+ ### ๐Ÿ”ง Configuration Changes
64
+
65
+ #### Added `engines` field
66
+ - Added `"engines": { "node": ">=18" }` to package.json
67
+ - Formalizes the Node.js minimum version requirement imposed by mime-types 3
68
+
69
+ #### Tightened Koa peerDependency for 2.x
70
+ - **koa**: `"^2.0.0 || >=3.1.2"` โ†’ `"^2.16.4 || >=3.1.2"`
71
+ - Excludes Koa 2.0.0โ€“2.16.3 which are affected by 4 known CVEs:
72
+ - CVE-2025-25200: ReDoS via `X-Forwarded-Proto`/`X-Forwarded-Host` (CVSS 9.2, fixed in 2.15.4)
73
+ - CVE-2025-32379: XSS via `ctx.redirect()` (fixed in 2.16.1)
74
+ - CVE-2025-62595: Open Redirect via trailing `//` (fixed in 2.16.3)
75
+ - CVE-2026-27959: Host Header Injection via `ctx.hostname` (CVSS 7.5, fixed in 2.16.4)
76
+
77
+ ### ๐Ÿงช Testing
78
+ - All 309 tests pass across 11 test suites (zero regressions)
79
+ - No code changes required โ€” both library upgrades are API-compatible
80
+
81
+ ### ๐Ÿ“ฆ Package Changes
82
+ - **Version**: `2.5.2` โ†’ `2.6.0`
83
+ - **Semver**: Minor version bump (dependency upgrades, no API changes)
84
+
85
+ ---
86
+
87
+ ## [2.5.2] - 2026-03-01
88
+
89
+ ### ๐Ÿ”’ Security Fix
90
+
91
+ #### Resolved all 11 npm audit vulnerabilities
92
+ - **jest**: `^29.7.0` โ†’ `^30.2.0` (major โ€” fixes minimatch ReDoS, brace-expansion ReDoS, @babel/helpers inefficient RegExp)
93
+ - **supertest**: `^7.0.0` โ†’ `^7.2.2` (fixes critical form-data unsafe random boundary)
94
+ - **inquirer**: `^12.4.1` โ†’ `^13.3.0` (fixes tmp arbitrary file write via symlink, external-editor chain)
95
+ - **autocannon**: `^7.15.0` โ†’ `^8.0.0` (major)
96
+
97
+ #### Updated peerDependency
98
+ - **koa**: `"^2.0.0 || ^3.0.0"` โ†’ `"^2.0.0 || >=3.1.2"`
99
+ - Excludes Koa 3.0.0โ€“3.1.1 which had Host Header Injection via `ctx.hostname`
100
+
101
+ ### ๐Ÿงช Testing
102
+ - All 309 tests pass across 11 test suites (zero regressions)
103
+ - `npm audit` reports 0 vulnerabilities
104
+
105
+ ### ๐Ÿ“ฆ Package Changes
106
+ - **Version**: `2.5.1` โ†’ `2.5.2`
107
+ - **Semver**: Patch version bump (security fixes only, no API changes)
108
+
109
+ ---
110
+
8
111
  ## [2.5.1] - 2026-03-01
9
112
 
10
113
  ### ๐Ÿ“ Documentation
package/index.cjs CHANGED
@@ -173,7 +173,13 @@ module.exports = function koaClassicServer(
173
173
 
174
174
  /**
175
175
  * Returns true if dirent is a regular file or a symlink pointing to a regular file.
176
- * Uses fs.promises.stat (which follows symlinks) only when dirent.isSymbolicLink() is true.
176
+ * Uses fs.promises.stat (which follows symlinks) when dirent.isSymbolicLink() is true,
177
+ * or when the dirent type is unknown (DT_UNKNOWN / type 0).
178
+ *
179
+ * DT_UNKNOWN occurs on overlayfs, NFS, FUSE, NixOS buildFHSEnv, ecryptfs,
180
+ * and any filesystem that doesn't fill d_type in the kernel's getdents64 syscall.
181
+ * On standard filesystems (ext4, btrfs, xfs, APFS, NTFS), d_type is always
182
+ * filled correctly, so the stat() fallback is never reached.
177
183
  */
178
184
  async function isFileOrSymlinkToFile(dirent, dirPath) {
179
185
  if (dirent.isFile()) return true;
@@ -185,12 +191,23 @@ module.exports = function koaClassicServer(
185
191
  return false; // Broken or circular symlink
186
192
  }
187
193
  }
194
+ // DT_UNKNOWN fallback: when none of the type methods return true,
195
+ // the filesystem didn't report d_type โ€” resolve via stat()
196
+ if (!dirent.isDirectory() && !dirent.isBlockDevice() && !dirent.isCharacterDevice() && !dirent.isFIFO() && !dirent.isSocket()) {
197
+ try {
198
+ const realStat = await fs.promises.stat(path.join(dirPath, dirent.name));
199
+ return realStat.isFile();
200
+ } catch {
201
+ return false;
202
+ }
203
+ }
188
204
  return false;
189
205
  }
190
206
 
191
207
  /**
192
208
  * Returns true if dirent is a directory or a symlink pointing to a directory.
193
- * Uses fs.promises.stat (which follows symlinks) only when dirent.isSymbolicLink() is true.
209
+ * Uses fs.promises.stat (which follows symlinks) when dirent.isSymbolicLink() is true,
210
+ * or when the dirent type is unknown (DT_UNKNOWN / type 0).
194
211
  */
195
212
  async function isDirOrSymlinkToDir(dirent, dirPath) {
196
213
  if (dirent.isDirectory()) return true;
@@ -202,6 +219,15 @@ module.exports = function koaClassicServer(
202
219
  return false; // Broken or circular symlink
203
220
  }
204
221
  }
222
+ // DT_UNKNOWN fallback: resolve via stat() when type is unknown
223
+ if (!dirent.isFile() && !dirent.isBlockDevice() && !dirent.isCharacterDevice() && !dirent.isFIFO() && !dirent.isSocket()) {
224
+ try {
225
+ const realStat = await fs.promises.stat(path.join(dirPath, dirent.name));
226
+ return realStat.isDirectory();
227
+ } catch {
228
+ return false;
229
+ }
230
+ }
205
231
  return false;
206
232
  }
207
233
 
@@ -662,7 +688,7 @@ module.exports = function koaClassicServer(
662
688
  const s_name = item.name.toString();
663
689
  const type = item[sy_type];
664
690
 
665
- if (type !== 1 && type !== 2 && type !== 3) {
691
+ if (type !== 0 && type !== 1 && type !== 2 && type !== 3) {
666
692
  console.error("Unknown file type:", type);
667
693
  continue;
668
694
  }
@@ -677,16 +703,21 @@ module.exports = function koaClassicServer(
677
703
  itemUri = `${baseUrl}/${encodeURIComponent(s_name)}`;
678
704
  }
679
705
 
680
- // Resolve symlinks to their effective type
706
+ // Resolve symlinks and DT_UNKNOWN entries to their effective type
681
707
  let effectiveType = type;
682
708
  let isBrokenSymlink = false;
683
- if (type === 3) {
709
+ if (type === 3 || type === 0) {
710
+ // type 3 = symlink, type 0 = DT_UNKNOWN (overlayfs, NFS, FUSE, NixOS buildFHSEnv, ecryptfs)
684
711
  try {
685
712
  const realStat = await fs.promises.stat(itemPath);
686
713
  if (realStat.isFile()) effectiveType = 1;
687
714
  else if (realStat.isDirectory()) effectiveType = 2;
688
715
  } catch {
689
- isBrokenSymlink = true; // Broken or circular symlink
716
+ if (type === 3) {
717
+ isBrokenSymlink = true; // Broken or circular symlink
718
+ } else {
719
+ continue; // DT_UNKNOWN entry that can't be stat'd โ€” skip it
720
+ }
690
721
  }
691
722
  }
692
723
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koa-classic-server",
3
- "version": "2.5.2",
3
+ "version": "2.6.1",
4
4
  "description": "High-performance Koa middleware for serving static files with Apache-like directory listing, HTTP caching, template engine support, and comprehensive security fixes",
5
5
  "main": "index.cjs",
6
6
  "exports": {
@@ -33,15 +33,18 @@
33
33
  "type": "git",
34
34
  "url": "https://github.com/italopaesano/koa-classic-server"
35
35
  },
36
+ "engines": {
37
+ "node": ">=18"
38
+ },
36
39
  "dependencies": {
37
- "mime-types": "^2.1.35"
40
+ "mime-types": "^3.0.2"
38
41
  },
39
42
  "peerDependencies": {
40
- "koa": "^2.0.0 || >=3.1.2"
43
+ "koa": "^2.16.4 || >=3.1.2"
41
44
  },
42
45
  "devDependencies": {
43
46
  "autocannon": "^8.0.0",
44
- "ejs": "^3.1.10",
47
+ "ejs": "^4.0.0",
45
48
  "inquirer": "^13.3.0",
46
49
  "jest": "^30.2.0",
47
50
  "supertest": "^7.2.2"