Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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);
},
/**
Expand Down
69 changes: 43 additions & 26 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
}
}
},
Expand All @@ -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<string>,
Expand Down
89 changes: 75 additions & 14 deletions test/util.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
it('should throw error on an empty input', () => {
expect(() => getMetadata('', new Set())).toThrow(Error);
});
});