From b4f7ba722fb263d369d182f046b879f7dd3a9894 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 13 Jun 2026 00:03:28 +0300 Subject: [PATCH] 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). --- packages/one/src/cli/buildPage.ts | 14 +++++- packages/one/src/utils/cleanUrl.test.ts | 64 ++++++++++++++++++++++++- packages/one/src/utils/cleanUrl.ts | 28 +++++++++-- packages/one/types/utils/cleanUrl.d.ts | 2 + 4 files changed, 102 insertions(+), 6 deletions(-) diff --git a/packages/one/src/cli/buildPage.ts b/packages/one/src/cli/buildPage.ts index e7f8a358df..7a53a2020a 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 a2de650a0e..daf77e6bed 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 9f2491b59c..b29cd4ed5f 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 3daf72a82b..1101131c37 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;