Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions packages/one/src/cli/buildPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import { normalizePath } from 'vite'
import * as constants from '../constants'
import { LOADER_JS_POSTFIX_UNCACHED } from '../constants'
import type { LoaderProps } from '../types'
import { getLoaderPath, getPreloadCSSPath, getPreloadPath } from '../utils/cleanUrl'
import {
encodeReservedFilenameChars,
getLoaderPath,
getPreloadCSSPath,
getPreloadPath,
} from '../utils/cleanUrl'
import { isResponse } from '../utils/isResponse'
import { toAbsolute, toAbsoluteUrl } from '../utils/toAbsolute'
import { replaceLoader } from '../vite/replaceLoader'
Expand Down Expand Up @@ -64,7 +69,12 @@ export async function buildPage(
const render = await getRender(serverEntry)
recordTiming('getRender', performance.now() - t0)

const htmlPath = `${path.endsWith('/') ? `${removeTrailingSlash(path)}/index` : path}.html`
// encode NTFS-reserved characters (`:` from `/:param` patterns, `*` from
// catch-alls, hostile param values) so the artifact is writable on Windows;
// routeMap stores this same string, so serve-side lookups stay consistent
const htmlPath = encodeReservedFilenameChars(
`${path.endsWith('/') ? `${removeTrailingSlash(path)}/index` : path}.html`
)
// forward-slash for cross-platform manifest hygiene (matches serverJsPath)
const clientJsPath = clientManifestEntry
? normalizePath(join(clientDir, clientManifestEntry.file))
Expand Down
64 changes: 63 additions & 1 deletion packages/one/src/utils/cleanUrl.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest'
import { getLoaderPath, getPathFromLoaderPath } from './cleanUrl'
import {
encodeReservedFilenameChars,
getLoaderPath,
getPathFromLoaderPath,
getPreloadPath,
} from './cleanUrl'

/**
* tests the cleanUrl encode/decode roundtrip used for loader URLs.
Expand Down Expand Up @@ -91,6 +96,63 @@ describe('getLoaderPath format', () => {
})
})

describe('NTFS-reserved characters in route-derived names', () => {
// route patterns reach these functions verbatim when a dynamic route has no
// generateStaticParams (every dynamic +ssr/+spa route at build time)
it('roundtrips a root dynamic route pattern', () => {
expect(roundtrip('/:param')).toBe('/:param')
})

it('roundtrips a nested dynamic route pattern', () => {
expect(roundtrip('/dashboard/:appId')).toBe('/dashboard/:appId')
})

it('roundtrips a catch-all pattern', () => {
expect(roundtrip('/*')).toBe('/*')
})

it('roundtrips hostile concrete param values (chars that survive URL parsing raw)', () => {
// getLoaderPath URL-parses its input; WHATWG URL percent-encodes " < > |
// in pathnames but passes : and * through raw — those two are the ones
// that reach generated filenames verbatim (and are the NTFS killers)
expect(roundtrip('/blog/a:b*c')).toBe('/blog/a:b*c')
})

it('emits filesystem-legal names for the full reserved set via preload paths', () => {
// getPreloadPath does not URL-parse, so every reserved char reaches the
// encoder raw — assert none survive into the filename
expect(getPreloadPath('/blog/a:b*c"d<e>f|g?h')).not.toMatch(/[<>:"|?*]/)
})

it('roundtrips literal equals signs (self-escape)', () => {
expect(roundtrip('/foo=bar')).toBe('/foo=bar')
})

it('roundtrips equals followed by hex-looking characters', () => {
expect(roundtrip('/x=3ay')).toBe('/x=3ay')
})

it('emits no NTFS-reserved characters in loader filenames', () => {
expect(getLoaderPath('/dashboard/:appId', false)).not.toMatch(/[<>:"|?*]/)
})

it('emits no NTFS-reserved characters in preload filenames', () => {
expect(getPreloadPath('/:param')).not.toMatch(/[<>:"|?*]/)
expect(getPreloadPath('/*')).not.toMatch(/[<>:"|?*]/)
})

it('encodeReservedFilenameChars is identity for clean paths', () => {
expect(encodeReservedFilenameChars('/blog/my-slug.html')).toBe('/blog/my-slug.html')
})

it('encodes the html artifact path for non-prerendered dynamic routes', () => {
expect(encodeReservedFilenameChars('/dashboard/:appId.html')).toBe(
'/dashboard/=3aappId.html'
)
expect(encodeReservedFilenameChars('/*.html')).toBe('/=2a.html')
})
})

describe('getPathFromLoaderPath', () => {
it('strips /_one/assets prefix', () => {
expect(getPathFromLoaderPath('/_one/assets/docs_intro_999_vxrn_loader.js')).toBe(
Expand Down
28 changes: 25 additions & 3 deletions packages/one/src/utils/cleanUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,31 @@ import {
import { getURL } from '../getURL'
import { removeSearch } from './removeSearch'

// Route patterns (`/:param`, `/*`) and concrete param values flow into
// generated artifact filenames. `< > : " | ? *` are reserved on Windows
// filesystems — a leading `:` segment fails outright and a mid-name `:`
// silently writes an NTFS alternate data stream — so encode them as `=hh`
// (hex char code), with literal `=` self-escaped first so decoding is
// unambiguous. Identity for paths without reserved characters.
export function encodeReservedFilenameChars(path: string) {
return path
.replaceAll('=', '=3d')
.replace(
/[<>:"|?*]/g,
(char) => `=${char.charCodeAt(0).toString(16).padStart(2, '0')}`
)
}

export function decodeReservedFilenameChars(path: string) {
return path.replace(/=([0-9a-f]{2})/g, (_match, hexCode) =>
String.fromCharCode(Number.parseInt(hexCode, 16))
)
}

function cleanUrl(path: string) {
return removeSearch(path)
.replace(/\/$/, '') // remove trailing slash before encoding
return encodeReservedFilenameChars(
removeSearch(path).replace(/\/$/, '') // remove trailing slash before encoding
)
.replaceAll('_', '__') // escape existing underscores
.replaceAll('/', '_') // use underscore as path separator
}
Expand Down Expand Up @@ -43,7 +65,7 @@ export function getLoaderPath(
}

export function getPathFromLoaderPath(loaderPath: string) {
return (
return decodeReservedFilenameChars(
loaderPath
.replace(LOADER_JS_POSTFIX_REGEX, '')
.replace(/^(\/_one)?\/assets/, '')
Expand Down
2 changes: 2 additions & 0 deletions packages/one/types/utils/cleanUrl.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export declare function encodeReservedFilenameChars(path: string): string;
export declare function decodeReservedFilenameChars(path: string): string;
export declare function getPreloadPath(currentPath: string): string;
export declare function getPreloadCSSPath(currentPath: string): string;
export declare function getLoaderPath(currentPath: string, includeUrl?: boolean, cacheBust?: string): string;
Expand Down