瀏覽代碼

feat: allow malformed userscript metadata + warn

* to make authors aware of the problem
* to match the relaxed behavior of other engines like Tampermonkey
tophf 1 年之前
父節點
當前提交
2dd34c3f72

+ 1 - 0
src/background/utils/db.js

@@ -98,6 +98,7 @@ addOwnCommands({
     return normalizePosition();
     return normalizePosition();
   },
   },
   ParseMeta: parseMetaWithErrors,
   ParseMeta: parseMetaWithErrors,
+  ParseMetaErrors: data => parseMetaWithErrors(data).errors,
   ParseScript: parseScript,
   ParseScript: parseScript,
   /** @return {Promise<void>} */
   /** @return {Promise<void>} */
   UpdateScriptInfo({ id, config, custom }) {
   UpdateScriptInfo({ id, config, custom }) {

+ 2 - 2
src/background/utils/preinject.js

@@ -538,8 +538,8 @@ function prepareScript(script, env) {
     [META_STR]: [
     [META_STR]: [
       '',
       '',
       codeIndex,
       codeIndex,
-      tmp = (metaStrMatch.index + metaStrMatch[1].length),
-      tmp + metaStrMatch[2].length,
+      tmp = metaStrMatch && (metaStrMatch.index + metaStrMatch[1].length),
+      tmp + metaStrMatch?.[4].length,
     ],
     ],
     [RUN_AT]: runAt[id],
     [RUN_AT]: runAt[id],
   };
   };

+ 34 - 15
src/background/utils/script.js

@@ -2,8 +2,7 @@ import {
   encodeFilename, getFullUrl, getScriptHome, getScriptSupportUrl, noop,
   encodeFilename, getFullUrl, getScriptHome, getScriptSupportUrl, noop,
 } from '@/common';
 } from '@/common';
 import {
 import {
-  __CODE, TL_AWAIT, UNWRAP,
-  HOMEPAGE_URL, INFERRED, METABLOCK_RE, SUPPORT_URL, USERSCRIPT_META_INTRO,
+  __CODE, HOMEPAGE_URL, INFERRED, METABLOCK_RE, SUPPORT_URL, TL_AWAIT, UNWRAP,
 } from '@/common/consts';
 } from '@/common/consts';
 import { formatDate } from '@/common/date';
 import { formatDate } from '@/common/date';
 import { mapEntry } from '@/common/object';
 import { mapEntry } from '@/common/object';
@@ -23,11 +22,8 @@ addOwnCommands({
   },
   },
 });
 });
 
 
