diff --git a/packages/one/src/cli/buildPage.ts b/packages/one/src/cli/buildPage.ts index e7f8a358d..7a53a2020 100644 --- a/packages/one/src/cli/buildPage.ts +++ b/packages/one/src/cli/buildPage.ts @@ -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' @@ -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)) diff --git a/packages/one/src/utils/cleanUrl.test.ts b/packages/one/src/utils/cleanUrl.test.ts index a2de650a0..daf77e6be 100644 --- a/packages/one/src/utils/cleanUrl.test.ts +++ b/packages/one/src/utils/cleanUrl.test.ts @@ -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. @@ -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"df|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( diff --git a/packages/one/src/utils/cleanUrl.ts b/packages/one/src/utils/cleanUrl.ts index 9f2491b59..b29cd4ed5 100644 --- a/packages/one/src/utils/cleanUrl.ts +++ b/packages/one/src/utils/cleanUrl.ts @@ -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 } @@ -43,7 +65,7 @@ export function getLoaderPath( } export function getPathFromLoaderPath(loaderPath: string) { - return ( + return decodeReservedFilenameChars( loaderPath .replace(LOADER_JS_POSTFIX_REGEX, '') .replace(/^(\/_one)?\/assets/, '') diff --git a/packages/one/types/utils/cleanUrl.d.ts b/packages/one/types/utils/cleanUrl.d.ts index 3daf72a82..1101131c3 100644 --- a/packages/one/types/utils/cleanUrl.d.ts +++ b/packages/one/types/utils/cleanUrl.d.ts @@ -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;