From 9feccde6c62ca15bf2b4581f9d210b4f337cee34 Mon Sep 17 00:00:00 2001 From: Mohak Gupta Date: Sat, 22 Aug 2026 07:00:16 +0530 Subject: [PATCH] Fix directory-index links using path.join instead of path.posix.join createIndexPage() builds each entry's href with path.join(relativePath, childFile), then encodeURI()'s the result. path.join uses the host OS's native separator, so on Windows it returns e.g. "\apps\folder" instead of "/apps/folder" -- encodeURI then turns the backslashes into %5C, producing broken links like %5Capps%5Cfolder (#855, #762, #795). relativePath is always a URL path (it comes from the decoded request URL, not a filesystem path), so it should use posix separators regardless of host OS. Switched that one join to path.posix.join; the sibling absolutePath join on the next line is unchanged since that one is a real filesystem path and needs native separators. Added a regression test following the existing suite's own pattern (ContentLoader + fsReadDir/PathUtil stubbing, as in manager.test.ts). --- src/server/serverUtils/contentLoader.ts | 2 +- src/test/suite/contentLoader.test.ts | 76 +++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 src/test/suite/contentLoader.test.ts diff --git a/src/server/serverUtils/contentLoader.ts b/src/server/serverUtils/contentLoader.ts index 327ccba0..dd9e4cbd 100644 --- a/src/server/serverUtils/contentLoader.ts +++ b/src/server/serverUtils/contentLoader.ts @@ -206,7 +206,7 @@ export class ContentLoader extends Disposable { } for (const childFile of childFiles) { - const relativeFileWithChild = path.join(relativePath, childFile); + const relativeFileWithChild = path.posix.join(relativePath, childFile); const absolutePath = path.join(readPath, childFile); const fileStats = (await PathUtil.FileExistsStat(absolutePath)).stat; diff --git a/src/test/suite/contentLoader.test.ts b/src/test/suite/contentLoader.test.ts new file mode 100644 index 00000000..7b12ae4b --- /dev/null +++ b/src/test/suite/contentLoader.test.ts @@ -0,0 +1,76 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import assert from 'assert'; +import sinon from 'sinon'; +import vscode from 'vscode'; +import fs from 'fs'; +import { ContentLoader } from '../../server/serverUtils/contentLoader'; +import { PathUtil } from '../../utils/pathUtil'; +import { EndpointManager } from '../../infoManagers/endpointManager'; +import { ConnectionManager } from '../../connectionInfo/connectionManager'; +import { Connection } from '../../connectionInfo/connection'; +import { MockTelemetryReporter } from './mocks/mockTelemetryReporter'; +import { testWorkspaces } from './common'; + +async function streamToString(stream: NodeJS.ReadableStream | undefined): Promise { + if (!stream) return ''; + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString('utf8'); +} + +describe('ContentLoader.createIndexPage', () => { + let sandbox: sinon.SinonSandbox; + let contentLoader: ContentLoader; + let telemetryReporter: MockTelemetryReporter; + let connection: Connection; + + before(async () => { + sandbox = sinon.createSandbox(); + const extensionUri = vscode.Uri.file('c:/Users/TestUser/vscode-livepreview/'); + telemetryReporter = new MockTelemetryReporter(); + const connectionManager = new ConnectionManager(); + connection = await connectionManager.createAndAddNewConnection(testWorkspaces[0]); + const endpointManager = new EndpointManager(); + + contentLoader = new ContentLoader(extensionUri, telemetryReporter, endpointManager, connection); + + sandbox.stub(ContentLoader.prototype, 'fsReadDir').returns(Promise.resolve(['folder', 'anotherfolder'])); + sandbox.stub(PathUtil, 'FileExistsStat').callsFake((_path: string) => { + // Every entry fsReadDir returns is reported as a directory, and + // none of them has an index.html, matching the bug report's + // workspace shape (two plain subfolders, no index files). + return Promise.resolve({ exists: true, stat: { isDirectory: () => true } as unknown as fs.Stats }); + }); + }); + + after(() => { + contentLoader.dispose(); + telemetryReporter.dispose(); + sandbox.restore(); + }); + + // Regression test for the bug reported in #855: on Windows, + // path.join('/apps', 'folder') returns '\apps\folder' (native + // separators), which encodeURI then turns into '%5Capps%5Cfolder' + // instead of a working relative link. The fix uses path.posix.join for + // the link href specifically, which always returns forward slashes + // regardless of host OS -- see the equivalent standalone reproduction + // in the PR description using path.win32.join vs path.posix.join + // directly, which is what actually demonstrates the before/after + // difference (this suite runs on whatever OS CI uses, so it can't + // force the native-separator branch to differ here the way a real + // Windows host does). + it('generates directory links with forward slashes, not encoded backslashes', async () => { + const respInfo = await contentLoader.createIndexPage('c:/Users/TestUser/workspace1/apps', '/apps'); + const html = await streamToString(respInfo.Stream as unknown as NodeJS.ReadableStream); + + assert.ok(!html.includes('%5C') && !html.includes('%5c'), `expected no encoded backslashes in:\n${html}`); + assert.ok(html.includes('href="/apps/folder/"'), `expected a working /apps/folder/ link in:\n${html}`); + assert.ok(html.includes('href="/apps/anotherfolder/"'), `expected a working /apps/anotherfolder/ link in:\n${html}`); + }); +});