char/avril

(wip) why'd you have to go and make things so complicaaaaated?

git clone https://git.t4t.associates/char/avril

Charlotte Somport path extension to typescript38d5bbf

main
14.4 KiB367 linesraw
1// File URL conversions for `avril:path`, bound explicitly to POSIX or
2// Windows path semantics. The WHATWG URL class and its property getters are
3// captured from `ext:url` at module scope, never read back through the
4// guest-mutable `globalThis.URL`; the standard percent-codec is captured the
5// same way, so poisoning the globals afterwards changes nothing.
6
7import {
8  ArrayPrototypeJoin,
9  ArrayPrototypePush,
10  StringPrototypeCharCodeAt,
11  StringPrototypeIncludes,
12  StringPrototypeIndexOf,
13  StringPrototypeIsWellFormed,
14  StringPrototypeSlice,
15  StringPrototypeToLowerCase,
16  TypeError,
17  getter,
18} from "ext:core/primordials";
19import { URL as AvrilURL } from "ext:url/main.js";
20import { splitOnChar, validateString, win32Base } from "./lexical.js";
21
22const { decodeURIComponent, encodeURIComponent } = globalThis;
23
24const getHref = getter(AvrilURL.prototype, "href");
25const getProtocol = getter(AvrilURL.prototype, "protocol");
26const getHostname = getter(AvrilURL.prototype, "hostname");
27const getPathname = getter(AvrilURL.prototype, "pathname");
28const getSearch = getter(AvrilURL.prototype, "search");
29const getHash = getter(AvrilURL.prototype, "hash");
30
31const CHAR_FORWARD_SLASH = 47; // /
32const CHAR_BACKWARD_SLASH = 92; // \
33
34function hexValueAt(input: string, index: number): number {
35  if (index < 0 || index >= input.length) return -1;
36  const code = StringPrototypeCharCodeAt(input, index);
37  if (code >= 48 && code <= 57) return code - 48;
38  if (code >= 65 && code <= 70) return code - 55;
39  if (code >= 97 && code <= 102) return code - 87;
40  return -1;
41}
42
43function rejectNul(value: string): void {
44  if (StringPrototypeIncludes(value, "\0")) {
45    throw new TypeError("File URL conversion does not accept NUL bytes in paths");
46  }
47}
48
49// The standard codec rejects malformed escapes, truncated sequences,
50// overlong forms, surrogate-range encodings, and out-of-range code points;
51// NUL still decodes quietly, so it is checked explicitly afterwards along
52// with well-formedness as defense in depth.
53function percentDecode(input: string): string {
54  if (StringPrototypeIndexOf(input, "%") === -1) {
55    rejectNul(input);
56    return input;
57  }
58  let decoded;
59  try {
60    decoded = decodeURIComponent(input);
61  } catch {
62    throw new TypeError("File URL path has a malformed percent-encoding");
63  }
64  rejectNul(decoded);
65  if (!StringPrototypeIsWellFormed(decoded)) {
66    throw new TypeError("File URL path decodes to lone surrogates");
67  }
68  return decoded;
69}
70
71// Rejects encoded separators in the still-encoded pathname: separating
72// slashes must arrive literally so the URL parser, not an escape, chose them.
73function assertNoEncodedSeparator(raw: string, windows: boolean): void {
74  for (let i = 0; i < raw.length; i++) {
75    if (StringPrototypeCharCodeAt(raw, i) !== 37) continue; // %
76    const high = hexValueAt(raw, i + 1);
77    const low = hexValueAt(raw, i + 2);
78    if (high < 0 || low < 0) continue;
79    const byte = high * 16 + low;
80    if (byte === CHAR_FORWARD_SLASH || (windows && byte === CHAR_BACKWARD_SLASH)) {
81      throw new TypeError("File URL path must not include encoded path separators");
82    }
83  }
84}
85
86function parseFileUrl(value: unknown): AvrilURL {
87  if (typeof value === "string") {
88    // WHATWG parsing normalizes lone surrogates to U+FFFD and silently drops
89    // ASCII tab/newline characters, so reject those inputs up front. A URL
90    // instance is already parsed and cannot be un-normalized; that residual
91    // replacement is documented rather than re-detected here.
92    if (!StringPrototypeIsWellFormed(value)) {
93      throw new TypeError("File URL conversion does not accept lone surrogates in URLs");
94    }
95    rejectNul(value);
96    for (let i = 0; i < value.length; i++) {
97      const code = StringPrototypeCharCodeAt(value, i);
98      if (code === 9 || code === 10 || code === 13) {
99        throw new TypeError("File URL strings must not contain tab or newline characters");
100      }
101    }
102    try {
103      return new AvrilURL(value);
104    } catch {
105      throw new TypeError("Expected an absolute file: URL string or URL instance");
106    }
107  }
108  try {
109    getHref(value);
110  } catch {
111    throw new TypeError("Expected an absolute file: URL string or URL instance");
112  }
113  return value as AvrilURL;
114}
115
116function checkFileUrl(url: AvrilURL): void {
117  if (getProtocol(url) !== "file:") {
118    throw new TypeError("File URL conversion requires a file: URL");
119  }
120  if (getSearch(url) !== "" || getHash(url) !== "") {
121    throw new TypeError("File URL conversion does not accept URLs with a query or fragment");
122  }
123}
124
125function isDriveLetter(code: number): boolean {
126  return (code >= 65 && code <= 90) || (code >= 97 && code <= 122);
127}
128
129function rejectExtendedNamespace(path: string): void {
130  const first = StringPrototypeCharCodeAt(path, 0);
131  const second = StringPrototypeCharCodeAt(path, 1);
132  const third = StringPrototypeCharCodeAt(path, 2);
133  const fourth = StringPrototypeCharCodeAt(path, 3);
134  const separator = (code: number) =>
135    code === CHAR_FORWARD_SLASH || code === CHAR_BACKWARD_SLASH;
136  if (
137    separator(first) && separator(second) &&
138    (third === 63 || third === 46) && separator(fourth)
139  ) {
140    throw new TypeError(
141      "File URL conversion does not accept extended-namespace paths (\\\\?\\ or \\\\.\\)",
142    );
143  }
144}
145
146function posixFromFileUrl(value: unknown): string {
147  const url = parseFileUrl(value);
148  checkFileUrl(url);
149  const host = getHostname(url);
150  if (host !== "" && host !== "localhost") {
151    throw new TypeError(
152      `File URL host "${host}" is not local; POSIX paths accept only empty or localhost hosts`,
153    );
154  }
155  const raw = getPathname(url);
156  assertNoEncodedSeparator(raw, false);
157  return percentDecode(raw);
158}
159
160function win32FromFileUrl(value: unknown): string {
161  const url = parseFileUrl(value);
162  checkFileUrl(url);
163  const host = getHostname(url);
164  const raw = getPathname(url);
165  assertNoEncodedSeparator(raw, true);
166  // WHATWG file URL paths always use forward slashes.
167  const decoded = ArrayPrototypeJoin(splitOnChar(percentDecode(raw), CHAR_FORWARD_SLASH), "\\");
168  if (host !== "" && StringPrototypeToLowerCase(host) !== "localhost") {
169    if (decoded.length < 2) {
170      throw new TypeError("File URL names an incomplete UNC path");
171    }
172    const segEnd = StringPrototypeIndexOf(decoded, "\\", 1);
173    const first = segEnd === -1
174      ? StringPrototypeSlice(decoded, 1)
175      : StringPrototypeSlice(decoded, 1, segEnd);
176    if (first === "" || first === "." || first === "..") {
177      throw new TypeError("File URL names an incomplete UNC path");
178    }
179    const unc = `\\\\${host}${decoded}`;
180    rejectExtendedNamespace(unc);
181    return unc;
182  }
183  // Only fully qualified drive paths convert: drive-relative (`C:`, `C:foo`)
184  // names no complete absolute location without consulting the process
185  // working directory, which URL conversion deliberately never does.
186  if (
187    !isDriveLetter(StringPrototypeCharCodeAt(decoded, 1) | 32) ||
188    StringPrototypeCharCodeAt(decoded, 2) !== 58 || // :
189    decoded.length < 4 ||
190    StringPrototypeCharCodeAt(decoded, 3) !== CHAR_BACKWARD_SLASH
191  ) {
192    throw new TypeError("File URL path must be absolute");
193  }
194  const drive = StringPrototypeSlice(decoded, 1);
195  rejectExtendedNamespace(drive);
196  return drive;
197}
198
199// ASCII characters kept literally in generated file URLs, matching Node's
200// pathToFileURL table: alphanumerics, sub-delims, `@:`, and `;`, `=`, `,`,
201// `-`, `.`, `_`. Everything else (including `%`, space, `"#<>?`, backtick,
202// braces, `~`, `|^[]`, backslash, controls, DEL, and non-ASCII as UTF-8) is
203// percent-encoded by the standard codec with uppercase hex.
204function isPathCharKept(code: number): boolean {
205  return (code >= 48 && code <= 57) || (code >= 65 && code <= 90) ||
206    (code >= 97 && code <= 122) || code === 33 || code === 36 || code === 38 ||
207    code === 39 || code === 40 || code === 41 || code === 42 || code === 43 ||
208    code === 44 || code === 45 || code === 46 || code === 58 || code === 59 ||
209    code === 61 || code === 64 || code === 95;
210}
211
212// `path` uses forward slashes throughout; callers normalize Windows
213// separators first so backslashes never leak in literally.
214function encodeAbsolutePath(path: string): string {
215  const pieces: string[] = [];
216  let start = 0;
217  for (let i = 0; i < path.length;) {
218    const code = StringPrototypeCharCodeAt(path, i);
219    if (code === CHAR_FORWARD_SLASH || (code < 128 && isPathCharKept(code))) {
220      i++;
221      continue;
222    }
223    ArrayPrototypePush(pieces, StringPrototypeSlice(path, start, i));
224    // The standard codec leaves `~` literally, but Node's file URLs encode
225    // it, so it is escaped here to match.
226    if (code === 126) {
227      ArrayPrototypePush(pieces, "%7E");
228      i++;
229    } else if (code >= 55296 && code <= 56319) {
230      ArrayPrototypePush(pieces, encodeURIComponent(StringPrototypeSlice(path, i, i + 2)));
231      i += 2;
232    } else {
233      ArrayPrototypePush(pieces, encodeURIComponent(StringPrototypeSlice(path, i, i + 1)));
234      i++;
235    }
236    start = i;
237  }
238  ArrayPrototypePush(pieces, StringPrototypeSlice(path, start, path.length));
239  return ArrayPrototypeJoin(pieces, "");
240}
241
242// UNC servers are validated strictly instead of percent-encoded: the URL
243// parser silently strips some invalid characters (notably tab and newline),
244// which would otherwise point the resulting URL elsewhere. Bracketed IPv6
245// literals are checked structurally; other non-ASCII names pass through for
246// the parser's IDNA handling and normalize to punycode.
247function checkUncHost(host: string): string {
248  if (StringPrototypeToLowerCase(host) === "localhost") {
249    throw new TypeError(
250      "toFileUrl does not represent the local machine as a UNC server; " +
251      "use a drive path such as C:\\... instead",
252    );
253  }
254  if (StringPrototypeCharCodeAt(host, 0) === 91) { // [
255    if (StringPrototypeCharCodeAt(host, host.length - 1) !== 93) { // ]
256      throw new TypeError("toFileUrl received an invalid IPv6 UNC server");
257    }
258    for (let i = 1; i < host.length - 1; i++) {
259      const code = StringPrototypeCharCodeAt(host, i);
260      const valid = (code >= 48 && code <= 57) ||
261        (code >= 65 && code <= 70) || (code >= 97 && code <= 102) ||
262        code === 46 || code === 58 || code === 37;
263      if (!valid) {
264        throw new TypeError("toFileUrl received an invalid IPv6 UNC server");
265      }
266    }
267    return host;
268  }
269  for (let i = 0; i < host.length; i++) {
270    const code = StringPrototypeCharCodeAt(host, i);
271    if (
272      code < 33 || code === 127 || code === 47 || code === 58 || // controls, space, DEL, / :
273      code === 63 || code === 64 || code === 91 || code === 93 || // ? @ [ ]
274      code === 35 || code === 37 // # %
275    ) {
276      throw new TypeError(`toFileUrl received an invalid UNC server "${host}"`);
277    }
278  }
279  return host;
280}
281
282// The URL parser lowercases hosts and applies IDNA itself; confirm the UNC
283// server survived construction instead of collapsing (notably to localhost,
284// which file URLs treat as "no host").
285function checkUncUrl(url: AvrilURL): void {
286  const host = getHostname(url);
287  if (host === "" || StringPrototypeToLowerCase(host) === "localhost") {
288    throw new TypeError("toFileUrl lost the UNC server to URL normalization");
289  }
290}
291
292function finishFileUrl(href: string): AvrilURL {
293  try {
294    return new AvrilURL(href);
295  } catch {
296    throw new TypeError("Path could not be converted to a file: URL");
297  }
298}
299
300function checkToFileUrlInput(path: unknown, windows: boolean): asserts path is string {
301  validateString(path, "path");
302  if (StringPrototypeIncludes(path, "\0")) {
303    throw new TypeError("File URL conversion does not accept NUL bytes in paths");
304  }
305  if (!StringPrototypeIsWellFormed(path)) {
306    throw new TypeError("File URL conversion does not accept lone surrogates in paths");
307  }
308  if (windows) rejectExtendedNamespace(path);
309}
310
311function posixToFileUrl(path: unknown): AvrilURL {
312  checkToFileUrlInput(path, false);
313  if (StringPrototypeCharCodeAt(path, 0) !== CHAR_FORWARD_SLASH) {
314    throw new TypeError("toFileUrl requires an absolute POSIX path");
315  }
316  return finishFileUrl(`file://${encodeAbsolutePath(path)}`);
317}
318
319function win32ToFileUrl(path: unknown): AvrilURL {
320  checkToFileUrlInput(path, true);
321  const unified = ArrayPrototypeJoin(splitOnChar(path, CHAR_BACKWARD_SLASH), "/");
322  const hadTrailing = StringPrototypeCharCodeAt(unified, unified.length - 1) === CHAR_FORWARD_SLASH;
323  // Clamp `.`/`..` lexically so they cannot cross a drive or share root; the
324  // URL parser below does not know the UNC share boundary.
325  const n = ArrayPrototypeJoin(splitOnChar(win32Base.normalize(unified), CHAR_BACKWARD_SLASH), "/");
326  if (
327    StringPrototypeCharCodeAt(n, 0) === CHAR_FORWARD_SLASH &&
328    StringPrototypeCharCodeAt(n, 1) === CHAR_FORWARD_SLASH
329  ) {
330    const end = StringPrototypeIndexOf(n, "/", 2);
331    if (end === -1) {
332      throw new TypeError("toFileUrl requires an absolute Windows path");
333    }
334    const rest = StringPrototypeSlice(n, end);
335    const segEnd = StringPrototypeIndexOf(rest, "/", 1);
336    const share = segEnd === -1 ? StringPrototypeSlice(rest, 1) : StringPrototypeSlice(rest, 1, segEnd);
337    // A bare, dot, or dot-dot server child names no share: normalizing it
338    // away would silently retarget the URL at another share or the server.
339    if (share === "" || share === "." || share === "..") {
340      throw new TypeError("toFileUrl requires a complete UNC share path");
341    }
342    const rootEnd = end + 1 + share.length;
343    // Normalization appends a separator to a bare share root; keep explicit
344    // trailing slashes only when the input had one.
345    let out = n;
346    if (!hadTrailing && out.length > rootEnd && StringPrototypeCharCodeAt(out, out.length - 1) === CHAR_FORWARD_SLASH) {
347      out = StringPrototypeSlice(out, 0, out.length - 1);
348    }
349    const href = `file://${checkUncHost(StringPrototypeSlice(n, 2, end))}${encodeAbsolutePath(StringPrototypeSlice(out, end))}`;
350    const url = finishFileUrl(href);
351    checkUncUrl(url);
352    return url;
353  }
354  if (
355    n.length >= 3 && isDriveLetter(StringPrototypeCharCodeAt(n, 0)) &&
356    StringPrototypeCharCodeAt(n, 1) === 58 && // :
357    StringPrototypeCharCodeAt(n, 2) === CHAR_FORWARD_SLASH
358  ) {
359    return finishFileUrl(`file:///${StringPrototypeSlice(n, 0, 2)}${encodeAbsolutePath(StringPrototypeSlice(n, 2))}`);
360  }
361  // Drive-relative (`C:foo`), root-only (`\\foo`), and relative paths all
362  // name no complete absolute location without consulting the process
363  // working directory, which URL conversion deliberately never does.
364  throw new TypeError("toFileUrl requires an absolute Windows path");
365}
366
367export { posixFromFileUrl, posixToFileUrl, win32FromFileUrl, win32ToFileUrl };