redline
← arena

Normalise the separators

PrincipalTypeScriptStatic filesMined
MINED FROM REAL HISTORYhonojs/honoMIT

The code below is the file exactly as it stood in honojs/hono. The defect was introduced on 2023-11-04, reviewed, merged, and fixed on 2026-05-24 in commit 82dad629 — after 932 days in the tree.

The commit was written by a human contributor to honojs/hono, not by an agent. The ticket and the description on the next two panels are ours — a reconstruction of the case the original change made, so you meet it the way its reviewer did. The code, the defect and the dates are untouched.

STATIC-12the ticket

Static file paths break on Windows-style separators

Some clients send asset paths with backslashes rather than slashes. The resolver should normalise a request path to a single separator style before it touches the filesystem, so the same asset resolves the same way whichever style the client used.

Traversal must stay blocked, obviously.

the case the change madereconstructed by us

The resolver already strips a leading separator, blocks any path containing a `..` segment, and converts backslashes to slashes so a Windows-style request resolves the same as a POSIX one. It also refuses absolute paths when no root is configured.

I reviewed the whole function against the traversal cases and it holds — `..` is rejected before any normalisation happens, so there is no ordering hole. Existing tests pass.

Our reconstruction of the argument the real change made, not a quotation of it. Fluent, specific, and not evidence of anything — which is the point.

src/utils/filepath.ts55 lines
1type FilePathOptions = {
2 filename: string
3 root?: string
4 defaultDocument?: string
5}
6
7export const getFilePath = (options: FilePathOptions): string | undefined => {
8 let filename = options.filename
9 const defaultDocument = options.defaultDocument || 'index.html'
10
11 if (filename.endsWith('/')) {
12 // /top/ => /top/index.html
13 filename = filename.concat(defaultDocument)
14 } else if (!filename.match(/\.[a-zA-Z0-9_-]+$/)) {
15 // /top => /top/index.html
16 filename = filename.concat('/' + defaultDocument)
17 }
18
19 const path = getFilePathWithoutDefaultDocument({
20 root: options.root,
21 filename,
22 })
23
24 return path
25}
26
27export const getFilePathWithoutDefaultDocument = (
28 options: Omit<FilePathOptions, 'defaultDocument'>
29): string | undefined => {
30 let root = options.root || ''
31 let filename = options.filename
32
33 if (/(?:^|[\/\\])\.\.(?:$|[\/\\])/.test(filename)) {
34 return
35 }
36
37 // /foo.html => foo.html
38 filename = filename.replace(/^\.?[\/\\]/, '')
39
40 // foo\bar.txt => foo/bar.txt
41 filename = filename.replace(/\\/, '/')
42
43 // assets/ => assets
44 root = root.replace(/\/$/, '')
45
46 // ./assets/foo.html => assets/foo.html
47 let path = root ? root + '/' + filename : filename
48 path = path.replace(/^\.?\//, '')
49
50 if (root[0] !== '/' && path[0] === '/') {
51 return
52 }
53
54 return path
55}