Skip to content

Commit 2b92c90

Browse files
authored
Merge pull request #9 from netux/feature/support-autogrant-of-gm-dot-apis-and-unsafe-window
Add support for `GM.*` and `unsafeWindow` autogranting
2 parents 9699f79 + 4cf35c4 commit 2b92c90

File tree

3 files changed

+120
-42
lines changed

3 files changed

+120
-42
lines changed

src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { readFile } from 'fs/promises';
22
import MagicString from 'magic-string';
33
import type { Plugin } from 'rollup';
4-
import { collectGmApi, getMetadata } from './util';
4+
import { collectGrants, getMetadata } from './util';
55

66
const suffix = '?userscript-metadata';
77

@@ -25,7 +25,7 @@ export default (transform?: (metadata: string) => string): Plugin => {
2525
},
2626
transform(code, id) {
2727
const ast = this.parse(code);
28-
const grantSetPerFile = collectGmApi(ast);
28+
const grantSetPerFile = collectGrants(ast);
2929
grantMap.set(id, grantSetPerFile);
3030
},
3131
/**

src/util.ts

Lines changed: 43 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,46 +2,40 @@ import { AttachedScope, attachScopes } from '@rollup/pluginutils';
22
import { Node, walk } from 'estree-walker';
33
import isReference from 'is-reference';
44
import type { AstNode } from 'rollup';
5+
import type { MemberExpression } from 'estree';
56

6-
const gmAPIs = [
7-
'GM_info',
8-
'GM_getValue',
9-
'GM_getValues',
10-
'GM_setValue',
11-
'GM_setValues',
12-
'GM_deleteValue',
13-
'GM_deleteValues',
14-
'GM_listValues',
15-
'GM_addValueChangeListener',
16-
'GM_removeValueChangeListener',
17-
'GM_getResourceText',
18-
'GM_getResourceURL',
19-
'GM_addElement',
20-
'GM_addStyle',
21-
'GM_openInTab',
22-
'GM_registerMenuCommand',
23-
'GM_unregisterMenuCommand',
24-
'GM_notification',
25-
'GM_setClipboard',
26-
'GM_xmlhttpRequest',
27-
'GM_download',
28-
];
297
const META_START = '// ==UserScript==';
308
const META_END = '// ==/UserScript==';
9+
const GRANTS_REGEXP = /^(unsafeWindow$|GM[._]\w+)/;
3110

32-
export function collectGmApi(ast: AstNode) {
11+
export function collectGrants(ast: AstNode) {
3312
let scope = attachScopes(ast, 'scope');
3413
const grantSetPerFile = new Set();
3514
walk(ast as Node, {
3615
enter(node: Node & { scope: AttachedScope }, parent) {
3716
if (node.scope) scope = node.scope;
17+
18+
if (
19+
node.type === 'MemberExpression' &&
20+
isReference(node, parent)
21+
) {
22+
const fullName = getMemberExpressionFullNameRecursive(node);
23+
const match = GRANTS_REGEXP.exec(fullName);
24+
if (match) {
25+
grantSetPerFile.add(match[0]);
26+
27+
this.skip();
28+
}
29+
}
30+
3831
if (
3932
node.type === 'Identifier' &&
4033
isReference(node, parent) &&
4134
!scope.contains(node.name)
4235
) {
43-
if (gmAPIs.includes(node.name)) {
44-
grantSetPerFile.add(node.name);
36+
const match = GRANTS_REGEXP.exec(node.name);
37+
if (match) {
38+
grantSetPerFile.add(match[0]);
4539
}
4640
}
4741
},
@@ -52,6 +46,29 @@ export function collectGmApi(ast: AstNode) {
5246
return grantSetPerFile;
5347
}
5448

49+
function getMemberExpressionFullNameRecursive(astNode: MemberExpression): string | null {
50+
if (astNode.property.type !== 'Identifier') {
51+
return null;
52+
}
53+
54+
switch (astNode.object.type) {
55+
case 'MemberExpression': {
56+
const nameSoFar = getMemberExpressionFullNameRecursive(astNode.object);
57+
if (nameSoFar == null) {
58+
return null;
59+
}
60+
61+
return `${nameSoFar}.${astNode.property.name}`
62+
}
63+
case 'Identifier': {
64+
return `${astNode.object.name}.${astNode.property.name}`;
65+
}
66+
default: {
67+
return null;
68+
}
69+
}
70+
}
71+
5572
export function getMetadata(
5673
metaFileContent: string,
5774
additionalGrantList: Set<string>,

test/util.test.ts

Lines changed: 75 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,84 @@
1+
import { parse as parseCode } from '@babel/parser';
12
import type { AstNode } from 'rollup';
2-
import type { EmptyStatement } from 'estree';
33

44
import {
5-
collectGmApi,
5+
collectGrants,
66
getMetadata
77
} from '../src/util';
88

9-
describe('collectGmApi', () => {
10-
const EMPTY_STATEMENT: EmptyStatement = {
11-
type: 'EmptyStatement'
12-
};
13-
14-
it('should return an empty set on an empty input', () => {
15-
expect(collectGmApi(EMPTY_STATEMENT as AstNode).size).toBe(0);
16-
});
9+
describe('collectGrants', () => {
10+
const parseCodeAsEstreeAst = (code: string) => {
11+
const file = parseCode(code, { plugins: ['estree'] });
12+
return file.program as AstNode;
13+
};
14+
15+
it('should return an empty set on an empty input', () => {
16+
const astNode = parseCodeAsEstreeAst(``);
17+
const result = collectGrants(astNode);
18+
19+
expect(result.size).toBe(0);
20+
});
21+
22+
it('should return only GM_dummyApi', () => {
23+
const astNode = parseCodeAsEstreeAst(`GM_dummyApi`);
24+
const result = collectGrants(astNode);
25+
26+
expect(result.size).toBe(1);
27+
expect(result).toContain('GM_dummyApi');
28+
});
29+
30+
it('should ignore any scope-defined variables that look like GM APIs', () => {
31+
const astNode = parseCodeAsEstreeAst(`
32+
let GM_dummyApi;
33+
GM_dummyApi;
34+
`);
35+
const result = collectGrants(astNode);
36+
37+
expect(result.size).toBe(0);
38+
});
39+
40+
it('should return only GM.dummyApi', () => {
41+
const astNode = parseCodeAsEstreeAst(`GM.dummyApi`);
42+
const result = collectGrants(astNode);
43+
44+
expect(result.size).toBe(1);
45+
expect(result).toContain('GM.dummyApi');
46+
});
47+
48+
it('should return unsafeWindow when presented with just unsafeWindow', () => {
49+
const astNode = parseCodeAsEstreeAst(`unsafeWindow`);
50+
const result = collectGrants(astNode);
51+
52+
expect(result.size).toBe(1);
53+
expect(result).toContain('unsafeWindow');
54+
});
55+
56+
it('should return nothing unsafeWindow when presented with unsafeWindowButNotReally', () => {
57+
const astNode = parseCodeAsEstreeAst(`unsafeWindowButNotReally`);
58+
const result = collectGrants(astNode);
59+
60+
expect(result.size).toBe(0);
61+
});
62+
63+
it('should return unsafeWindow even when a subfield is accessed', () => {
64+
const astNode = parseCodeAsEstreeAst(`unsafeWindow.anotherThing`);
65+
const result = collectGrants(astNode);
66+
67+
expect(result.size).toBe(1);
68+
expect(result).toContain('unsafeWindow');
69+
});
70+
71+
it('should return unsafeWindow even when a subfield is accessed with object notation', () => {
72+
const astNode = parseCodeAsEstreeAst(`unsafeWindow["anotherThing"]`);
73+
const result = collectGrants(astNode);
74+
75+
expect(result.size).toBe(1);
76+
expect(result).toContain('unsafeWindow');
77+
});
1778
});
1879

1980
describe('getMetadata', () => {
20-
it('should throw error on an empty input', () => {
21-
expect(() => getMetadata('', new Set())).toThrow(Error);
22-
});
23-
});
81+
it('should throw error on an empty input', () => {
82+
expect(() => getMetadata('', new Set())).toThrow(Error);
83+
});
84+
});

0 commit comments

Comments
 (0)