// File URL conversions for `avril:path`, bound explicitly to POSIX or // Windows path semantics. The WHATWG URL class and its property getters are // captured from `ext:url` at module scope, never read back through the // guest-mutable `globalThis.URL`; the standard percent-codec is captured the // same way, so poisoning the globals afterwards changes nothing. import { ArrayPrototypeJoin, ArrayPrototypePush, StringPrototypeCharCodeAt, StringPrototypeIncludes, StringPrototypeIndexOf, StringPrototypeIsWellFormed, StringPrototypeSlice, StringPrototypeToLowerCase, TypeError, getter, } from "ext:core/primordials"; import { URL as AvrilURL } from "ext:url/main.js"; import { splitOnChar, validateString, win32Base } from "./lexical.js"; const { decodeURIComponent, encodeURIComponent } = globalThis; const getHref = getter(AvrilURL.prototype, "href"); const getProtocol = getter(AvrilURL.prototype, "protocol"); const getHostname = getter(AvrilURL.prototype, "hostname"); const getPathname = getter(AvrilURL.prototype, "pathname"); const getSearch = getter(AvrilURL.prototype, "search"); const getHash = getter(AvrilURL.prototype, "hash"); const CHAR_FORWARD_SLASH = 47; // / const CHAR_BACKWARD_SLASH = 92; // \ function hexValueAt(input: string, index: number): number { if (index < 0 || index >= input.length) return -1; const code = StringPrototypeCharCodeAt(input, index); if (code >= 48 && code <= 57) return code - 48; if (code >= 65 && code <= 70) return code - 55; if (code >= 97 && code <= 102) return code - 87; return -1; } function rejectNul(value: string): void { if (StringPrototypeIncludes(value, "\0")) { throw new TypeError("File URL conversion does not accept NUL bytes in paths"); } } // The standard codec rejects malformed escapes, truncated sequences, // overlong forms, surrogate-range encodings, and out-of-range code points; // NUL still decodes quietly, so it is checked explicitly afterwards along // with well-formedness as defense in depth. function percentDecode(input: string): string { if (StringPrototypeIndexOf(input, "%") === -1) { rejectNul(input); return input; } let decoded; try { decoded = decodeURIComponent(input); } catch { throw new TypeError("File URL path has a malformed percent-encoding"); } rejectNul(decoded); if (!StringPrototypeIsWellFormed(decoded)) { throw new TypeError("File URL path decodes to lone surrogates"); } return decoded; } // Rejects encoded separators in the still-encoded pathname: separating // slashes must arrive literally so the URL parser, not an escape, chose them. function assertNoEncodedSeparator(raw: string, windows: boolean): void { for (let i = 0; i < raw.length; i++) { if (StringPrototypeCharCodeAt(raw, i) !== 37) continue; // % const high = hexValueAt(raw, i + 1); const low = hexValueAt(raw, i + 2); if (high < 0 || low < 0) continue; const byte = high * 16 + low; if (byte === CHAR_FORWARD_SLASH || (windows && byte === CHAR_BACKWARD_SLASH)) { throw new TypeError("File URL path must not include encoded path separators"); } } } function parseFileUrl(value: unknown): AvrilURL { if (typeof value === "string") { // WHATWG parsing normalizes lone surrogates to U+FFFD and silently drops // ASCII tab/newline characters, so reject those inputs up front. A URL // instance is already parsed and cannot be un-normalized; that residual // replacement is documented rather than re-detected here. if (!StringPrototypeIsWellFormed(value)) { throw new TypeError("File URL conversion does not accept lone surrogates in URLs"); } rejectNul(value); for (let i = 0; i < value.length; i++) { const code = StringPrototypeCharCodeAt(value, i); if (code === 9 || code === 10 || code === 13) { throw new TypeError("File URL strings must not contain tab or newline characters"); } } try { return new AvrilURL(value); } catch { throw new TypeError("Expected an absolute file: URL string or URL instance"); } } try { getHref(value); } catch { throw new TypeError("Expected an absolute file: URL string or URL instance"); } return value as AvrilURL; } function checkFileUrl(url: AvrilURL): void { if (getProtocol(url) !== "file:") { throw new TypeError("File URL conversion requires a file: URL"); } if (getSearch(url) !== "" || getHash(url) !== "") { throw new TypeError("File URL conversion does not accept URLs with a query or fragment"); } } function isDriveLetter(code: number): boolean { return (code >= 65 && code <= 90) || (code >= 97 && code <= 122); } function rejectExtendedNamespace(path: string): void { const first = StringPrototypeCharCodeAt(path, 0); const second = StringPrototypeCharCodeAt(path, 1); const third = StringPrototypeCharCodeAt(path, 2); const fourth = StringPrototypeCharCodeAt(path, 3); const separator = (code: number) => code === CHAR_FORWARD_SLASH || code === CHAR_BACKWARD_SLASH; if ( separator(first) && separator(second) && (third === 63 || third === 46) && separator(fourth) ) { throw new TypeError( "File URL conversion does not accept extended-namespace paths (\\\\?\\ or \\\\.\\)", ); } } function posixFromFileUrl(value: unknown): string { const url = parseFileUrl(value); checkFileUrl(url); const host = getHostname(url); if (host !== "" && host !== "localhost") { throw new TypeError( `File URL host "${host}" is not local; POSIX paths accept only empty or localhost hosts`, ); } const raw = getPathname(url); assertNoEncodedSeparator(raw, false); return percentDecode(raw); } function win32FromFileUrl(value: unknown): string { const url = parseFileUrl(value); checkFileUrl(url); const host = getHostname(url); const raw = getPathname(url); assertNoEncodedSeparator(raw, true); // WHATWG file URL paths always use forward slashes. const decoded = ArrayPrototypeJoin(splitOnChar(percentDecode(raw), CHAR_FORWARD_SLASH), "\\"); if (host !== "" && StringPrototypeToLowerCase(host) !== "localhost") { if (decoded.length < 2) { throw new TypeError("File URL names an incomplete UNC path"); } const segEnd = StringPrototypeIndexOf(decoded, "\\", 1); const first = segEnd === -1 ? StringPrototypeSlice(decoded, 1) : StringPrototypeSlice(decoded, 1, segEnd); if (first === "" || first === "." || first === "..") { throw new TypeError("File URL names an incomplete UNC path"); } const unc = `\\\\${host}${decoded}`; rejectExtendedNamespace(unc); return unc; } // Only fully qualified drive paths convert: drive-relative (`C:`, `C:foo`) // names no complete absolute location without consulting the process // working directory, which URL conversion deliberately never does. if ( !isDriveLetter(StringPrototypeCharCodeAt(decoded, 1) | 32) || StringPrototypeCharCodeAt(decoded, 2) !== 58 || // : decoded.length < 4 || StringPrototypeCharCodeAt(decoded, 3) !== CHAR_BACKWARD_SLASH ) { throw new TypeError("File URL path must be absolute"); } const drive = StringPrototypeSlice(decoded, 1); rejectExtendedNamespace(drive); return drive; } // ASCII characters kept literally in generated file URLs, matching Node's // pathToFileURL table: alphanumerics, sub-delims, `@:`, and `;`, `=`, `,`, // `-`, `.`, `_`. Everything else (including `%`, space, `"#<>?`, backtick, // braces, `~`, `|^[]`, backslash, controls, DEL, and non-ASCII as UTF-8) is // percent-encoded by the standard codec with uppercase hex. function isPathCharKept(code: number): boolean { return (code >= 48 && code <= 57) || (code >= 65 && code <= 90) || (code >= 97 && code <= 122) || code === 33 || code === 36 || code === 38 || code === 39 || code === 40 || code === 41 || code === 42 || code === 43 || code === 44 || code === 45 || code === 46 || code === 58 || code === 59 || code === 61 || code === 64 || code === 95; } // `path` uses forward slashes throughout; callers normalize Windows // separators first so backslashes never leak in literally. function encodeAbsolutePath(path: string): string { const pieces: string[] = []; let start = 0; for (let i = 0; i < path.length;) { const code = StringPrototypeCharCodeAt(path, i); if (code === CHAR_FORWARD_SLASH || (code < 128 && isPathCharKept(code))) { i++; continue; } ArrayPrototypePush(pieces, StringPrototypeSlice(path, start, i)); // The standard codec leaves `~` literally, but Node's file URLs encode // it, so it is escaped here to match. if (code === 126) { ArrayPrototypePush(pieces, "%7E"); i++; } else if (code >= 55296 && code <= 56319) { ArrayPrototypePush(pieces, encodeURIComponent(StringPrototypeSlice(path, i, i + 2))); i += 2; } else { ArrayPrototypePush(pieces, encodeURIComponent(StringPrototypeSlice(path, i, i + 1))); i++; } start = i; } ArrayPrototypePush(pieces, StringPrototypeSlice(path, start, path.length)); return ArrayPrototypeJoin(pieces, ""); } // UNC servers are validated strictly instead of percent-encoded: the URL // parser silently strips some invalid characters (notably tab and newline), // which would otherwise point the resulting URL elsewhere. Bracketed IPv6 // literals are checked structurally; other non-ASCII names pass through for // the parser's IDNA handling and normalize to punycode. function checkUncHost(host: string): string { if (StringPrototypeToLowerCase(host) === "localhost") { throw new TypeError( "toFileUrl does not represent the local machine as a UNC server; " + "use a drive path such as C:\\... instead", ); } if (StringPrototypeCharCodeAt(host, 0) === 91) { // [ if (StringPrototypeCharCodeAt(host, host.length - 1) !== 93) { // ] throw new TypeError("toFileUrl received an invalid IPv6 UNC server"); } for (let i = 1; i < host.length - 1; i++) { const code = StringPrototypeCharCodeAt(host, i); const valid = (code >= 48 && code <= 57) || (code >= 65 && code <= 70) || (code >= 97 && code <= 102) || code === 46 || code === 58 || code === 37; if (!valid) { throw new TypeError("toFileUrl received an invalid IPv6 UNC server"); } } return host; } for (let i = 0; i < host.length; i++) { const code = StringPrototypeCharCodeAt(host, i); if ( code < 33 || code === 127 || code === 47 || code === 58 || // controls, space, DEL, / : code === 63 || code === 64 || code === 91 || code === 93 || // ? @ [ ] code === 35 || code === 37 // # % ) { throw new TypeError(`toFileUrl received an invalid UNC server "${host}"`); } } return host; } // The URL parser lowercases hosts and applies IDNA itself; confirm the UNC // server survived construction instead of collapsing (notably to localhost, // which file URLs treat as "no host"). function checkUncUrl(url: AvrilURL): void { const host = getHostname(url); if (host === "" || StringPrototypeToLowerCase(host) === "localhost") { throw new TypeError("toFileUrl lost the UNC server to URL normalization"); } } function finishFileUrl(href: string): AvrilURL { try { return new AvrilURL(href); } catch { throw new TypeError("Path could not be converted to a file: URL"); } } function checkToFileUrlInput(path: unknown, windows: boolean): asserts path is string { validateString(path, "path"); if (StringPrototypeIncludes(path, "\0")) { throw new TypeError("File URL conversion does not accept NUL bytes in paths"); } if (!StringPrototypeIsWellFormed(path)) { throw new TypeError("File URL conversion does not accept lone surrogates in paths"); } if (windows) rejectExtendedNamespace(path); } function posixToFileUrl(path: unknown): AvrilURL { checkToFileUrlInput(path, false); if (StringPrototypeCharCodeAt(path, 0) !== CHAR_FORWARD_SLASH) { throw new TypeError("toFileUrl requires an absolute POSIX path"); } return finishFileUrl(`file://${encodeAbsolutePath(path)}`); } function win32ToFileUrl(path: unknown): AvrilURL { checkToFileUrlInput(path, true); const unified = ArrayPrototypeJoin(splitOnChar(path, CHAR_BACKWARD_SLASH), "/"); const hadTrailing = StringPrototypeCharCodeAt(unified, unified.length - 1) === CHAR_FORWARD_SLASH; // Clamp `.`/`..` lexically so they cannot cross a drive or share root; the // URL parser below does not know the UNC share boundary. const n = ArrayPrototypeJoin(splitOnChar(win32Base.normalize(unified), CHAR_BACKWARD_SLASH), "/"); if ( StringPrototypeCharCodeAt(n, 0) === CHAR_FORWARD_SLASH && StringPrototypeCharCodeAt(n, 1) === CHAR_FORWARD_SLASH ) { const end = StringPrototypeIndexOf(n, "/", 2); if (end === -1) { throw new TypeError("toFileUrl requires an absolute Windows path"); } const rest = StringPrototypeSlice(n, end); const segEnd = StringPrototypeIndexOf(rest, "/", 1); const share = segEnd === -1 ? StringPrototypeSlice(rest, 1) : StringPrototypeSlice(rest, 1, segEnd); // A bare, dot, or dot-dot server child names no share: normalizing it // away would silently retarget the URL at another share or the server. if (share === "" || share === "." || share === "..") { throw new TypeError("toFileUrl requires a complete UNC share path"); } const rootEnd = end + 1 + share.length; // Normalization appends a separator to a bare share root; keep explicit // trailing slashes only when the input had one. let out = n; if (!hadTrailing && out.length > rootEnd && StringPrototypeCharCodeAt(out, out.length - 1) === CHAR_FORWARD_SLASH) { out = StringPrototypeSlice(out, 0, out.length - 1); } const href = `file://${checkUncHost(StringPrototypeSlice(n, 2, end))}${encodeAbsolutePath(StringPrototypeSlice(out, end))}`; const url = finishFileUrl(href); checkUncUrl(url); return url; } if ( n.length >= 3 && isDriveLetter(StringPrototypeCharCodeAt(n, 0)) && StringPrototypeCharCodeAt(n, 1) === 58 && // : StringPrototypeCharCodeAt(n, 2) === CHAR_FORWARD_SLASH ) { return finishFileUrl(`file:///${StringPrototypeSlice(n, 0, 2)}${encodeAbsolutePath(StringPrototypeSlice(n, 2))}`); } // Drive-relative (`C:foo`), root-only (`\\foo`), and relative paths all // name no complete absolute location without consulting the process // working directory, which URL conversion deliberately never does. throw new TypeError("toFileUrl requires an absolute Windows path"); } export { posixFromFileUrl, posixToFileUrl, win32FromFileUrl, win32ToFileUrl };