diff --git a/src/index.ts b/src/index.ts index 3702827..759e852 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ import { readFile } from 'fs/promises'; import MagicString from 'magic-string'; import type { Plugin } from 'rollup'; -import { collectGmApi, getMetadata } from './util'; +import { collectGrants, getMetadata } from './util'; const suffix = '?userscript-metadata'; @@ -25,7 +25,7 @@ export default (transform?: (metadata: string) => string): Plugin => { }, transform(code, id) { const ast = this.parse(code); - const grantSetPerFile = collectGmApi(ast); + const grantSetPerFile = collectGrants(ast); grantMap.set(id, grantSetPerFile); }, /** diff --git a/src/util.ts b/src/util.ts index 00319eb..8106fea 100644 --- a/src/util.ts +++ b/src/util.ts @@ -2,46 +2,40 @@ import { AttachedScope, attachScopes } from '@rollup/pluginutils'; import { Node, walk } from 'estree-walker'; import isReference from 'is-reference'; import type { AstNode } from 'rollup'; +import type { MemberExpression } from 'estree'; -const gmAPIs = [ - 'GM_info', - 'GM_getValue', - 'GM_getValues', - 'GM_setValue', - 'GM_setValues', - 'GM_deleteValue', - 'GM_deleteValues', - 'GM_listValues', - 'GM_addValueChangeListener', - 'GM_removeValueChangeListener', - 'GM_getResourceText', - 'GM_getResourceURL', - 'GM_addElement', - 'GM_addStyle', - 'GM_openInTab', - 'GM_registerMenuCommand', - 'GM_unregisterMenuCommand', - 'GM_notification', - 'GM_setClipboard', - 'GM_xmlhttpRequest', - 'GM_download', -]; const META_START = '// ==UserScript=='; const META_END = '// ==/UserScript=='; +const GRANTS_REGEXP = /^(unsafeWindow$|GM[._]\w+)/; -export function collectGmApi(ast: AstNode) { +export function collectGrants(ast: AstNode) { let scope = attachScopes(ast, 'scope'); const grantSetPerFile = new Set(); walk(ast as Node, { enter(node: Node & { scope: AttachedScope }, parent) { if (node.scope) scope = node.scope; + + if ( + node.type === 'MemberExpression' && + isReference(node, parent) + ) { + const fullName = getMemberExpressionFullNameRecursive(node); + const match = GRANTS_REGEXP.exec(fullName); + if (match) { + grantSetPerFile.add(match[0]); + + this.skip(); + } + } + if ( node.type === 'Identifier' && isReference(node, parent) && !scope.contains(node.name) ) { - if (gmAPIs.includes(node.name)) { - grantSetPerFile.add(node.name); + const match = GRANTS_REGEXP.exec(node.name); + if (match) { + grantSetPerFile.add(match[0]); } } }, @@ -52,6 +46,29 @@ export function collectGmApi(ast: AstNode) { return grantSetPerFile; } +function getMemberExpressionFullNameRecursive(astNode: MemberExpression): string | null { + if (astNode.property.type !== 'Identifier') { + return null; + } + + switch (astNode.object.type) { + case 'MemberExpression': { + const nameSoFar = getMemberExpressionFullNameRecursive(astNode.object); + if (nameSoFar == null) { + return null; + } + + return `${nameSoFar}.${astNode.property.name}` + } + case 'Identifier': { + return `${astNode.object.name}.${astNode.property.name}`; + } + default: { + return null; + } + } +} + export function getMetadata( metaFileContent: string, additionalGrantList: Set, diff --git a/test/util.test.ts b/test/util.test.ts index 1559a53..c98e11a 100644 --- a/test/util.test.ts +++ b/test/util.test.ts @@ -1,23 +1,84 @@ +import { parse as parseCode } from '@babel/parser'; import type { AstNode } from 'rollup'; -import type { EmptyStatement } from 'estree'; import { - collectGmApi, + collectGrants, getMetadata } from '../src/util'; -describe('collectGmApi', () => { - const EMPTY_STATEMENT: EmptyStatement = { - type: 'EmptyStatement' - }; - - it('should return an empty set on an empty input', () => { - expect(collectGmApi(EMPTY_STATEMENT as AstNode).size).toBe(0); - }); +describe('collectGrants', () => { + const parseCodeAsEstreeAst = (code: string) => { + const file = parseCode(code, { plugins: ['estree'] }); + return file.program as AstNode; + }; + + it('should return an empty set on an empty input', () => { + const astNode = parseCodeAsEstreeAst(``); + const result = collectGrants(astNode); + + expect(result.size).toBe(0); + }); + + it('should return only GM_dummyApi', () => { + const astNode = parseCodeAsEstreeAst(`GM_dummyApi`); + const result = collectGrants(astNode); + + expect(result.size).toBe(1); + expect(result).toContain('GM_dummyApi'); + }); + + it('should ignore any scope-defined variables that look like GM APIs', () => { + const astNode = parseCodeAsEstreeAst(` + let GM_dummyApi; + GM_dummyApi; + `); + const result = collectGrants(astNode); + + expect(result.size).toBe(0); + }); + + it('should return only GM.dummyApi', () => { + const astNode = parseCodeAsEstreeAst(`GM.dummyApi`); + const result = collectGrants(astNode); + + expect(result.size).toBe(1); + expect(result).toContain('GM.dummyApi'); + }); + + it('should return unsafeWindow when presented with just unsafeWindow', () => { + const astNode = parseCodeAsEstreeAst(`unsafeWindow`); + const result = collectGrants(astNode); + + expect(result.size).toBe(1); + expect(result).toContain('unsafeWindow'); + }); + + it('should return nothing unsafeWindow when presented with unsafeWindowButNotReally', () => { + const astNode = parseCodeAsEstreeAst(`unsafeWindowButNotReally`); + const result = collectGrants(astNode); + + expect(result.size).toBe(0); + }); + + it('should return unsafeWindow even when a subfield is accessed', () => { + const astNode = parseCodeAsEstreeAst(`unsafeWindow.anotherThing`); + const result = collectGrants(astNode); + + expect(result.size).toBe(1); + expect(result).toContain('unsafeWindow'); + }); + + it('should return unsafeWindow even when a subfield is accessed with object notation', () => { + const astNode = parseCodeAsEstreeAst(`unsafeWindow["anotherThing"]`); + const result = collectGrants(astNode); + + expect(result.size).toBe(1); + expect(result).toContain('unsafeWindow'); + }); }); describe('getMetadata', () => { - it('should throw error on an empty input', () => { - expect(() => getMetadata('', new Set())).toThrow(Error); - }); -}); \ No newline at end of file + it('should throw error on an empty input', () => { + expect(() => getMetadata('', new Set())).toThrow(Error); + }); +});