Skip to content
File

Blob: src/node/internal/internal_url.ts

typescript313 lines
1// Copyright (c) 2017-2022 Cloudflare, Inc.
2// Licensed under the Apache 2.0 license found in the LICENSE file or at:
3// https://opensource.org/licenses/Apache-2.0
4// Copyright Joyent and Node contributors. All rights reserved. MIT license.
5 
6import {
7 ERR_INVALID_FILE_URL_HOST,
8 ERR_INVALID_FILE_URL_PATH,
9 ERR_INVALID_ARG_TYPE,
10 ERR_INVALID_ARG_VALUE,
11 ERR_INVALID_URL_SCHEME,
12} from 'node-internal:internal_errors';
13import { default as urlUtil } from 'node-internal:url';
14import {
15 CHAR_LOWERCASE_A,
16 CHAR_LOWERCASE_Z,
17 CHAR_FORWARD_SLASH,
18 CHAR_BACKWARD_SLASH,
19} from 'node-internal:constants';
20import {
21 win32 as pathWin32,
22 posix as pathPosix,
23} from 'node-internal:internal_path';
24 
25const FORWARD_SLASH = /\//g;
26 
27// RFC1738 defines the following chars as "unsafe" for URLs
28// @see https://www.ietf.org/rfc/rfc1738.txt 2.2. URL Character Encoding Issues
29const percentRegEx = /%/g;
30const newlineRegEx = /\n/g;
31const carriageReturnRegEx = /\r/g;
32const tabRegEx = /\t/g;
33const quoteRegEx = /"/g;
34const hashRegex = /#/g;
35const spaceRegEx = / /g;
36const questionMarkRegex = /\?/g;
37const openSquareBracketRegEx = /\[/g;
38const backslashRegEx = /\\/g;
39const closeSquareBracketRegEx = /]/g;
40const caretRegEx = /\^/g;
41const verticalBarRegEx = /\|/g;
42const tildeRegEx = /~/g;
43 
44function encodePathChars(
45 filepath: string,
46 options?: { windows: boolean | undefined }
47): string {
48 if (filepath.includes('%')) {
49 filepath = filepath.replace(percentRegEx, '%25');
50 }
51 
52 if (filepath.includes('\t')) {
53 filepath = filepath.replace(tabRegEx, '%09');
54 }
55 if (filepath.includes('\n')) {
56 filepath = filepath.replace(newlineRegEx, '%0A');
57 }
58 if (filepath.includes('\r')) {
59 filepath = filepath.replace(carriageReturnRegEx, '%0D');
60 }
61 if (filepath.includes(' ')) {
62 filepath = filepath.replace(spaceRegEx, '%20');
63 }
64 if (filepath.includes('"')) {
65 filepath = filepath.replace(quoteRegEx, '%22');
66 }
67 if (filepath.includes('#')) {
68 filepath = filepath.replace(hashRegex, '%23');
69 }
70 if (filepath.includes('?')) {
71 filepath = filepath.replace(questionMarkRegex, '%3F');
72 }
73 if (filepath.includes('[')) {
74 filepath = filepath.replace(openSquareBracketRegEx, '%5B');
75 }
76 // Back-slashes must be special-cased on Windows, where they are treated as path separator.
77 if (!options?.windows && filepath.includes('\\')) {
78 filepath = filepath.replace(backslashRegEx, '%5C');
79 }
80 if (filepath.includes(']')) {
81 filepath = filepath.replace(closeSquareBracketRegEx, '%5D');
82 }
83 if (filepath.includes('^')) {
84 filepath = filepath.replace(caretRegEx, '%5E');
85 }
86 if (filepath.includes('|')) {
87 filepath = filepath.replace(verticalBarRegEx, '%7C');
88 }
89 if (filepath.includes('~')) {
90 filepath = filepath.replace(tildeRegEx, '%7E');
91 }
92 
93 return filepath;
94}
95 
96/**
97 * Checks if a value has the shape of a WHATWG URL object.
98 *
99 * Using a symbol or instanceof would not be able to recognize URL objects
100 * coming from other implementations (e.g. in Electron), so instead we are
101 * checking some well known properties for a lack of a better test.
102 *
103 * We use `href` and `protocol` as they are the only properties that are
104 * easy to retrieve and calculate due to the lazy nature of the getters.
105 *
106 * We check for `auth` and `path` attribute to distinguish legacy url instance with
107 * WHATWG URL instance.
108 */
109/* eslint-disable */
110export function isURL(self?: any): self is URL {
111 return Boolean(
112 self?.href &&
113 self.protocol &&
114 self.auth === undefined &&
115 self.path === undefined
116 );
117}
118/* eslint-enable */
119 
120export function getPathFromURLPosix(url: URL): string {
121 if (url.hostname !== '') {
122 // Note: Difference between Node.js and Workerd.
123 // Node.js uses `process.platform` whereas workerd hard codes it to linux.
124 // This is done to avoid confusion regarding non-linux support and conformance.
125 throw new ERR_INVALID_FILE_URL_HOST('linux');
126 }
127 const pathname = url.pathname;
128 for (let n = 0; n < pathname.length; n++) {
129 if (pathname[n] === '%') {
130 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
131 const third = pathname.codePointAt(n + 2)! | 0x20;
132 if (pathname[n + 1] === '2' && third === 102) {
133 throw new ERR_INVALID_FILE_URL_PATH(
134 'must not include encoded / characters'
135 );
136 }
137 }
138 }
139 return decodeURIComponent(pathname);
140}
141 
142export function getPathFromURLWin32(url: URL): string {
143 const hostname = url.hostname;
144 let pathname = url.pathname;
145 for (let n = 0; n < pathname.length; n++) {
146 if (pathname[n] === '%') {
147 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
148 const third = pathname.codePointAt(n + 2)! | 0x20;
149 if (
150 (pathname[n + 1] === '2' && third === 102) || // 2f 2F /
151 (pathname[n + 1] === '5' && third === 99)
152 ) {
153 // 5c 5C \
154 throw new ERR_INVALID_FILE_URL_PATH(
155 'must not include encoded \\ or / characters'
156 );
157 }
158 }
159 }
160 pathname = pathname.replace(FORWARD_SLASH, '\\');
161 pathname = decodeURIComponent(pathname);
162 if (hostname !== '') {
163 // If hostname is set, then we have a UNC path
164 // Pass the hostname through domainToUnicode just in case
165 // it is an IDN using punycode encoding. We do not need to worry
166 // about percent encoding because the URL parser will have
167 // already taken care of that for us. Note that this only
168 // causes IDNs with an appropriate `xn--` prefix to be decoded.
169 return `\\\\${urlUtil.domainToUnicode(hostname)}${pathname}`;
170 }
171 // Otherwise, it's a local path that requires a drive letter
172 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
173 const letter = pathname.codePointAt(1)! | 0x20;
174 const sep = pathname.charAt(2);
175 if (
176 letter < CHAR_LOWERCASE_A ||
177 letter > CHAR_LOWERCASE_Z || // a..z A..Z
178 sep !== ':'
179 ) {
180 throw new ERR_INVALID_FILE_URL_PATH('must be absolute');
181 }
182 return pathname.slice(1);
183}
184 
185export function fileURLToPath(
186 input: string | URL,
187 options?: { windows?: boolean }
188): string {
189 const windows = options?.windows;
190 let path: URL;
191 if (typeof input === 'string') {
192 path = new URL(input);
193 } else if (!isURL(input)) {
194 throw new ERR_INVALID_ARG_TYPE('path', ['string', 'URL'], input);
195 } else {
196 path = input;
197 }
198 if (path.protocol !== 'file:') {
199 throw new ERR_INVALID_URL_SCHEME('file');
200 }
201 return windows ? getPathFromURLWin32(path) : getPathFromURLPosix(path);
202}
203 
204export function pathToFileURL(
205 filepath: string,
206 options?: { windows?: boolean }
207): URL {
208 const windows = options?.windows;
209 // IMPORTANT: Difference between Node.js and workerd.
210 // The following check does not exist in Node.js due to primordial usage.
211 if (typeof filepath !== 'string') {
212 throw new ERR_INVALID_ARG_TYPE('filepath', 'string', filepath);
213 }
214 if (windows && filepath.startsWith('\\\\')) {
215 const outURL = new URL('file://');
216 // UNC path format: \\server\share\resource
217 // Handle extended UNC path and standard UNC path
218 // "\\?\UNC\" path prefix should be ignored.
219 // Ref: https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation
220 const isExtendedUNC = filepath.startsWith('\\\\?\\UNC\\');
221 const prefixLength = isExtendedUNC ? 8 : 2;
222 const hostnameEndIndex = filepath.indexOf('\\', prefixLength);
223 if (hostnameEndIndex === -1) {
224 throw new ERR_INVALID_ARG_VALUE(
225 'path',
226 filepath,
227 'Missing UNC resource path'
228 );
229 }
230 if (hostnameEndIndex === 2) {
231 throw new ERR_INVALID_ARG_VALUE('path', filepath, 'Empty UNC servername');
232 }
233 const hostname = filepath.slice(prefixLength, hostnameEndIndex);
234 outURL.hostname = urlUtil.domainToASCII(hostname);
235 outURL.pathname = encodePathChars(
236 filepath.slice(hostnameEndIndex).replace(backslashRegEx, '/'),
237 { windows }
238 );
239 return outURL;
240 }
241 let resolved = windows
242 ? pathWin32.resolve(filepath)
243 : pathPosix.resolve(filepath);
244 const sep = windows ? pathWin32.sep : pathPosix.sep;
245 // path.resolve strips trailing slashes so we must add them back
246 const filePathLast = filepath.charCodeAt(filepath.length - 1);
247 if (
248 (filePathLast === CHAR_FORWARD_SLASH ||
249 (windows && filePathLast === CHAR_BACKWARD_SLASH)) &&
250 resolved[resolved.length - 1] !== sep
251 )
252 resolved += '/';
253 
254 return new URL(`file://${encodePathChars(resolved, { windows })}`);
255}
256 
257export function toPathIfFileURL(fileURLOrPath: URL | string): string {
258 if (!isURL(fileURLOrPath)) return fileURLOrPath;
259 return fileURLToPath(fileURLOrPath);
260}
261 
262/**
263 * Utility function that converts a URL object into an ordinary options object
264 * as expected by the `http.request` and `https.request` APIs.
265 * @param {URL} url
266 * @returns {Record<string, unknown>}
267 */
268export function urlToHttpOptions(url: URL): Record<string, unknown> {
269 const { hostname, pathname, port, username, password, search } = url;
270 const options: Record<string, unknown> = {
271 __proto__: null,
272 // eslint-disable-next-line @typescript-eslint/no-misused-spread
273 ...url, // In case the url object was extended by the user.
274 protocol: url.protocol,
275 hostname:
276 hostname && hostname[0] === '[' ? hostname.slice(1, -1) : hostname,
277 hash: url.hash,
278 search: search,
279 pathname: pathname,
280 path: `${pathname || ''}${search || ''}`,
281 href: url.href,
282 };
283 if (port !== '') {
284 options.port = Number(port);
285 }
286 if (username || password) {
287 options.auth = `${decodeURIComponent(username)}:${decodeURIComponent(password)}`;
288 }
289 return options;
290}
291 
292// Protocols that can allow "unsafe" and "unwise" chars.
293export const unsafeProtocol = new Set<string>(['javascript', 'javascript:']);
294// Protocols that never have a hostname.
295export const hostlessProtocol = new Set<string>(['javascript', 'javascript:']);
296// Protocols that always contain a // bit.
297export const slashedProtocol = new Set<string>([
298 'http',
299 'http:',
300 'https',
301 'https:',
302 'ftp',
303 'ftp:',
304 'gopher',
305 'gopher:',
306 'file',
307 'file:',
308 'ws',
309 'ws:',
310 'wss',
311 'wss:',
312]);