From a64b030cf1e58a953289733d8dc11488bc4e3053 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20=28Netux=29=20Rodr=C3=ADguez?= Date: Fri, 8 Aug 2025 14:38:40 -0300 Subject: [PATCH 1/4] feat: support automatic granting of `GM.*` and `unsafeWindow` --- src/index.ts | 4 +-- src/util.ts | 69 ++++++++++++++++++++++++++++++++-------------------- 2 files changed, 45 insertions(+), 28 deletions(-) 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..5965382 100644 --- a/src/util.ts +++ b/src/util.ts @@ -2,46 +2,63 @@ 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[._][a-zA-Z0-9_]+/; -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) + ) { + 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; + } + } + } + + 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]); } } }, From 5658483c65c44a6b01a7591467072df4dc0fb07e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20=28Netux=29=20Rodr=C3=ADguez?= Date: Fri, 8 Aug 2025 14:39:16 -0300 Subject: [PATCH 2/4] test: implement tests for `collectGrants()` util Also, fix indentation in test file to align with the rest of the repository. --- test/util.test.ts | 82 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 68 insertions(+), 14 deletions(-) diff --git a/test/util.test.ts b/test/util.test.ts index 1559a53..00be38e 100644 --- a/test/util.test.ts +++ b/test/util.test.ts @@ -1,23 +1,77 @@ +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 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); + }); +}); From b88b2fa0db291662b6e4c7d611b2f5f26754345a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20=28Netux=29=20Rodr=C3=ADguez?= Date: Sat, 9 Aug 2025 00:42:10 -0300 Subject: [PATCH 3/4] fix: make grants regexp stricted, prevent `unsafeWindow` + other stuff from being matched --- src/util.ts | 2 +- test/util.test.ts | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/util.ts b/src/util.ts index 5965382..b687639 100644 --- a/src/util.ts +++ b/src/util.ts @@ -6,7 +6,7 @@ import type { MemberExpression } from 'estree'; const META_START = '// ==UserScript=='; const META_END = '// ==/UserScript=='; -const GRANTS_REGEXP = /^unsafeWindow|GM[._][a-zA-Z0-9_]+/; +const GRANTS_REGEXP = /^(unsafeWindow$|GM[._]\w+)/; export function collectGrants(ast: AstNode) { let scope = attachScopes(ast, 'scope'); diff --git a/test/util.test.ts b/test/util.test.ts index 00be38e..c98e11a 100644 --- a/test/util.test.ts +++ b/test/util.test.ts @@ -53,6 +53,13 @@ describe('collectGrants', () => { 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); From 4cf35c4ff529c9dc0adcfc7169b0c69095102117 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20=28Netux=29=20Rodr=C3=ADguez?= Date: Sat, 9 Aug 2025 00:43:51 -0300 Subject: [PATCH 4/4] refactor: move `getMemberExpressionFullNameRecursive()` to module top level --- src/util.ts | 46 +++++++++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/src/util.ts b/src/util.ts index b687639..8106fea 100644 --- a/src/util.ts +++ b/src/util.ts @@ -19,29 +19,6 @@ export function collectGrants(ast: AstNode) { node.type === 'MemberExpression' && isReference(node, parent) ) { - 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; - } - } - } - const fullName = getMemberExpressionFullNameRecursive(node); const match = GRANTS_REGEXP.exec(fullName); if (match) { @@ -69,6 +46,29 @@ export function collectGrants(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,