-export function isUserScript(text) {
-  if (/^\s*</.test(text)) return false; // HTML
-  if (text.indexOf(USERSCRIPT_META_INTRO) < 0) return false; // Lack of meta block
-  return true;
-}
+/** @return {boolean|?RegExpExecArray} */
+export const matchUserScript = text => !/^\s*</.test(text) /*HTML*/ && METABLOCK_RE.exec(text);
 
 
 const arrayType = {
 const arrayType = {
   default: () => [],
   default: () => [],
@@ -68,28 +64,51 @@ const metaOptionalTypes = {
   [TL_AWAIT]: booleanType,
   [TL_AWAIT]: booleanType,
   [UNWRAP]: booleanType,
   [UNWRAP]: booleanType,
 };
 };
-export function parseMeta(code, includeMatchedString) {
+/**                   0        1       2          3     4 */
+const META_ITEM_RE = /(?:^|\n)(.*)\/\/([\x20\t]*)(@\S+)(.*)/g;
+export const ERR_META_SPACE_BEFORE = 'Unexpected text before "//" in ';
+export const ERR_META_SPACE_INSIDE = 'Expected a single space after "//" in ';
+
+export function parseMeta(code, includeMatchedString, errors) {
   // initialize meta
   // initialize meta
   const meta = metaTypes::mapEntry(value => value.default());
   const meta = metaTypes::mapEntry(value => value.default());
-  const match = code.match(METABLOCK_RE);
-  const metaBody = match[2];
-  if (!metaBody) return false; // TODO: `return;` + null check in all callers?
-  metaBody.replace(/(?:^|\n)\s*\/\/\x20(@\S+)(.*)/g, (_match, rawKey, rawValue) => {
-    const [keyName, locale] = rawKey.slice(1).split(':');
+  const match = matchUserScript(code);
+  if (!match) return false; // TODO: `return;` + null check in all callers?
+  if (errors) checkMetaItemErrors(match, 1, errors);
+  let parts;
+  while ((parts = META_ITEM_RE.exec(match[4]))) {
+    const [keyName, locale] = parts[3].slice(1).split(':');
     const camelKey = keyName.replace(/[-_](\w)/g, (m, g) => g.toUpperCase());
     const camelKey = keyName.replace(/[-_](\w)/g, (m, g) => g.toUpperCase());
     const key = locale ? `${camelKey}:${locale.toLowerCase()}` : camelKey;
     const key = locale ? `${camelKey}:${locale.toLowerCase()}` : camelKey;
-    const val = rawValue.trim();
+    const val = parts[4].trim();
     const metaType = metaTypes[key] || metaOptionalTypes[key] || defaultType;
     const metaType = metaTypes[key] || metaOptionalTypes[key] || defaultType;
     let oldValue = meta[key];
     let oldValue = meta[key];
     if (typeof oldValue === 'undefined') oldValue = metaType.default();
     if (typeof oldValue === 'undefined') oldValue = metaType.default();
+    if (errors) checkMetaItemErrors(parts, 0, errors);
     meta[key] = metaType.transform(oldValue, val);
     meta[key] = metaType.transform(oldValue, val);
-  });
+  }
+  if (errors) checkMetaItemErrors(match, 5, errors);
   meta.resources = meta.resource;
   meta.resources = meta.resource;
   delete meta.resource;
   delete meta.resource;
   if (includeMatchedString) meta[__CODE] = match[0];
   if (includeMatchedString) meta[__CODE] = match[0];
   return meta;
   return meta;
 }
 }
 
 
+function checkMetaItemErrors(parts, index, errors) {
+  let clipped;
+  if (parts[index + 1].match(/\S/)) {
+    errors.push(ERR_META_SPACE_BEFORE + (clipped = clipString(parts[index], 50)));
+  }
+  if (parts[index + 2] !== ' ') {
+    errors.push(ERR_META_SPACE_INSIDE + (clipped || clipString(parts[index], 50)));
+  }
+}
+
+function clipString(line, maxLen) {
+  line = line.trim();
+  return JSON.stringify(line.length > maxLen ? line.slice(0, maxLen) + '...' : line);
+}
+
 export function getDefaultCustom() {
 export function getDefaultCustom() {
   return {
   return {
     origInclude: true,
     origInclude: true,

+ 5 - 5
src/background/utils/tab-redirector.js

@@ -2,7 +2,7 @@ import { browserWindows, request, noop, i18n, getUniqId } from '@/common';
 import cache from './cache';
 import cache from './cache';
 import { addPublicCommands, commands } from './init';
 import { addPublicCommands, commands } from './init';
 import { getOption } from './options';
 import { getOption } from './options';
-import { parseMeta, isUserScript } from './script';
+import { parseMeta, matchUserScript } from './script';
 import { fileSchemeRequestable, getTabUrl, NEWTAB_URL_RE, tabsOnUpdated } from './tabs';
 import { fileSchemeRequestable, getTabUrl, NEWTAB_URL_RE, tabsOnUpdated } from './tabs';
 import { FIREFOX } from './ua';
 import { FIREFOX } from './ua';
 
 
@@ -14,11 +14,11 @@ addPublicCommands({
       && await browser.tabs.get(tabId).catch(noop);
       && await browser.tabs.get(tabId).catch(noop);
     return tab && getTabUrl(tab).startsWith(CONFIRM_URL_BASE);
     return tab && getTabUrl(tab).startsWith(CONFIRM_URL_BASE);
   },
   },
-  async ConfirmInstall({ code, from, url, fs }, { tab = {} }) {
+  async ConfirmInstall({ code, from, url, fs, parsed }, { tab = {} }) {
     if (!fs) {
     if (!fs) {
       if (!code) code = (await request(url)).data;
       if (!code) code = (await request(url)).data;
       // TODO: display the error in UI
       // TODO: display the error in UI
-      if (!isUserScript(code)) {
+      if (!parsed && !matchUserScript(code)) {
         throw `${i18n('msgInvalidScript')}\n\n${
         throw `${i18n('msgInvalidScript')}\n\n${
           code.trim().split(/[\r\n]+\s*/, 9/*max lines*/).join('\n')
           code.trim().split(/[\r\n]+\s*/, 9/*max lines*/).join('\n')
             .slice(0, 500/*max overall length*/)
             .slice(0, 500/*max overall length*/)
@@ -77,12 +77,12 @@ async function maybeInstallUserJs(tabId, url) {
   const tab = tabId >= 0 && await browser.tabs.get(tabId) || {};
   const tab = tabId >= 0 && await browser.tabs.get(tabId) || {};
   const { data: code } = await request(url).catch(noop) || {};
   const { data: code } = await request(url).catch(noop) || {};
   if (code && parseMeta(code).name) {
   if (code && parseMeta(code).name) {
-    commands.ConfirmInstall({ code, url, from: tab.url }, { tab });
+    commands.ConfirmInstall({ code, url, from: tab.url, parsed: true }, { tab });
   } else {
   } else {
     cache.put(`bypass:${url}`, true, 10e3);
     cache.put(`bypass:${url}`, true, 10e3);
     const error = `${VIOLENTMONKEY} installer skipped ${url}.
     const error = `${VIOLENTMONKEY} installer skipped ${url}.
 Either not a userscript or the metablock comment is malformed:
 Either not a userscript or the metablock comment is malformed:
-${code.length > 10e3 ? code.slice(0, 10e3) + '...' : code}`;
+${code.length > 1e6 ? code.slice(0, 1e6) + '...' : code}`;
     if (tabId < 0) {
     if (tabId < 0) {
       console.warn(error);
       console.warn(error);
     } else {
     } else {

+ 2 - 2
src/background/utils/tester.js

@@ -1,6 +1,6 @@
 /* eslint-disable max-classes-per-file */
 /* eslint-disable max-classes-per-file */
 import { escapeStringForRegExp, getScriptPrettyUrl } from '@/common';
 import { escapeStringForRegExp, getScriptPrettyUrl } from '@/common';
-import { BLACKLIST, BLACKLIST_NET, ERRORS } from '@/common/consts';
+import { ERR_BAD_PATTERN, BLACKLIST, BLACKLIST_NET, ERRORS } from '@/common/consts';
 import initCache from '@/common/cache';
 import initCache from '@/common/cache';
 import { getPublicSuffix } from '@/common/tld';
 import { getPublicSuffix } from '@/common/tld';
 import { hookOptionsInit } from './options';
 import { hookOptionsInit } from './options';
@@ -104,7 +104,7 @@ export class MatchTest {
       + (parts[4] !== '://' ? 'missing "://", ' : '')
       + (parts[4] !== '://' ? 'missing "://", ' : '')
       || (parts[6] == null ? 'missing "/" for path, ' : '')
       || (parts[6] == null ? 'missing "/" for path, ' : '')
     ).slice(0, -2) + ' in ';
     ).slice(0, -2) + ' in ';
-    throw `Bad pattern: ${parts}${rule}`;
+    throw `${ERR_BAD_PATTERN} ${parts}${rule}`;
   }
   }
 }
 }
 
 

+ 16 - 6
src/common/consts.js

@@ -6,12 +6,21 @@ export const INFERRED = 'inferred';
 export const HOMEPAGE_URL = 'homepageURL';
 export const HOMEPAGE_URL = 'homepageURL';
 export const SUPPORT_URL = 'supportURL';
 export const SUPPORT_URL = 'supportURL';
 
 
-// Allow metadata lines to start with WHITESPACE? '//' SPACE
-// Allow anything to follow the predefined text of the metaStart/End
-// The SPACE must be on the same line and specifically \x20 as \s would also match \r\n\t
-// Note: when there's no valid metablock, an empty string is matched for convenience
-export const USERSCRIPT_META_INTRO = '// ==UserScript==';
-export const METABLOCK_RE = /((?:^|\n)\s*\/\/\x20==UserScript==)([\s\S]*?\n)\s*\/\/\x20==\/UserScript==|$/;
+/** A relaxed check, see METABLOCK_RE description */
+export const USERSCRIPT_META_INTRO = '==UserScript==';
+/** A strictly valid metablock should start at the beginning of the line:
+ * "// ==UserScript==" with exactly one \x20 space inside.
+ * To match Tampermonkey's relaxed parsing, we allow any preceding text at line start
+ * (i.e. not just spaces for indented metablock comments, but literally anything)
+ * and inside, but we'll warn about this later in the installer/editor. */
+export const METABLOCK_RE = re`/
+# 1          2           3
+  ((?:^|\n)(.*?)\/\/([\x20\t]*)==UserScript==)
+# 4
+  ([\s\S]*?\n)
+# 5  6          7
+  ((.*?)\/\/([\x20\t]*)==\/UserScript==)
+/x`;
 export const META_STR = 'metaStr';
 export const META_STR = 'metaStr';
 export const NEWLINE_END_RE = /\n((?!\n)\s)*$/;
 export const NEWLINE_END_RE = /\n((?!\n)\s)*$/;
 export const WATCH_STORAGE = 'watchStorage';
 export const WATCH_STORAGE = 'watchStorage';
@@ -41,5 +50,6 @@ export const UA_PROPS = ['userAgent', 'brands', 'mobile', 'platform'];
 export const TL_AWAIT = 'topLevelAwait';
 export const TL_AWAIT = 'topLevelAwait';
 export const UNWRAP = 'unwrap';
 export const UNWRAP = 'unwrap';
 export const FETCH_OPTS = 'fetchOpts';
 export const FETCH_OPTS = 'fetchOpts';
+export const ERR_BAD_PATTERN = 'Bad pattern:';
 export const VM_HOME = 'https://violentmonkey.github.io/';
 export const VM_HOME = 'https://violentmonkey.github.io/';
 export const VM_DOCS_MATCHING = VM_HOME + 'api/matching/';
 export const VM_DOCS_MATCHING = VM_HOME + 'api/matching/';

+ 20 - 6
src/options/views/edit/index.vue

@@ -81,9 +81,11 @@
         <code v-text="hashPattern"/>
         <code v-text="hashPattern"/>
       </locale-group>
       </locale-group>
       <p v-for="e in errors" :key="e" v-text="e" class="text-red"/>
       <p v-for="e in errors" :key="e" v-text="e" class="text-red"/>
-      <p class="my-1" v-if="errors">
-        <a :href="VM_DOCS_MATCHING" v-bind="EXTERNAL_LINK_PROPS" v-text="VM_DOCS_MATCHING"/>
-      </p>
+      <template v-if="errors">
+        <p class="my-1" v-for="url in errorsLinks" :key="url">
+          <a :href="url" v-bind="EXTERNAL_LINK_PROPS" v-text="url"/>
+        </p>
+      </template>
     </div>
     </div>
   </div>
   </div>
 </template>
 </template>
@@ -94,7 +96,7 @@ import {
   debounce, formatByteLength, getScriptName, getScriptUpdateUrl, i18n, isEmpty,
   debounce, formatByteLength, getScriptName, getScriptUpdateUrl, i18n, isEmpty,
   nullBool2string, sendCmdDirectly, trueJoin,
   nullBool2string, sendCmdDirectly, trueJoin,
 } from '@/common';
 } from '@/common';
-import { VM_DOCS_MATCHING } from '@/common/consts';
+import { ERR_BAD_PATTERN, VM_DOCS_MATCHING, VM_HOME } from '@/common/consts';
 import { deepCopy, deepEqual, objectPick } from '@/common/object';
 import { deepCopy, deepEqual, objectPick } from '@/common/object';
 import { externalEditorInfoUrl, focusMe, getActiveElement, showMessage } from '@/common/ui';
 import { externalEditorInfoUrl, focusMe, getActiveElement, showMessage } from '@/common/ui';
 import { keyboardService } from '@/common/keyboard';
 import { keyboardService } from '@/common/keyboard';
@@ -191,6 +193,15 @@ const commands = {
 };
 };
 const hotkeys = ref();
 const hotkeys = ref();
 const errors = ref();
 const errors = ref();
+const errorsLinks = computed(() => {
+  let patterns = 0;
+  const errorsValue = errors.value;
+  for (const e of errorsValue) if (e.startsWith(ERR_BAD_PATTERN)) patterns++;
+  return [
+    patterns < errorsValue.length && `${VM_HOME}api/metadata-block/`,
+    patterns && VM_DOCS_MATCHING,
+  ].filter(Boolean);
+});
 const hashPattern = computed(() => { // eslint-disable-line vue/return-in-computed-property
 const hashPattern = computed(() => { // eslint-disable-line vue/return-in-computed-property
   for (const sectionKey of ['meta', 'custom']) {
   for (const sectionKey of ['meta', 'custom']) {
     for (const key of CUSTOM_LISTS) {
     for (const key of CUSTOM_LISTS) {
@@ -240,9 +251,12 @@ watch(script, onScript);
 
 
 {
 {
   // The eslint rule is bugged as this is a block scope, not a global scope.
   // The eslint rule is bugged as this is a block scope, not a global scope.
-  const src = props.initial; // eslint-disable-line vue/no-setup-props-destructure
-  code.value = props.initialCode; // eslint-disable-line vue/no-setup-props-destructure
+  const src = props.initial;
+  const initialCode = code.value = props.initialCode;
   script.value = deepCopy(src);
   script.value = deepCopy(src);
+  sendCmdDirectly('ParseMetaErrors', initialCode).then(res => {
+    errors.value = res;
+  });
   watch(() => script.value.config, onChange, { deep: true });
   watch(() => script.value.config, onChange, { deep: true });
   watch(() => script.value.custom, onChange, { deep: true });
   watch(() => script.value.custom, onChange, { deep: true });
   watch(() => src.error, error => {
   watch(() => src.error, error => {

+ 22 - 9
test/background/script.test.js

@@ -1,4 +1,4 @@
-import { parseMeta } from '@/background/utils/script';
+import { ERR_META_SPACE_INSIDE, parseMeta } from '@/background/utils/script';
 
 
 const baseMeta = {
 const baseMeta = {
   include: [],
   include: [],
@@ -44,25 +44,38 @@ test('parseMeta', () => {
 });
 });
 
 
 test('parseMetaIrregularities', () => {
 test('parseMetaIrregularities', () => {
-  expect(parseMeta(`\
+  const baseMetaFoo = {
+    ...baseMeta,
+    name: 'foo',
+  };
+  const parseWeirdMeta = code => {
+    const errors = [];
+    const res = parseMeta(code, false, errors);
+    return errors.length ? [res, ...errors] : res;
+  };
+  expect(parseWeirdMeta(`\
   // ==UserScript==============
   // ==UserScript==============
 // @name foo
 // @name foo
  // @namespace bar
  // @namespace bar
 // ==/UserScript===================
 // ==/UserScript===================
   `)).toEqual({
   `)).toEqual({
-    ...baseMeta,
-    name: 'foo',
+    ...baseMetaFoo,
     namespace: 'bar',
     namespace: 'bar',
   });
   });
-  expect(parseMeta(`\
+  expect(parseWeirdMeta(`\
 // ==UserScript==
 // ==UserScript==
 //@name foo
 //@name foo
-// ==/UserScript==`)).toEqual(baseMeta);
-  expect(parseMeta(`\
+// ==/UserScript==`)).toEqual([baseMetaFoo,
+    ERR_META_SPACE_INSIDE + `"//@name foo"`,
+  ]);
+  expect(parseWeirdMeta(`\
 //==UserScript==
 //==UserScript==
 // @name foo
 // @name foo
-//\t==/UserScript==`)).toBeFalsy();
-  expect(parseMeta(`\
+//\t==/UserScript==`)).toEqual([baseMetaFoo,
+    ERR_META_SPACE_INSIDE + `"//==UserScript=="`,
+    ERR_META_SPACE_INSIDE + String.raw`"//\t==/UserScript=="`,
+  ]);
+  expect(parseWeirdMeta(`\
 /*
 /*
 //
 //
   ==UserScript==
   ==UserScript==