33 * Licensed under the MIT License. See License.txt in the project root for license information.
44 *--------------------------------------------------------------------------------------------*/
55
6- import { afterAll , beforeAll , beforeEach , describe , expect , suite , test } from 'vitest' ;
6+ import { afterAll , beforeAll , beforeEach , describe , expect , suite , test , it } from 'vitest' ;
77import { ConfigKey , IConfigurationService } from '../../../../platform/configuration/common/configurationService' ;
88import { InMemoryConfigurationService } from '../../../../platform/configuration/test/common/inMemoryConfigurationService' ;
99import { IFileSystemService } from '../../../../platform/filesystem/common/fileSystemService' ;
@@ -18,6 +18,7 @@ import { SyncDescriptor } from '../../../../util/vs/platform/instantiation/commo
1818import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation' ;
1919import { createExtensionUnitTestingServices } from '../../../test/node/services' ;
2020import { assertFileOkForTool , isDirExternalAndNeedsConfirmation , isFileExternalAndNeedsConfirmation } from '../toolUtils' ;
21+ import { encodeUrlHostname } from '../../common/toolUtils' ;
2122
2223class TestIgnoreService extends NullIgnoreService {
2324 private readonly _ignoredUris = new Set < string > ( ) ;
@@ -266,3 +267,183 @@ suite('toolUtils - external file existence', () => {
266267 expect ( await invokeIsFileExternalAndNeedsConfirmation ( URI . file ( '/workspace/nonexistent.ts' ) ) ) . toBe ( false ) ;
267268 } ) ;
268269} ) ;
270+
271+ describe ( 'encodeUrlHostname' , ( ) => {
272+ describe ( 'ASCII URLs' , ( ) => {
273+ it ( 'handles standard ASCII domain' , ( ) => {
274+ const result = encodeUrlHostname ( 'https://example.com' ) ;
275+ expect ( result . encoded ) . toBe ( 'https://example.com' ) ;
276+ expect ( result . isDifferent ) . toBe ( false ) ;
277+ } ) ;
278+
279+ it ( 'handles ASCII domain with path' , ( ) => {
280+ const result = encodeUrlHostname ( 'https://example.com/path/to/page' ) ;
281+ expect ( result . encoded ) . toBe ( 'https://example.com/path/to/page' ) ;
282+ expect ( result . isDifferent ) . toBe ( false ) ;
283+ } ) ;
284+
285+ it ( 'handles ASCII domain with query string' , ( ) => {
286+ const result = encodeUrlHostname ( 'https://example.com/page?foo=bar&baz=qux' ) ;
287+ expect ( result . encoded ) . toBe ( 'https://example.com/page?foo=bar&baz=qux' ) ;
288+ expect ( result . isDifferent ) . toBe ( false ) ;
289+ } ) ;
290+
291+ it ( 'handles ASCII domain with fragment' , ( ) => {
292+ const result = encodeUrlHostname ( 'https://example.com/page#section' ) ;
293+ expect ( result . encoded ) . toBe ( 'https://example.com/page#section' ) ;
294+ expect ( result . isDifferent ) . toBe ( false ) ;
295+ } ) ;
296+
297+ it ( 'handles http scheme' , ( ) => {
298+ const result = encodeUrlHostname ( 'http://example.com' ) ;
299+ expect ( result . encoded ) . toBe ( 'http://example.com' ) ;
300+ expect ( result . isDifferent ) . toBe ( false ) ;
301+ } ) ;
302+ } ) ;
303+
304+ describe ( 'internationalized domain names (IDN)' , ( ) => {
305+ it ( 'encodes Cyrillic domain' , ( ) => {
306+ const result = encodeUrlHostname ( 'https://пример.рф' ) ;
307+ expect ( result . encoded ) . toBe ( 'https://xn--e1afmkfd.xn--p1ai' ) ;
308+ expect ( result . isDifferent ) . toBe ( true ) ;
309+ } ) ;
310+
311+ it ( 'encodes Chinese domain' , ( ) => {
312+ const result = encodeUrlHostname ( 'https://例え.jp' ) ;
313+ expect ( result . encoded ) . toBe ( 'https://xn--r8jz45g.jp' ) ;
314+ expect ( result . isDifferent ) . toBe ( true ) ;
315+ } ) ;
316+
317+ it ( 'encodes German domain with umlaut' , ( ) => {
318+ const result = encodeUrlHostname ( 'https://müller.de' ) ;
319+ expect ( result . encoded ) . toBe ( 'https://xn--mller-kva.de' ) ;
320+ expect ( result . isDifferent ) . toBe ( true ) ;
321+ } ) ;
322+
323+ it ( 'encodes Arabic domain' , ( ) => {
324+ const result = encodeUrlHostname ( 'https://مثال.السعودية' ) ;
325+ expect ( result . encoded ) . toBe ( 'https://xn--mgbh0fb.xn--mgberp4a5d4ar' ) ;
326+ expect ( result . isDifferent ) . toBe ( true ) ;
327+ } ) ;
328+
329+ it ( 'preserves path when encoding IDN' , ( ) => {
330+ const result = encodeUrlHostname ( 'https://пример.рф/path/to/page' ) ;
331+ expect ( result . encoded ) . toBe ( 'https://xn--e1afmkfd.xn--p1ai/path/to/page' ) ;
332+ expect ( result . isDifferent ) . toBe ( true ) ;
333+ } ) ;
334+
335+ it ( 'preserves query string when encoding IDN' , ( ) => {
336+ const result = encodeUrlHostname ( 'https://пример.рф?foo=bar' ) ;
337+ expect ( result . encoded ) . toBe ( 'https://xn--e1afmkfd.xn--p1ai?foo=bar' ) ;
338+ expect ( result . isDifferent ) . toBe ( true ) ;
339+ } ) ;
340+ } ) ;
341+
342+ describe ( 'URLs with port' , ( ) => {
343+ it ( 'handles ASCII domain with port' , ( ) => {
344+ const result = encodeUrlHostname ( 'https://example.com:8080' ) ;
345+ expect ( result . encoded ) . toBe ( 'https://example.com:8080' ) ;
346+ expect ( result . isDifferent ) . toBe ( false ) ;
347+ } ) ;
348+
349+ it ( 'encodes IDN with port' , ( ) => {
350+ const result = encodeUrlHostname ( 'https://пример.рф:8080' ) ;
351+ expect ( result . encoded ) . toBe ( 'https://xn--e1afmkfd.xn--p1ai:8080' ) ;
352+ expect ( result . isDifferent ) . toBe ( true ) ;
353+ } ) ;
354+
355+ it ( 'handles port with path' , ( ) => {
356+ const result = encodeUrlHostname ( 'https://пример.рф:8080/path' ) ;
357+ expect ( result . encoded ) . toBe ( 'https://xn--e1afmkfd.xn--p1ai:8080/path' ) ;
358+ expect ( result . isDifferent ) . toBe ( true ) ;
359+ } ) ;
360+ } ) ;
361+
362+ describe ( 'URLs with userinfo' , ( ) => {
363+ it ( 'handles userinfo with ASCII domain' , ( ) => {
364+ const result = encodeUrlHostname ( 'https://user:pass@example.com' ) ;
365+ expect ( result . encoded ) . toBe ( 'https://user:pass@example.com' ) ;
366+ expect ( result . isDifferent ) . toBe ( false ) ;
367+ } ) ;
368+
369+ it ( 'encodes IDN with userinfo' , ( ) => {
370+ const result = encodeUrlHostname ( 'https://user:pass@пример.рф' ) ;
371+ expect ( result . encoded ) . toBe ( 'https://user:pass@xn--e1afmkfd.xn--p1ai' ) ;
372+ expect ( result . isDifferent ) . toBe ( true ) ;
373+ } ) ;
374+
375+ it ( 'handles userinfo with port' , ( ) => {
376+ const result = encodeUrlHostname ( 'https://user:pass@пример.рф:8080' ) ;
377+ expect ( result . encoded ) . toBe ( 'https://user:pass@xn--e1afmkfd.xn--p1ai:8080' ) ;
378+ expect ( result . isDifferent ) . toBe ( true ) ;
379+ } ) ;
380+
381+ it ( 'handles username without password' , ( ) => {
382+ const result = encodeUrlHostname ( 'https://user@пример.рф' ) ;
383+ expect ( result . encoded ) . toBe ( 'https://user@xn--e1afmkfd.xn--p1ai' ) ;
384+ expect ( result . isDifferent ) . toBe ( true ) ;
385+ } ) ;
386+ } ) ;
387+
388+ describe ( 'subdomain handling' , ( ) => {
389+ it ( 'encodes subdomain with non-ASCII characters' , ( ) => {
390+ const result = encodeUrlHostname ( 'https://поддомен.пример.рф' ) ;
391+ expect ( result . encoded ) . toBe ( 'https://xn--d1aad1agbce.xn--e1afmkfd.xn--p1ai' ) ;
392+ expect ( result . isDifferent ) . toBe ( true ) ;
393+ } ) ;
394+
395+ it ( 'handles mixed ASCII and non-ASCII subdomains' , ( ) => {
396+ const result = encodeUrlHostname ( 'https://www.пример.рф' ) ;
397+ expect ( result . encoded ) . toBe ( 'https://www.xn--e1afmkfd.xn--p1ai' ) ;
398+ expect ( result . isDifferent ) . toBe ( true ) ;
399+ } ) ;
400+ } ) ;
401+
402+ describe ( 'edge cases' , ( ) => {
403+ it ( 'handles localhost' , ( ) => {
404+ const result = encodeUrlHostname ( 'http://localhost:3000' ) ;
405+ expect ( result . encoded ) . toBe ( 'http://localhost:3000' ) ;
406+ expect ( result . isDifferent ) . toBe ( false ) ;
407+ } ) ;
408+
409+ it ( 'handles IP address' , ( ) => {
410+ const result = encodeUrlHostname ( 'http://192.168.1.1:8080' ) ;
411+ expect ( result . encoded ) . toBe ( 'http://192.168.1.1:8080' ) ;
412+ expect ( result . isDifferent ) . toBe ( false ) ;
413+ } ) ;
414+
415+ it ( 'handles IPv6 address' , ( ) => {
416+ const result = encodeUrlHostname ( 'http://[::1]:8080' ) ;
417+ expect ( result . encoded ) . toBe ( 'http://[::1]:8080' ) ;
418+ expect ( result . isDifferent ) . toBe ( false ) ;
419+ } ) ;
420+
421+ it ( 'handles empty authority gracefully' , ( ) => {
422+ const result = encodeUrlHostname ( 'file:///path/to/file' ) ;
423+ expect ( result . encoded ) . toBe ( 'file:///path/to/file' ) ;
424+ expect ( result . isDifferent ) . toBe ( false ) ;
425+ } ) ;
426+
427+ it ( 'handles invalid URL gracefully' , ( ) => {
428+ const result = encodeUrlHostname ( 'not a url' ) ;
429+ expect ( result . encoded ) . toBe ( 'not a url' ) ;
430+ expect ( result . isDifferent ) . toBe ( false ) ;
431+ } ) ;
432+ } ) ;
433+
434+ describe ( 'homograph attack prevention' , ( ) => {
435+ it ( 'encodes Cyrillic "a" that looks like Latin "a"' , ( ) => {
436+ // Cyrillic а (U+0430) vs Latin a (U+0061)
437+ const result = encodeUrlHostname ( 'https://exаmple.com' ) ; // Contains Cyrillic а
438+ expect ( result . isDifferent ) . toBe ( true ) ;
439+ expect ( result . encoded ) . toContain ( 'xn--' ) ;
440+ } ) ;
441+
442+ it ( 'encodes Greek omicron that looks like Latin "o"' , ( ) => {
443+ // Greek ο (U+03BF) vs Latin o (U+006F)
444+ const result = encodeUrlHostname ( 'https://gοοgle.com' ) ; // Contains Greek ο
445+ expect ( result . isDifferent ) . toBe ( true ) ;
446+ expect ( result . encoded ) . toContain ( 'xn--' ) ;
447+ } ) ;
448+ } ) ;
449+ } ) ;
0 commit comments