diff --git a/src/vs/workbench/contrib/webview/browser/resourceLoading.ts b/src/vs/workbench/contrib/webview/browser/resourceLoading.ts index 2069029b1d5..cedc81fc1ab 100644 --- a/src/vs/workbench/contrib/webview/browser/resourceLoading.ts +++ b/src/vs/workbench/contrib/webview/browser/resourceLoading.ts @@ -5,12 +5,11 @@ import { VSBufferReadableStream } from '../../../../base/common/buffer.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; -import { isUNC } from '../../../../base/common/extpath.js'; import { Schemas } from '../../../../base/common/network.js'; -import { normalize, sep } from '../../../../base/common/path.js'; import { URI } from '../../../../base/common/uri.js'; import { FileOperationError, FileOperationResult, IFileService, IWriteFileOptions } from '../../../../platform/files/common/files.js'; import { ILogService } from '../../../../platform/log/common/log.js'; +import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { getWebviewContentMimeType } from '../../../../platform/webview/common/mimeTypes.js'; export namespace WebviewResourceResponse { @@ -48,11 +47,12 @@ export async function loadLocalResource( ifNoneMatch: string | undefined; roots: ReadonlyArray; }, + uriIdentityService: IUriIdentityService, fileService: IFileService, logService: ILogService, token: CancellationToken, ): Promise { - const resourceToLoad = getResourceToLoad(requestUri, options.roots); + const resourceToLoad = getResourceToLoad(requestUri, options.roots, uriIdentityService); logService.trace(`Webview.loadLocalResource - trying to load resource. requestUri=${requestUri}, resourceToLoad=${resourceToLoad}`); @@ -84,12 +84,13 @@ export async function loadLocalResource( } } -function getResourceToLoad( +export function getResourceToLoad( requestUri: URI, roots: ReadonlyArray, + uriIdentityService: IUriIdentityService, ): URI | undefined { for (const root of roots) { - if (containsResource(root, requestUri)) { + if (containsResource(root, requestUri, uriIdentityService)) { return normalizeResourcePath(requestUri); } } @@ -97,20 +98,15 @@ function getResourceToLoad( return undefined; } -function containsResource(root: URI, resource: URI): boolean { - if (root.scheme !== resource.scheme) { +function containsResource(root: URI, resource: URI, uriIdentityService: IUriIdentityService): boolean { + if (uriIdentityService.extUri.isEqual(root, resource, /* ignoreFragment */ true)) { return false; } - let resourceFsPath = normalize(resource.fsPath); - let rootPath = normalize(root.fsPath + (root.fsPath.endsWith(sep) ? '' : sep)); - - if (isUNC(root.fsPath) && isUNC(resource.fsPath)) { - rootPath = rootPath.toLowerCase(); - resourceFsPath = resourceFsPath.toLowerCase(); - } - - return resourceFsPath.startsWith(rootPath); + return uriIdentityService.extUri.isEqualOrParent( + resource.with({ query: '' }), + root, + /* ignoreFragment */ true); } function normalizeResourcePath(resource: URI): URI { diff --git a/src/vs/workbench/contrib/webview/browser/webviewElement.ts b/src/vs/workbench/contrib/webview/browser/webviewElement.ts index 2824da9bb28..5d0d61f8113 100644 --- a/src/vs/workbench/contrib/webview/browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/browser/webviewElement.ts @@ -30,6 +30,7 @@ import { ILogService } from '../../../../platform/log/common/log.js'; import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { IRemoteAuthorityResolverService } from '../../../../platform/remote/common/remoteAuthorityResolver.js'; import { ITunnelService } from '../../../../platform/tunnel/common/tunnel.js'; +import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { WebviewPortMappingManager } from '../../../../platform/webview/common/webviewPortMapping.js'; import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; import { decodeAuthority, webviewGenericCspSource, webviewRootResourceAuthority } from '../common/webview.js'; @@ -163,6 +164,7 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi @ITunnelService private readonly _tunnelService: ITunnelService, @IInstantiationService instantiationService: IInstantiationService, @IAccessibilityService private readonly _accessibilityService: IAccessibilityService, + @IUriIdentityService private readonly _uriIdentityService: IUriIdentityService, ) { super(); @@ -763,7 +765,7 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi const result = await loadLocalResource(uri, { ifNoneMatch, roots: this._content.options.localResourceRoots || [], - }, this._fileService, this._logService, this._resourceLoadingCts.token); + }, this._uriIdentityService, this._fileService, this._logService, this._resourceLoadingCts.token); switch (result.type) { case WebviewResourceResponse.Type.Success: { diff --git a/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts b/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts index 13d75633fb4..deaf633184c 100644 --- a/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts @@ -19,6 +19,7 @@ import { INativeHostService } from '../../../../platform/native/common/native.js import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { IRemoteAuthorityResolverService } from '../../../../platform/remote/common/remoteAuthorityResolver.js'; import { ITunnelService } from '../../../../platform/tunnel/common/tunnel.js'; +import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { FindInFrameOptions, IWebviewManagerService } from '../../../../platform/webview/common/webviewManagerService.js'; import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; import { WebviewThemeDataProvider } from '../browser/themeing.js'; @@ -56,10 +57,11 @@ export class ElectronWebviewElement extends WebviewElement { @INativeHostService private readonly _nativeHostService: INativeHostService, @IInstantiationService instantiationService: IInstantiationService, @IAccessibilityService accessibilityService: IAccessibilityService, + @IUriIdentityService uriIdentityService: IUriIdentityService, ) { super(initInfo, webviewThemeDataProvider, configurationService, contextMenuService, notificationService, environmentService, - fileService, logService, remoteAuthorityResolverService, tunnelService, instantiationService, accessibilityService); + fileService, logService, remoteAuthorityResolverService, tunnelService, instantiationService, accessibilityService, uriIdentityService); this._webviewKeyboardHandler = new WindowIgnoreMenuShortcutsManager(configurationService, mainProcessService, _nativeHostService); diff --git a/src/vs/workbench/contrib/webview/test/browser/resourceLoading.test.ts b/src/vs/workbench/contrib/webview/test/browser/resourceLoading.test.ts new file mode 100644 index 00000000000..2ae1d202ac7 --- /dev/null +++ b/src/vs/workbench/contrib/webview/test/browser/resourceLoading.test.ts @@ -0,0 +1,243 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { isWindows } from '../../../../../base/common/platform.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { FileService } from '../../../../../platform/files/common/fileService.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; +import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; +import { UriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentityService.js'; +import { getResourceToLoad } from '../../browser/resourceLoading.js'; + +suite('Webview Resource Loading - getResourceToLoad', () => { + const disposableStore = ensureNoDisposablesAreLeakedInTestSuite(); + + let uriIdentityService: IUriIdentityService; + + setup(() => { + const instantiationService = disposableStore.add(new TestInstantiationService()); + instantiationService.stub(ILogService, NullLogService); + const fileService = disposableStore.add(new FileService(instantiationService.get(ILogService))); + uriIdentityService = instantiationService.stub(IUriIdentityService, disposableStore.add(new UriIdentityService(fileService))); + }); + + test('Returns resource when file is under root', () => { + const root = URI.file('/home/user/project'); + const resource = URI.file('/home/user/project/file.txt'); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result?.toString(), resource.toString()); + }); + + test('Returns resource when file is in nested directory', () => { + const root = URI.file('/home/user/project'); + const resource = URI.file('/home/user/project/subdir/nested/file.txt'); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result?.toString(), resource.toString()); + }); + + test('Fails when file is outside root', () => { + const root = URI.file('/home/user/project'); + const resource = URI.file('/home/user/other/file.txt'); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result, undefined); + }); + + test('Fails when file is root', () => { + const root = URI.file('/home/user/project'); + const result = getResourceToLoad(root, [root], uriIdentityService); + assert.strictEqual(result, undefined); + }); + + test('Fails when file is sibling of root directory', () => { + const root = URI.file('/home/user/project'); + { + const resource = URI.file('/home/user/projectOther/file.txt'); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result, undefined); + } + { + const resource = URI.file('/home/user/project.txt'); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result, undefined); + } + }); + + test('Returns resource when root ends with /', () => { + const root = URI.file('/home/user/project/'); + const resource = URI.file('/home/user/project/file.txt'); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result?.toString(), resource.toString()); + }); + + test('Fails for sibling when root ends with / ', () => { + const root = URI.file('/home/user/project/'); + const resource = URI.file('/home/user/projectOther/file.txt'); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result, undefined); + }); + + (!isWindows /* UNC is windows only */ ? suite.skip : suite)('UNC paths', () => { + test('Returns resource when file is under UNC root', () => { + const root = URI.file('\\\\server\\share\\folder'); + const resource = URI.file('\\\\server\\share\\folder\\file.txt'); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result?.toString(), resource.toString()); + }); + + test('Returns resource with case-insensitive comparison for UNC paths', () => { + const root = URI.file('\\\\SERVER\\SHARE\\folder'); + const resource = URI.file('\\\\server\\share\\folder\\file.txt'); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result?.toString(), resource.toString()); + }); + + test('Fails when file is outside UNC root', () => { + const root = URI.file('\\\\server\\share\\folder'); + const resource = URI.file('\\\\server\\share\\other\\file.txt'); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result, undefined); + }); + + test('Fails when UNC server differs', () => { + const root = URI.file('\\\\server1\\share\\folder'); + const resource = URI.file('\\\\server2\\share\\folder\\file.txt'); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result, undefined); + }); + }); + + suite('Different authorities', () => { + test('Returns resource when authorities match', () => { + const root = URI.from({ scheme: 'test-scheme', authority: 'ssh-remote+myserver', path: '/home/user/project' }); + const resource = URI.from({ scheme: 'test-scheme', authority: 'ssh-remote+myserver', path: '/home/user/project/file.txt' }); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.ok(result); + }); + + test('Fails when authorities differ', () => { + const root = URI.from({ scheme: 'test-scheme', authority: 'ssh-remote+server1', path: '/home/user/project' }); + const resource = URI.from({ scheme: 'test-scheme', authority: 'ssh-remote+server2', path: '/home/user/project/file.txt' }); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result, undefined); + }); + + test('handles empty authority', () => { + const root = URI.from({ scheme: 'test-scheme', authority: '', path: '/home/user/project' }); + const resource = URI.from({ scheme: 'test-scheme', authority: '', path: '/home/user/project/file.txt' }); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result?.toString(), resource.toString()); + }); + }); + + suite('Different schemes', () => { + test('Fails when schemes differ', () => { + const root = URI.from({ scheme: 'file', path: '/home/user/project' }); + const resource = URI.from({ scheme: 'http', path: '/home/user/project/file.txt' }); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result, undefined); + }); + + test('Returns resource when schemes match', () => { + const root = URI.from({ scheme: 'custom-scheme', path: '/home/user/project' }); + const resource = URI.from({ scheme: 'custom-scheme', path: '/home/user/project/file.txt' }); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result?.toString(), resource.toString()); + }); + + test('normalizes vscode-remote scheme', () => { + const root = URI.from({ scheme: 'vscode-remote', authority: 'test', path: '/home/user/project' }); + const resource = URI.from({ scheme: 'vscode-remote', authority: 'test', path: '/home/user/project/file.txt' }); + const result = getResourceToLoad(resource, [root], uriIdentityService); + + assert.ok(result); + assert.strictEqual(result.scheme, 'vscode-remote'); + assert.strictEqual(result.authority, 'test'); + assert.strictEqual(result.path, '/vscode-resource'); + const query = JSON.parse(result.query); + assert.strictEqual(query.requestResourcePath, '/home/user/project/file.txt'); + }); + }); + + suite('Fragment and query strings', () => { + test('preserves fragment in returned URI', () => { + const root = URI.file('/home/user/project'); + const resource = URI.file('/home/user/project/file.txt').with({ fragment: 'section1' }); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result?.fragment, 'section1'); + }); + + test('preserves query in returned URI', () => { + const root = URI.file('/home/user/project'); + const resource = URI.file('/home/user/project/file.txt').with({ query: 'version=2' }); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result?.query, 'version=2'); + }); + + test('preserves both fragment and query', () => { + const root = URI.file('/home/user/project'); + const resource = URI.file('/home/user/project/file.txt').with({ fragment: 'section1', query: 'version=2' }); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result?.fragment, 'section1'); + assert.strictEqual(result?.query, 'version=2'); + }); + + test('still validates path containment with query params', () => { + const root = URI.file('/home/user/project'); + const resource = URI.file('/home/user/other/file.txt').with({ query: 'version=2' }); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result, undefined); + }); + + test('still validates path containment with fragment', () => { + const root = URI.file('/home/user/project'); + const resource = URI.file('/home/user/other/file.txt').with({ fragment: 'section1' }); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result, undefined); + }); + }); + + suite('Multiple roots', () => { + test('Returns resource when file is under one of multiple roots', () => { + const roots = [ + URI.file('/home/user/project1'), + URI.file('/home/user/project2'), + URI.file('/home/user/project3') + ]; + const resource = URI.file('/home/user/project2/file.txt'); + const result = getResourceToLoad(resource, roots, uriIdentityService); + assert.strictEqual(result?.toString(), resource.toString()); + }); + + test('Fails when file is not under any root', () => { + const roots = [ + URI.file('/home/user/project1'), + URI.file('/home/user/project2') + ]; + const resource = URI.file('/home/user/other/file.txt'); + const result = getResourceToLoad(resource, roots, uriIdentityService); + assert.strictEqual(result, undefined); + }); + + test('Returns resource matching first valid root', () => { + const roots = [ + URI.file('/home/user/project'), + URI.file('/home/user/project/subdir') + ]; + const resource = URI.file('/home/user/project/subdir/file.txt'); + const result = getResourceToLoad(resource, roots, uriIdentityService); + // Should match first root in the list + assert.strictEqual(result?.toString(), resource.toString()); + }); + + test('handles empty roots array', () => { + const resource = URI.file('/home/user/project/file.txt'); + const result = getResourceToLoad(resource, [], uriIdentityService); + assert.strictEqual(result, undefined); + }); + }); +});