Skip to content

Commit e52f0fb

Browse files
fix: encode NTFS-reserved characters in route-derived artifact filenames
Dynamic-route patterns (/:param, /*) and concrete generateStaticParams values flow into generated artifact names: preload/loader assets via cleanUrl() and the static HTML path in buildPage. The characters < > : " | ? * are reserved on Windows filesystems - a colon-led segment fails the write outright and a mid-name colon silently creates an NTFS alternate data stream. The per-page build error is caught and logged as "skipping page" while the build exits 0, so on Windows every root-level (or route-group-rooted) dynamic route and every non-SSR dynamic route silently disappears from routeToBuildInfo and `one serve` returns a bare 404 for it; surviving nested +ssr routes write their preloads into invisible ADS streams that vanish from any copied or deployed dist. Encode reserved characters as =hh (hex char code, with literal = self-escaped first so decoding is unambiguous) in cleanUrl()'s filename transform and in the htmlPath artifact, and decode them in getPathFromLoaderPath(). The encoding is identity for paths without reserved characters, so artifact names for every currently-working route are byte-identical; serve-side postfix matching (PRELOAD_JS_POSTFIX_REGEX etc.) is unaffected. Validated on Windows 11: tests/test-app-cases dynamic-route-at-root and catch-all prod phases go 404 -> 200 (routes restored to the manifest, zero "skipping page" warnings) and test-spa-shell-routing goes from 8 failed prod tests to 27/27 in both dev and prod; packages/one vitest 534 passed with 11 new roundtrip/reserved-character tests. On Linux/macOS output is unchanged for clean paths, and pattern-named artifacts become portable (no more :/* in dist filenames for deploy tooling to reject).
1 parent 8bde055 commit e52f0fb

4 files changed

Lines changed: 102 additions & 6 deletions

File tree

packages/one/src/cli/buildPage.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ import { normalizePath } from 'vite'
44
import * as constants from '../constants'
55
import { LOADER_JS_POSTFIX_UNCACHED } from '../constants'
66
import type { LoaderProps } from '../types'
7-
import { getLoaderPath, getPreloadCSSPath, getPreloadPath } from '../utils/cleanUrl'
7+
import {
8+
encodeReservedFilenameChars,
9+
getLoaderPath,
10+
getPreloadCSSPath,
11+
getPreloadPath,
12+
} from '../utils/cleanUrl'
813
import { isResponse } from '../utils/isResponse'
914
import { toAbsolute, toAbsoluteUrl } from '../utils/toAbsolute'
1015
import { replaceLoader } from '../vite/replaceLoader'
@@ -64,7 +69,12 @@ export async function buildPage(
6469
const render = await getRender(serverEntry)
6570
recordTiming('getRender', performance.now() - t0)
6671

67-
const htmlPath = `${path.endsWith('/') ? `${removeTrailingSlash(path)}/index` : path}.html`
72+
// encode NTFS-reserved characters (`:` from `/:param` patterns, `*` from
73+
// catch-alls, hostile param values) so the artifact is writable on Windows;
74+
// routeMap stores this same string, so serve-side lookups stay consistent
75+
const htmlPath = encodeReservedFilenameChars(
76+
`${path.endsWith('/') ? `${removeTrailingSlash(path)}/index` : path}.html`
77+
)
6878
// forward-slash for cross-platform manifest hygiene (matches serverJsPath)
6979
const clientJsPath = clientManifestEntry
7080
? normalizePath(join(clientDir, clientManifestEntry.file))

packages/one/src/utils/cleanUrl.test.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { describe, expect, it } from 'vitest'
2-
import { getLoaderPath, getPathFromLoaderPath } from './cleanUrl'
2+
import {
3+
encodeReservedFilenameChars,
4+
getLoaderPath,
5+
getPathFromLoaderPath,
6+
getPreloadPath,
7+
} from './cleanUrl'
38

49
/**
510
* tests the cleanUrl encode/decode roundtrip used for loader URLs.
@@ -91,6 +96,63 @@ describe('getLoaderPath format', () => {
9196
})
9297
})
9398

99+
describe('NTFS-reserved characters in route-derived names', () => {
100+
// route patterns reach these functions verbatim when a dynamic route has no
101+
// generateStaticParams (every dynamic +ssr/+spa route at build time)
102+
it('roundtrips a root dynamic route pattern', () => {
103+
expect(roundtrip('/:param')).toBe('/:param')
104+
})
105+
106+
it('roundtrips a nested dynamic route pattern', () => {
107+
expect(roundtrip('/dashboard/:appId')).toBe('/dashboard/:appId')
108+
})
109+
110+
it('roundtrips a catch-all pattern', () => {
111+
expect(roundtrip('/*')).toBe('/*')
112+
})
113+
114+
it('roundtrips hostile concrete param values (chars that survive URL parsing raw)', () => {
115+
// getLoaderPath URL-parses its input; WHATWG URL percent-encodes " < > |
116+
// in pathnames but passes : and * through raw — those two are the ones
117+
// that reach generated filenames verbatim (and are the NTFS killers)
118+
expect(roundtrip('/blog/a:b*c')).toBe('/blog/a:b*c')
119+
})
120+
121+
it('emits filesystem-legal names for the full reserved set via preload paths', () => {
122+
// getPreloadPath does not URL-parse, so every reserved char reaches the
123+
// encoder raw — assert none survive into the filename
124+
expect(getPreloadPath('/blog/a:b*c"d<e>f|g?h')).not.toMatch(/[<>:"|?*]/)
125+
})
126+
127+
it('roundtrips literal equals signs (self-escape)', () => {
128+
expect(roundtrip('/foo=bar')).toBe('/foo=bar')
129+
})
130+
131+
it('roundtrips equals followed by hex-looking characters', () => {
132+
expect(roundtrip('/x=3ay')).toBe('/x=3ay')
133+
})
134+
135+
it('emits no NTFS-reserved characters in loader filenames', () => {
136+
expect(getLoaderPath('/dashboard/:appId', false)).not.toMatch(/[<>:"|?*]/)
137+
})
138+
139+
it('emits no NTFS-reserved characters in preload filenames', () => {
140+
expect(getPreloadPath('/:param')).not.toMatch(/[<>:"|?*]/)
141+
expect(getPreloadPath('/*')).not.toMatch(/[<>:"|?*]/)
142+
})
143+
144+
it('encodeReservedFilenameChars is identity for clean paths', () => {
145+
expect(encodeReservedFilenameChars('/blog/my-slug.html')).toBe('/blog/my-slug.html')
146+
})
147+
148+
it('encodes the html artifact path for non-prerendered dynamic routes', () => {
149+
expect(encodeReservedFilenameChars('/dashboard/:appId.html')).toBe(
150+
'/dashboard/=3aappId.html'
151+
)
152+
expect(encodeReservedFilenameChars('/*.html')).toBe('/=2a.html')
153+
})
154+
})
155+
94156
describe('getPathFromLoaderPath', () => {
95157
it('strips /_one/assets prefix', () => {
96158
expect(getPathFromLoaderPath('/_one/assets/docs_intro_999_vxrn_loader.js')).toBe(

packages/one/src/utils/cleanUrl.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,31 @@ import {
88
import { getURL } from '../getURL'
99
import { removeSearch } from './removeSearch'
1010

11+
// Route patterns (`/:param`, `/*`) and concrete param values flow into
12+
// generated artifact filenames. `< > : " | ? *` are reserved on Windows
13+
// filesystems — a leading `:` segment fails outright and a mid-name `:`
14+
// silently writes an NTFS alternate data stream — so encode them as `=hh`
15+
// (hex char code), with literal `=` self-escaped first so decoding is
16+
// unambiguous. Identity for paths without reserved characters.
17+
export function encodeReservedFilenameChars(path: string) {
18+
return path
19+
.replaceAll('=', '=3d')
20+
.replace(
21+
/[<>:"|?*]/g,
22+
(char) => `=${char.charCodeAt(0).toString(16).padStart(2, '0')}`
23+
)
24+
}
25+
26+
export function decodeReservedFilenameChars(path: string) {
27+
return path.replace(/=([0-9a-f]{2})/g, (_match, hexCode) =>
28+
String.fromCharCode(Number.parseInt(hexCode, 16))
29+
)
30+
}
31+
1132
function cleanUrl(path: string) {
12-
return removeSearch(path)
13-
.replace(/\/$/, '') // remove trailing slash before encoding
33+
return encodeReservedFilenameChars(
34+
removeSearch(path).replace(/\/$/, '') // remove trailing slash before encoding
35+
)
1436
.replaceAll('_', '__') // escape existing underscores
1537
.replaceAll('/', '_') // use underscore as path separator
1638
}
@@ -43,7 +65,7 @@ export function getLoaderPath(
4365
}
4466

4567
export function getPathFromLoaderPath(loaderPath: string) {
46-
return (
68+
return decodeReservedFilenameChars(
4769
loaderPath
4870
.replace(LOADER_JS_POSTFIX_REGEX, '')
4971
.replace(/^(\/_one)?\/assets/, '')

packages/one/types/utils/cleanUrl.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
export declare function encodeReservedFilenameChars(path: string): string;
2+
export declare function decodeReservedFilenameChars(path: string): string;
13
export declare function getPreloadPath(currentPath: string): string;
24
export declare function getPreloadCSSPath(currentPath: string): string;
35
export declare function getLoaderPath(currentPath: string, includeUrl?: boolean, cacheBust?: string): string;

0 commit comments

Comments
 (0)