Просмотр исходного кода

Merge remote-tracking branch 'LOCAL-main/master' into vue3

# Conflicts:
#	test/background/tester.test.js
tophf 3 лет назад
Родитель
Сommit
45c90e5f56

+ 7 - 5
src/background/utils/db.js

@@ -511,14 +511,16 @@ export async function updateScriptInfo(id, data) {
 }
 
 /**
- * @param {string} code
+ * @param {string | {code:string, custom:VMScript.Custom}} src
  * @return {{ meta: VMScript.Meta, errors: string[] }}
  */
-function parseMetaWithErrors(code) {
-  const meta = parseMeta(code);
+function parseMetaWithErrors(src) {
+  const isObject = typeof src === 'object';
+  const custom = isObject && src.custom || getDefaultCustom();
+  const meta = parseMeta(isObject ? src.code : src);
   const errors = [];
   testerBatch(errors);
-  testScript('', { custom: getDefaultCustom(), meta });
+  testScript('', { meta, custom });
   testerBatch();
   return {
     meta,
@@ -528,7 +530,7 @@ function parseMetaWithErrors(code) {
 
 /** @return {Promise<{ isNew?, update, where }>} */
 export async function parseScript(src) {
-  const { meta, errors } = parseMetaWithErrors(src.code);
+  const { meta, errors } = parseMetaWithErrors(src);
   if (!meta.name) throw `${i18n('msgInvalidScript')}\n${i18n('labelNoName')}`;
   const result = {
     errors,

+ 157 - 96
src/background/utils/tester.js

@@ -1,3 +1,4 @@
+/* eslint-disable max-classes-per-file */
 import { getScriptPrettyUrl } from '@/common';
 import { BLACKLIST, BLACKLIST_ERRORS } from '@/common/consts';
 import initCache from '@/common/cache';
@@ -11,7 +12,7 @@ Object.assign(commands, {
   TestBlacklist: testBlacklist,
 });
 
-const matchAlways = () => 1;
+const matchAlways = { test: () => 1 };
 /**
  * Using separate caches to avoid memory consumption for thousands of prefixed long urls
  * TODO: switch `cache` to hubs internally and add a prefix parameter or accept an Array for key
@@ -20,13 +21,47 @@ const cacheMat = initCache({ lifetime: 60 * 60e3 });
 const cacheInc = initCache({ lifetime: 60 * 60e3 });
 const cacheResultMat = initCache({ lifetime: 60e3 });
 const cacheResultInc = initCache({ lifetime: 60e3 });
-const RE_MATCH_PARTS = /(.*?):\/\/([^/]*)\/(.*)/;
-const RE_HTTP_OR_HTTPS = /^https?$/i;
+/** Simple matching for valid patterns */
+const RE_MATCH_PARTS = re`/^
+  (\*|http([s*])?|file|ftp|urn):\/\/
+  ([^/]*)\/
+  (.*)
+/x`;
+/** Resilient matching for broken patterns allows reporting errors with a helpful message */
+const RE_MATCH_BAD = re`/^
+  (
+    \*|
+    # allowing the incorrect http* scheme which is the same as *
+    http([s*])?|
+    file|
+    ftp|
+    urn|
+    # detecting an unknown scheme
+    ([^:]*?)(?=:)
+  )
+  # detecting a partially missing ://
+  (:(?:\/(?:\/)?)?)?
+  ([^/]*)
+  # detecting a missing / for path
+  (?:\/(.*))?
+/x`;
+/** Simpler matching for a valid URL */
+const RE_URL_PARTS = /^([^:]*):\/\/([^/]*)\/(.*)/;
+const RE_STR_ANY = '(?:|[^:/]*?\\.)';
+const RE_STR_TLD = '(|(?:\\.[-\\w]+)+)';
 const MAX_BL_CACHE_LENGTH = 100e3;
 let blCache = {};
 let blCacheSize = 0;
 let blacklistRules = [];
+// Context start
 let batchErrors;
+let curUrl;
+let curScheme;
+let curHost;
+let curTail;
+let urlResultsMat;
+let urlResultsInc;
+// Context end
 
 postInitialize.push(resetBlacklist);
 hookOptions((changes) => {
@@ -39,6 +74,77 @@ hookOptions((changes) => {
 });
 tld.initTLD(true);
 
+export class MatchTest {
+  constructor(rule, scheme, httpMod, host, path) {
+    const isWild = scheme === '*' || httpMod === '*';
+    this.scheme = isWild ? 'http' : scheme;
+    this.scheme2 = isWild ? 'https' : null;
+    this.host = host === '*' ? null : hostMatcher(host);
+    this.path = path === '*' ? null : pathMatcher(path);
+  }
+
+  test() {
+    return (this.scheme === curScheme || this.scheme2 === curScheme)
+      && this.host?.test(curHost) !== false
+      && this.path?.test(curTail) !== false;
+  }
+
+  /**
+   * @returns {MatchTest|matchAlways}
+   * @throws {string}
+   */
+  static try(rule) {
+    const parts = rule.match(RE_MATCH_PARTS);
+    if (parts) return new MatchTest(...parts);
+    if (rule === '<all_urls>') return matchAlways; // checking it second as it's super rare
+    throw `Bad pattern: ${MatchTest.fail(rule)} in ${rule}`;
+  }
+
+  static fail(rule) {
+    const parts = rule.match(RE_MATCH_BAD);
+    return (
+      (parts[3] != null ? `${parts[3] ? 'unknown' : 'missing'} scheme, ` : '')
+      + (parts[4] !== '://' ? 'missing "://", ' : '')
+      || (parts[6] == null ? 'missing "/" for path, ' : '')
+    ).slice(0, -2);
+  }
+}
+
+/** For strings without wildcards/tld it's 1.5x faster and much more memory-efficient than RegExp */
+class StringTest {
+  constructor(str, i, ignoreCase) {
+    this.s = ignoreCase ? str.toLowerCase() : str;
+    this.i = !!ignoreCase; // must be boolean to ensure test() returns boolean
+    this.cmp = i < 0 ? '' : (i && 'startsWith' || 'endsWith');
+  }
+
+  test(str) {
+    const { s, cmp } = this;
+    const delta = str.length - s.length;
+    const res = delta >= 0 && (
+      cmp && delta
+        ? str[cmp](s) || this.i && str.toLowerCase()[cmp](s)
+        : str === s || !delta && this.i && str.toLowerCase() === s
+    );
+    return res;
+  }
+
+  /** @returns {?StringTest} */
+  static try(rule, ignoreCase) {
+    // TODO: support *. for domain if it's faster than regex
+    const i = rule.indexOf('*');
+    if (i === rule.length - 1) {
+      rule = rule.slice(0, -1); // prefix*
+    } else if (i === 0 && rule.indexOf('*', 1) < 0) {
+      rule = rule.slice(1); // *suffix
+    } else if (i >= 0) {
+      return; // *wildcards*anywhere*
+    }
+    return new StringTest(rule, i, ignoreCase);
+  }
+}
+
+
 export function testerBatch(arr) {
   cacheMat.batch(arr);
   cacheInc.batch(arr);
@@ -47,6 +153,15 @@ export function testerBatch(arr) {
   batchErrors = Array.isArray(arr) && arr;
 }
 
+function setContext(url) {
+  curUrl = url;
+  [, curScheme, curHost, curTail] = url
+    ? url.match(RE_URL_PARTS)
+    : ['', '', '', '']; // parseMetaWithErrors uses an empty url for tests
+  urlResultsMat = url ? (cacheResultMat.get(url) || cacheResultMat.put(url, {})) : null;
+  urlResultsInc = url ? (cacheResultInc.get(url) || cacheResultInc.put(url, {})) : null;
+}
+
 /**
  * As this code is *very* hot, we avoid calling functions or creating possibly big arrays
  * or creating copies of thousands of keys by prefixing them in `cache`, thus we avoid pauses
@@ -78,6 +193,7 @@ export function testScript(url, script) {
 }
 
 function testRules(url, script, ...list) {
+  if (curUrl !== url) setContext(url);
   // TODO: combine all non-regex rules in one big smart regexp
   // e.g. lots of `*://foo/*` can be combined into `^https?://(foo|bar|baz)/`
   for (let i = 0, m, rules, builder, cache, urlResults, res, err, scriptUrl; i < 4; i += 1) {
@@ -85,18 +201,17 @@ function testRules(url, script, ...list) {
     if ((rules = list[i]).length) {
       if (!cache) { // happens one time for 0 or 1 and another time for 2 or 3
         if (i < 2) { // matches1, matches2
-          builder = matchTester;
+          builder = MatchTest.try;
           cache = cacheMat;
-          urlResults = cacheResultMat;
+          urlResults = urlResultsMat;
         } else { // includes1, includes2
           builder = autoReg;
           cache = cacheInc;
-          urlResults = cacheResultInc;
+          urlResults = urlResultsInc;
         }
-        urlResults = urlResults.get(url) || urlResults.put(url, {});
       }
       for (const rule of rules) {
-        if ((res = urlResults[rule])) {
+        if (url && (res = urlResults[rule])) {
           return res;
         }
         if (res == null) {
@@ -116,7 +231,7 @@ function testRules(url, script, ...list) {
                 : err;
               batchErrors.push(err);
             }
-          } else if ((urlResults[rule] = m.test(url))) {
+          } else if (url && (urlResults[rule] = +!!m.test(url))) {
             return true;
           }
         }
@@ -127,8 +242,7 @@ function testRules(url, script, ...list) {
 }
 
 function str2RE(str) {
-  const re = str.replace(/([.?+[\]{}()|^$])/g, '\\$1').replace(/\*/g, '.*?');
-  return re;
+  return str.replace(/[.?+[\]{}()|^$]/g, '\\$&').replace(/\*/g, '.*?');
 }
 
 function autoReg(str) {
@@ -136,115 +250,62 @@ function autoReg(str) {
   if (str.length > 1 && str[0] === '/' && str[str.length - 1] === '/') {
     return new RegExp(str.slice(1, -1), 'i');
   }
-  // glob mode: case-insensitive to match GM4 & Tampermonkey bugged behavior
-  const reStr = str2RE(str.toLowerCase());
-  const reTldStr = reStr.replace('\\.tld/', '((?:\\.[-\\w]+)+)/');
-  if (reStr !== reTldStr) {
-    return { test: matchTld.bind([reTldStr]) };
+  const isTld = str.includes('.tld/');
+  const strTester = !isTld && StringTest.try(str, true);
+  if (strTester) {
+    return strTester;
   }
-  // String with wildcards
-  return RegExp(`^${reStr}$`, 'i');
+  // glob mode: case-insensitive to match GM4 & Tampermonkey bugged behavior
+  const reStr = `^${str2RE(str)}$`;
+  const reTldStr = isTld ? reStr.replace('\\.tld/', '((?:\\.[-\\w]+)+)/') : reStr;
+  const re = RegExp(reTldStr, 'i');
+  if (reStr !== reTldStr) re.test = matchTld;
+  return re;
 }
 
 function matchTld(tstr) {
-  const matches = tstr.toLowerCase().match(this[0]);
-  const suffix = matches?.[1].slice(1);
-  return suffix && tld.getPublicSuffix(suffix) === suffix;
+  const matches = tstr.match(this);
+  const suffix = matches?.[1]?.slice(1).toLowerCase();
+  // Must return a proper boolean
+  return !!suffix && tld.getPublicSuffix(suffix) === suffix;
 }
 
-function matchScheme(rule, data) {
-  // exact match
-  if (rule === data) return 1;
-  // * = http | https
-  // support http*
-  if ((rule === '*' || rule === 'http*') && RE_HTTP_OR_HTTPS.test(data)) return 1;
-  return 0;
-}
-
-const RE_STR_ANY = '(?:|.*?\\.)';
-const RE_STR_TLD = '((?:\\.[-\\w]+)+)';
 function hostMatcher(rule) {
-  // * matches all
-  if (rule === '*') {
-    return matchAlways;
-  }
+  // host matching is case-insensitive
   // *.example.com
   // www.google.*
   // www.google.tld
-  const ruleLC = rule.toLowerCase(); // host matching is case-insensitive
+  const isTld = rule.endsWith('.tld') && tld.isReady();
   let prefix = '';
-  let base = ruleLC;
+  let base = rule;
   let suffix = '';
+  let strTester;
   if (rule.startsWith('*.')) {
     base = base.slice(2);
     prefix = RE_STR_ANY;
+  } else if (!isTld && (strTester = StringTest.try(rule, true))) {
+    return strTester;
   }
-  if (tld.isReady() && rule.endsWith('.tld')) {
+  if (isTld) {
     base = base.slice(0, -4);
     suffix = RE_STR_TLD;
   }
-  const re = new RegExp(`^${prefix}${str2RE(base)}${suffix}$`);
-  return hostMatcherFunc.bind([ruleLC, re]);
-}
-
-function hostMatcherFunc(data) {
-  // exact match, case-insensitive
-  data = data.toLowerCase();
-  if (this[0] === data) return 1;
-  // full check
-  const matches = data.match(this[1]);
-  if (matches) {
-    const [, tldStr] = matches;
-    if (!tldStr) return 1;
-    const tldSuffix = tldStr.slice(1);
-    return tld.getPublicSuffix(tldSuffix) === tldSuffix;
-  }
-  return 0;
-}
-
-function pathMatcher(rule) {
-  const iHash = rule.indexOf('#');
-  let iQuery = rule.indexOf('?');
-  let strRe = str2RE(rule);
-  if (iQuery > iHash) iQuery = -1;
-  if (iHash < 0) {
-    if (iQuery < 0) strRe = `^${strRe}(?:[?#]|$)`;
-    else strRe = `^${strRe}(?:#|$)`;
-  }
-  return RegExp(strRe);
-}
-
-function matchTester(rule) {
-  let test;
-  if (rule === '<all_urls>') {
-    test = matchAlways;
-  } else {
-    const ruleParts = rule.match(RE_MATCH_PARTS);
-    if (ruleParts) {
-      test = matchTesterFunc.bind([
-        ruleParts[1],
-        hostMatcher(ruleParts[2]),
-        pathMatcher(ruleParts[3]),
-      ]);
-    } else {
-      throw `Invalid @match ${rule}`;
-    }
-  }
-  return { test };
+  const re = RegExp(`^${prefix}${str2RE(base)}${suffix}$`, 'i');
+  if (isTld) re.test = matchTld;
+  return re;
 }
 
-function matchTesterFunc(url) {
-  const parts = url.match(RE_MATCH_PARTS);
-  return +!!(parts
-    && matchScheme(this[0], parts[1])
-    && this[1](parts[2])
-    && this[2].test(parts[3])
-  );
+function pathMatcher(tail) {
+  const iQuery = tail.indexOf('?');
+  const hasHash = tail.indexOf('#', iQuery + 1) >= 0;
+  return hasHash && StringTest.try(tail)
+    || RegExp(`^${str2RE(tail)}${hasHash ? '$' : `($|${iQuery >= 0 ? '#' : '[?#]'})`}`);
 }
 
 export function testBlacklist(url) {
   let res = blCache[url];
   if (res === undefined) {
+    if (curUrl !== url) setContext(url);
     const rule = blacklistRules.find(m => m.test(url));
     res = rule?.reject && rule.text;
     updateBlacklistCache(url, res || false);
@@ -269,8 +330,8 @@ export function resetBlacklist(rules = getOption(BLACKLIST)) {
       const rule = mode ? text.slice(mode.length + 1).trim() : text;
       const isInc = mode === '@include';
       const m = (isInc || mode === '@exclude') && emplace(cacheInc, rule, autoReg)
-      || !mode && !rule.includes('/') && emplace(cacheMat, `*://${rule}/*`, matchTester) // domain
-      || emplace(cacheMat, rule, matchTester); // @match and @exclude-match
+      || !mode && !rule.includes('/') && emplace(cacheMat, `*://${rule}/*`, MatchTest.try) // domain
+      || emplace(cacheMat, rule, MatchTest.try); // @match and @exclude-match
       m.reject = !(mode === '@match' || isInc); // @include and @match = whitelist
       m.text = text;
       res.push(m);

+ 6 - 3
src/common/ui/index.js

@@ -32,12 +32,15 @@ export function showMessage(message) {
  * @param {string | false} [cfg.input=false] if not false, shows a text input with this string
  * @param {?Object|false} [cfg.ok] additional props for the Ok button or `false` to remove it
  * @param {?Object|false} [cfg.cancel] same for the Cancel button
- * @return {Promise<?string|true>} resolves on Ok to `true` or the entered string, null otherwise
+ * @return {Promise<?string|boolean>}
+ *   `input` is false: <boolean> i.e. true on Ok, false otherwise;
+ *   `input` is string: <?string> i.e. string on Ok, null otherwise;
  */
 export function showConfirmation(text, { ok, cancel, input = false } = {}) {
   return new Promise(resolve => {
-    const onCancel = () => resolve(null);
-    const onOk = val => resolve(input === false || val);
+    const hasInput = input !== false;
+    const onCancel = () => resolve(hasInput ? null : false);
+    const onOk = val => resolve(!hasInput || val);
     showMessage({
       input,
       text,

+ 4 - 52
src/injected/content/bridge.js

@@ -1,8 +1,6 @@
 import { INJECT_PAGE, browser } from '../util';
 import { sendCmd } from './util';
 
-const allowed = createNullObj();
-const dataKeyNameMap = createNullObj();
 const handlers = createNullObj();
 const bgHandlers = createNullObj();
 const onScripts = [];
@@ -13,38 +11,9 @@ const assignHandlers = (dest, src, force) => {
     onScripts.push(() => assign(dest, src));
   }
 };
-const allowCmd = (cmd, dataKey) => {
-  ensureNestedProp(allowed, cmd, dataKey, true);
-};
-const XHR = ['HttpRequest', 'AbortRequest'];
-const ADD_ELEMENT = ['AddElement'];
-const UPDATE_VALUE = ['UpdateValue'];
-const TAB_CLOSE = ['TabClose'];
-const GET_RESOURCE = ['GetResource'];
-const GM_CMD = {
-  __proto__: null,
-  addElement: ADD_ELEMENT,
-  addStyle: ADD_ELEMENT,
-  deleteValue: UPDATE_VALUE,
-  download: XHR,
-  getResourceText: GET_RESOURCE,
-  getResourceURL: GET_RESOURCE, // also allows the misspelled GM.getResourceURL for compatibility
-  notification: ['Notification', 'RemoveNotification'],
-  openInTab: ['TabOpen', TAB_CLOSE],
-  registerMenuCommand: ['RegisterMenu'],
-  setClipboard: ['SetClipboard'],
-  setValue: UPDATE_VALUE,
-  unregisterMenuCommand: ['UnregisterMenu'],
-};
-const GRANT_CMD = {
-  __proto__: null,
-  'GM.getResourceUrl': GET_RESOURCE,
-  'GM.xmlHttpRequest': XHR,
-  'GM_xmlhttpRequest': XHR, // eslint-disable-line quote-props
-  [WINDOW_CLOSE]: TAB_CLOSE,
-  [WINDOW_FOCUS]: ['TabFocus'],
-};
-
+/**
+ * @property {VMBridgePostFunc} post
+ */
 const bridge = {
   __proto__: null,
   ids: [], // all ids including the disabled ones for SetPopup
@@ -57,7 +26,6 @@ const bridge = {
   pathMaps: createNullObj(),
   /** @type {function(VMInjection)[]} */
   onScripts,
-  allowCmd,
   /**
    * Without `force` handlers will be added only when userscripts are about to be injected.
    * @param {Object.<string, MessageFromGuestHandler>} obj
@@ -71,25 +39,9 @@ const bridge = {
   addBackgroundHandlers(obj, force) {
     assignHandlers(bgHandlers, obj, force);
   },
-  /**
-   * @param {VMInjection.Script} script
-   */
-  allowScript({ dataKey, meta }) {
-    allowCmd('Run', dataKey);
-    dataKeyNameMap[dataKey] = meta.name;
-    meta.grant::forEach(grant => {
-      const cmds = GRANT_CMD[grant]
-        || /^GM[._]/::regexpTest(grant) && GM_CMD[grant::slice(3)];
-      if (cmds) cmds::forEach(cmd => allowCmd(cmd, dataKey));
-    });
-  },
   // realm is provided when called directly via invokeHost
-  async onHandle({ cmd, data, dataKey, node }, realm) {
+  async onHandle({ cmd, data, node }, realm) {
     const handle = handlers[cmd];
-    if (!handle || !allowed[cmd]?.[dataKey]) {
-      // TODO: maybe remove this check if our VAULT is reliable
-      log('info', null, `Invalid command: "${cmd}" from "${dataKeyNameMap[dataKey] || '?'}"`);
-    }
     let callbackId = data && getOwnProp(data, CALLBACK_ID);
     if (callbackId) {
       data = data.data;

+ 0 - 4
src/injected/content/index.js

@@ -32,20 +32,16 @@ async function init() {
       ? await getDataFF(dataPromise)
       : await dataPromise
   );
-  const { allowCmd } = bridge;
   pickIntoNullObj(bridge, data, [
     'ids',
     'injectInto',
   ]);
   if (data.expose && !isXml && injectPageSandbox(contentId, webId)) {
-    allowCmd('GetScriptVer', contentId);
     bridge.addHandlers({ GetScriptVer: true }, true);
     bridge.post('Expose');
   }
   if (data.scripts) {
     bridge.onScripts.forEach(fn => fn(data));
-    allowCmd('NextTask', contentId);
-    if (IS_FIREFOX) allowCmd('InjectList', contentId);
     await injectScripts(contentId, webId, data, isXml);
   }
   bridge.onScripts = null;

+ 15 - 3
src/injected/content/inject.js

@@ -69,7 +69,7 @@ export function injectPageSandbox(contentId, webId) {
      * is it can be emptied by the opener page, too. */
     inject({ code: `parent["${vaultId}"] = [this]` }, ok => {
       // Skipping page injection in FF if our script element was blocked by site's CSP
-      if (ok && (!IS_FIREFOX || window.wrappedJSObject[vaultId])) {
+      if (ok && (!IS_FIREFOX || (ok = window.wrappedJSObject[vaultId]) && addVaultExports(ok))) {
         startHandshake();
       }
     });
@@ -164,7 +164,6 @@ export async function injectScripts(contentId, webId, data, isXml) {
       realmData.lists[runAt].push(script); // 'start' or 'body' per getScriptsByURL()
       realmData.is = true;
       if (pathMap) bridge.pathMaps[id] = pathMap;
-      bridge.allowScript(script);
     } else {
       bridge.failedIds.push(id);
       bridge.ids.push(id);
@@ -234,7 +233,6 @@ async function injectDelayedScripts(contentId, webId, { cache, scripts }) {
   if (needsInvoker && contentId) {
     setupContentInvoker(contentId, webId);
   }
-  scripts::forEach(bridge.allowScript);
   injectAll('end');
   injectAll('idle');
 }
@@ -348,3 +346,17 @@ function tellBridgeToWriteVault(vaultId, wnd) {
     return true;
   }
 }
+
+function addVaultExports(vaultSrc) {
+  const exports = cloneInto(createNullObj(), document);
+  // In FF a detached iframe's `console` doesn't print anything, we'll export it from content
+  const exportedConsole = cloneInto(createNullObj(), document);
+  ['log', 'info', 'warn', 'error', 'debug']::forEach(k => {
+    exportedConsole[k] = exportFunction(logging[k], document);
+    /* global exportFunction */
+  });
+  exports.console = exportedConsole;
+  // vaultSrc[0] is the iframe's `this`
+  vaultSrc[1] = exports;
+  return true;
+}

+ 1 - 0
src/injected/web/bridge.js

@@ -7,6 +7,7 @@ const callbacks = {
 };
 /**
  * @property {VMScriptGMInfoPlatform} ua
+ * @property {VMBridgePostFunc} post
  */
 const bridge = {
   __proto__: null,

+ 5 - 1
src/injected/web/safe-globals-web.js

@@ -88,6 +88,7 @@ export const VAULT = (() => {
   let i = -1;
   let call;
   let res;
+  let srcFF;
   let src = global; // FF defines some stuff only on `global` in content mode
   let srcWindow = window;
   if (process.env.VAULT_ID) {
@@ -97,9 +98,12 @@ export const VAULT = (() => {
   if (!res) {
     res = createNullObj();
   } else if (!isFunction(res[0])) {
+    // res is [this, addVaultExports object]
     // injectPageSandbox iframe's `global` is `window` because it's in page mode
     src = res[0];
     srcWindow = src;
+    // In FF some stuff from a detached iframe doesn't work, so we export it from content
+    srcFF = IS_FIREFOX && res[1];
     res = createNullObj();
   }
   res = [
@@ -151,7 +155,7 @@ export const VAULT = (() => {
      * by the page if it gains access to any Object from the vault e.g. a thrown SafeError. */
     jsonParse = res[i += 1] || src.JSON.parse,
     jsonStringify = res[i += 1] || src.JSON.stringify,
-    logging = res[i += 1] || createNullObj(src.console),
+    logging = res[i += 1] || createNullObj((srcFF || src).console),
     mathRandom = res[i += 1] || src.Math.random,
     parseFromString = res[i += 1] || SafeDOMParser[PROTO].parseFromString,
     stopImmediatePropagation = res[i += 1] || src.Event[PROTO].stopImmediatePropagation,

+ 6 - 1
src/types.d.ts

@@ -100,7 +100,12 @@ declare namespace GMReq {
     }
   }
 }
-
+declare type VMBridgePostFunc = (
+  cmd: string,
+  data: PlainJSONValue,
+  context?: { dataKey: string },
+  node?: Node,
+) => void;
 //#endregion Generic
 //#region VM-specific
 

+ 16 - 1
test/background/tester.test.js

@@ -1,4 +1,4 @@
-import { testScript, testBlacklist, resetBlacklist } from '@/background/utils/tester';
+import { MatchTest, resetBlacklist, testScript, testBlacklist } from '@/background/utils/tester';
 import cache from '@/background/utils/cache';
 
 afterEach(cache.destroy);
@@ -419,6 +419,21 @@ describe('exclude-match', () => {
   });
 });
 
+describe('@match error reporting', () => {
+  test('should throw', () => {
+    for (const [rule, err] of [
+      ['://*/*', 'missing scheme'],
+      ['foo://*/*', 'unknown scheme'],
+      ['*//*/*', 'missing "://"'],
+      ['http:/*/', 'missing "://"'],
+      ['htp:*', 'unknown scheme, missing "://"'],
+      ['https://foo*', 'missing "/" for path'],
+    ]) {
+      expect(() => MatchTest.try(rule)).toThrow(`Bad pattern: ${err} in ${rule}`);
+    }
+  });
+});
+
 describe('custom', () => {
   test('should ignore original rules', () => {
     const script = buildScript({