Source code

Revision control

Copy as Markdown

Other Tools

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
});
/**
* Parsed pattern to use for URL matching.
*
* @typedef {object} ParsedURLPattern
* @property {string|null} protocol
* The protocol, for instance "https".
* @property {string|null} hostname
* The hostname, for instance "example.com".
* @property {string|null} port
* The serialized port. Empty string for default ports of special schemes.
* @property {string|null} path
* The path, starting with "/".
* @property {string|null} search
* The search query string, without the leading "?"
*/
/**
* Subset of properties extracted from a parsed URL.
*
* @typedef {object} ParsedURL
* @property {string=} host
* @property {string|Array<string>} path
* Either a string if the path is an opaque path, or an array of strings
* (path segments).
* @property {number=} port
* @property {string=} query
* @property {string=} scheme
*/
/**
* Enum of URLPattern types.
*
* @readonly
* @enum {URLPatternType}
*/
const URLPatternType = {
Pattern: "pattern",
String: "string",
};
const supportedURLPatternTypes = Object.values(URLPatternType);
const SPECIAL_SCHEMES = ["file", "http", "https", "ws", "wss"];
const DEFAULT_PORTS = {
file: null,
http: 80,
https: 443,
ws: 80,
wss: 443,
};
/**
* Check if a given URL pattern is compatible with the provided URL.
*
*
* @param {ParsedURLPattern} urlPattern
* The URL pattern to match.
* @param {string} url
* The string representation of a URL to test against the pattern.
*
* @returns {boolean}
* True if the pattern is compatible with the provided URL, false otherwise.
*/
export function matchURLPattern(urlPattern, url) {
const parsedURL = parseURL(url);
if (urlPattern.protocol !== null && urlPattern.protocol != parsedURL.scheme) {
return false;
}
if (urlPattern.hostname !== null && urlPattern.hostname != parsedURL.host) {
return false;
}
if (urlPattern.port !== null && urlPattern.port != serializePort(parsedURL)) {
return false;
}
if (
urlPattern.pathname !== null &&
urlPattern.pathname != serializePath(parsedURL)
) {
return false;
}
if (urlPattern.search !== null) {
const urlQuery = parsedURL.query === null ? "" : parsedURL.query;
if (urlPattern.search != urlQuery) {
return false;
}
}
return true;
}
/**
* Parse a URLPattern into a parsed pattern object which can be used to match
* URLs using `matchURLPattern`.
*
*
* @param {URLPattern} pattern
* The pattern to parse.
*
* @returns {ParsedURLPattern}
* The parsed URL pattern.
*
* @throws {InvalidArgumentError}
* Raised if an argument is of an invalid type or value.
* @throws {UnsupportedOperationError}
* Raised if the pattern uses a protocol not supported by Firefox.
*/
export function parseURLPattern(pattern) {
lazy.assert.object(
pattern,
`Expected url pattern to be an object, got ${pattern}`
);
let hasProtocol = true;
let hasHostname = true;
let hasPort = true;
let hasPathname = true;
let hasSearch = true;
let patternUrl;
switch (pattern.type) {
case URLPatternType.Pattern:
patternUrl = "";
if ("protocol" in pattern) {
patternUrl += parseProtocol(pattern.protocol);
} else {
hasProtocol = false;
patternUrl += "http";
}
const scheme = patternUrl.toLowerCase();
patternUrl += ":";
if (SPECIAL_SCHEMES.includes(scheme)) {
patternUrl += "//";
}
if ("hostname" in pattern) {
patternUrl += parseHostname(pattern.hostname, scheme);
} else {
if (scheme != "file") {
patternUrl += "placeholder";
}
hasHostname = false;
}
if ("port" in pattern) {
patternUrl += parsePort(pattern.port);
} else {
hasPort = false;
}
if ("pathname" in pattern) {
patternUrl += parsePathname(pattern.pathname);
} else {
hasPathname = false;
}
if ("search" in pattern) {
patternUrl += parseSearch(pattern.search);
} else {
hasSearch = false;
}
break;
case URLPatternType.String:
lazy.assert.string(
pattern.pattern,
`Expected "urlPattern" of type "string" to have a string "pattern" property, got ${pattern.pattern}`
);
patternUrl = unescapeUrlPattern(pattern.pattern);
break;
default:
throw new lazy.error.InvalidArgumentError(
`Expected "urlPattern" type to be one of ${supportedURLPatternTypes}, got ${pattern.type}`
);
}
if (!URL.canParse(patternUrl)) {
throw new lazy.error.InvalidArgumentError(
`Unable to parse URL "${patternUrl}"`
);
}
let parsedURL;
try {
parsedURL = parseURL(patternUrl);
} catch (e) {
throw new lazy.error.InvalidArgumentError(
`Failed to parse URL "${patternUrl}"`
);
}
if (hasProtocol && !SPECIAL_SCHEMES.includes(parsedURL.scheme)) {
throw new lazy.error.UnsupportedOperationError(
`URL pattern did not specify a supported protocol (one of ${SPECIAL_SCHEMES}), got ${parsedURL.scheme}`
);
}
return {
protocol: hasProtocol ? parsedURL.scheme : null,
hostname: hasHostname ? parsedURL.host : null,
port: hasPort ? serializePort(parsedURL) : null,
pathname:
hasPathname && parsedURL.path.length ? serializePath(parsedURL) : null,
search: hasSearch ? parsedURL.query || "" : null,
};
}
/**
* Parse the hostname property of a URLPatternPattern.
*
* @param {string} hostname
* A hostname property.
* @param {string} scheme
* The scheme for the URLPatternPattern.
*
* @returns {string}
* The parsed property.
*
* @throws {InvalidArgumentError}
* Raised if an argument is of an invalid type or value.
*/
function parseHostname(hostname, scheme) {
if (typeof hostname != "string" || hostname == "") {
throw new lazy.error.InvalidArgumentError(
`Expected URLPattern "hostname" to be a non-empty string, got ${hostname}`
);
}
if (scheme == "file") {
throw new lazy.error.InvalidArgumentError(
`URLPattern with "file" scheme cannot specify a hostname, got ${hostname}`
);
}
hostname = unescapeUrlPattern(hostname);
const forbiddenHostnameCharacters = ["/", "?", "#"];
let insideBrackets = false;
for (const codepoint of hostname) {
if (
forbiddenHostnameCharacters.includes(codepoint) ||
(!insideBrackets && codepoint == ":")
) {
throw new lazy.error.InvalidArgumentError(
`URL pattern "hostname" contained a forbidden character, got "${hostname}"`
);
}
if (codepoint == "[") {
insideBrackets = true;
} else if (codepoint == "]") {
insideBrackets = false;
}
}
return hostname;
}
/**
* Parse the pathname property of a URLPatternPattern.
*
* @param {string} pathname
* A pathname property.
*
* @returns {string}
* The parsed property.
*
* @throws {InvalidArgumentError}
* Raised if an argument is of an invalid type or value.
*/
function parsePathname(pathname) {
lazy.assert.string(
pathname,
`Expected URLPattern "pathname" to be a string, got ${pathname}`
);
pathname = unescapeUrlPattern(pathname);
if (!pathname.startsWith("/")) {
pathname = `/${pathname}`;
}
if (pathname.includes("?") || pathname.includes("#")) {
throw new lazy.error.InvalidArgumentError(
`URL pattern "pathname" contained a forbidden character, got "${pathname}"`
);
}
return pathname;
}
/**
* Parse the port property of a URLPatternPattern.
*
* @param {string} port
* A port property.
*
* @returns {string}
* The parsed property.
*
* @throws {InvalidArgumentError}
* Raised if an argument is of an invalid type or value.
*/
function parsePort(port) {
if (typeof port != "string" || port == "") {
throw new lazy.error.InvalidArgumentError(
`Expected URLPattern "port" to be a non-empty string, got ${port}`
);
}
port = unescapeUrlPattern(port);
const isNumber = /^\d*$/.test(port);
if (!isNumber) {
throw new lazy.error.InvalidArgumentError(
`URL pattern "port" is not a valid number, got "${port}"`
);
}
return `:${port}`;
}
/**
* Parse the protocol property of a URLPatternPattern.
*
* @param {string} protocol
* A protocol property.
*
* @returns {string}
* The parsed property.
*
* @throws {InvalidArgumentError}
* Raised if an argument is of an invalid type or value.
*/
function parseProtocol(protocol) {
if (typeof protocol != "string" || protocol == "") {
throw new lazy.error.InvalidArgumentError(
`Expected URLPattern "protocol" to be a non-empty string, got ${protocol}`
);
}
protocol = unescapeUrlPattern(protocol);
if (!/^[a-zA-Z0-9+-.]*$/.test(protocol)) {
throw new lazy.error.InvalidArgumentError(
`URL pattern "protocol" contained a forbidden character, got "${protocol}"`
);
}
return protocol;
}
/**
* Parse the search property of a URLPatternPattern.
*
* @param {string} search
* A search property.
*
* @returns {string}
* The parsed property.
*
* @throws {InvalidArgumentError}
* Raised if an argument is of an invalid type or value.
*/
function parseSearch(search) {
lazy.assert.string(
search,
`Expected URLPattern "search" to be a string, got ${search}`
);
search = unescapeUrlPattern(search);
if (!search.startsWith("?")) {
search = `?${search}`;
}
if (search.includes("#")) {
throw new lazy.error.InvalidArgumentError(
`Expected URLPattern "search" to never contain "#", got ${search}`
);
}
return search;
}
/**
* Parse a string URL. This tries to be close to Basic URL Parser, however since
* this is not currently implemented in Firefox and URL parsing has many edge
* cases, it does not try to be a faithful implementation.
*
* Edge cases which are not supported are mostly about non-special URLs, which
* in practice should not be observable in automation.
*
* @param {string} url
* The string based URL to parse.
* @returns {ParsedURL}
* The parsed URL.
*/
function parseURL(url) {
const urlObj = new URL(url);
const uri = urlObj.URI;
return {
scheme: uri.scheme,
// Note: Use urlObj instead of uri for hostname:
// nsIURI removes brackets from ipv6 hostnames (eg [::1] becomes ::1).
host: urlObj.hostname,
path: uri.filePath,
// Note: Use urlObj instead of uri for port:
// nsIURI throws on the port getter for non-special schemes.
port: urlObj.port != "" ? Number(uri.port) : null,
query: uri.hasQuery ? uri.query : null,
};
}
/**
* Serialize the path of a parsed URL.
*
*
* @param {ParsedURL} url
* A parsed url.
*
* @returns {string}
* The serialized path
*/
function serializePath(url) {
// Check for opaque path
if (typeof url.path == "string") {
return url.path;
}
let serialized = "";
for (const segment of url.path) {
serialized += `/${segment}`;
}
return serialized;
}
/**
* Serialize the port of a parsed URL.
*
*
* @param {ParsedURL} url
* A parsed url.
*
* @returns {string}
* The serialized port
*/
function serializePort(url) {
let port = null;
if (
SPECIAL_SCHEMES.includes(url.scheme) &&
DEFAULT_PORTS[url.scheme] !== null &&
(url.port === null || url.port == DEFAULT_PORTS[url.scheme])
) {
port = "";
} else if (url.port !== null) {
port = `${url.port}`;
}
return port;
}
/**
* Unescape and check a pattern string against common forbidden characters.
*
*
* @param {string} pattern
* Either a full URLPatternString pattern or a property of a URLPatternPattern.
*
* @returns {string}
* The unescaped pattern
*
* @throws {InvalidArgumentError}
* Raised if an argument is of an invalid type or value.
*/
function unescapeUrlPattern(pattern) {
const forbiddenCharacters = ["(", ")", "*", "{", "}"];
const escapeCharacter = "\\";
let isEscaped = false;
let result = "";
for (const codepoint of Array.from(pattern)) {
if (!isEscaped) {
if (forbiddenCharacters.includes(codepoint)) {
throw new lazy.error.InvalidArgumentError(
`URL pattern contained an unescaped forbidden character ${codepoint}`
);
}
if (codepoint == escapeCharacter) {
isEscaped = true;
continue;
}
}
result += codepoint;
isEscaped = false;
}
return result;
}