Skip to content
This repository was archived by the owner on Aug 7, 2024. It is now read-only.

Commit 0c07148

Browse files
authored
feat: validate imports (#1838)
* feat: validate imports * disable codecov comments
1 parent e09cf2d commit 0c07148

File tree

4 files changed

+279
-11
lines changed

4 files changed

+279
-11
lines changed

.codecov.yml

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,4 @@ ignore:
2020
- "test_utils/*"
2121
- "setup.py"
2222

23-
comment:
24-
layout: "reach, diff, flags, files"
25-
behavior: new
26-
require_changes: false
27-
require_base: no
28-
require_head: yes
29-
branches:
30-
- "!master"
23+
comment: false
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/* eslint-env jest */
2+
import { matchFromImports, matchImports } from './syntax';
3+
jest.mock('threads/worker')
4+
5+
6+
describe('validate imports', () => {
7+
it('match imports', () => {
8+
const imports = matchImports(`
9+
import a
10+
import b, c
11+
12+
def import_d():
13+
import d
14+
15+
def import_e(): import e
16+
17+
import f.f2
18+
`);
19+
20+
return expect(imports).toEqual(new Set([
21+
'a',
22+
'b',
23+
'c',
24+
'd',
25+
'e',
26+
'f.f2'
27+
]));
28+
});
29+
30+
it('match imports with comments', () => {
31+
const imports = matchImports(`
32+
import a # some comment
33+
import b #
34+
import c#touching
35+
import d # some # comment
36+
import e, f #
37+
`);
38+
39+
return expect(imports).toEqual(new Set([
40+
'a',
41+
'b',
42+
'c',
43+
'd',
44+
'e',
45+
'f'
46+
]));
47+
});
48+
49+
it('match imports with irregular spacing', () => {
50+
const imports = matchImports(`
51+
import a
52+
import b, c
53+
import d , e
54+
import f,g
55+
`);
56+
57+
return expect(imports).toEqual(new Set([
58+
'a',
59+
'b',
60+
'c',
61+
'd',
62+
'e',
63+
'f',
64+
'g'
65+
]));
66+
});
67+
68+
it('match imports with invalid syntax', () => {
69+
const imports = matchImports(`
70+
import a,
71+
import b,,c
72+
import d.
73+
`);
74+
75+
return expect(imports).toEqual(new Set());
76+
});
77+
78+
it('match from-imports', () => {
79+
const fromImports = matchFromImports(`
80+
from a import ( # after import
81+
b, # after b
82+
c, # after b
83+
) # after end
84+
85+
from d.d1 import (
86+
e,
87+
f
88+
)
89+
90+
from g import (
91+
h,,
92+
i
93+
)
94+
95+
def foo(): from j import (
96+
k,
97+
l
98+
)
99+
100+
from m import (n, o)
101+
from p import q, r # some comment
102+
`);
103+
104+
return expect(fromImports).toEqual({
105+
'a': new Set(['b', 'c']),
106+
'd.d1': new Set(['e', 'f']),
107+
'j': new Set(['k', 'l']),
108+
'm': new Set(['n', 'o']),
109+
'p': new Set(['q', 'r']),
110+
});
111+
});
112+
});

game_frontend/src/pyodide/syntax.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
const namePattern = '[{base}][{base}0-9]*'.replace(/{base}/g, '_a-zA-Z');
2+
const modulePattern = '{name}(?:\\.{name})*'.replace(/{name}/g, namePattern);
3+
4+
export function funcPattern({
5+
lineStart,
6+
captureName,
7+
captureArgs
8+
}: {
9+
lineStart: boolean
10+
captureName: boolean
11+
captureArgs: boolean
12+
}) {
13+
let pattern = ' *def +{name} *\\({args}\\) *:';
14+
15+
if (lineStart) pattern = '^' + pattern;
16+
17+
// TODO: refine
18+
const argsPattern = '.*';
19+
20+
pattern = pattern.replace(
21+
/{name}/g,
22+
captureName ? `(${namePattern})` : namePattern
23+
);
24+
pattern = pattern.replace(
25+
/{args}/g,
26+
captureArgs ? `(${argsPattern})` : argsPattern
27+
);
28+
29+
return pattern;
30+
}
31+
32+
function splitImports(imports: string) {
33+
return new Set(imports.split(',').map((_import) => _import.trim()));
34+
}
35+
36+
export function matchImports(code: string) {
37+
const pattern = new RegExp(
38+
[
39+
'^',
40+
'(?:{func})?'.replace(
41+
/{func}/g,
42+
funcPattern({
43+
lineStart: false,
44+
captureName: false,
45+
captureArgs: false
46+
})
47+
),
48+
' *import +({module}(?: *, *{module})*)'.replace(
49+
/{module}/g,
50+
modulePattern
51+
),
52+
' *(?:#.*)?',
53+
'$'
54+
].join(''),
55+
'gm'
56+
);
57+
58+
const imports: Set<string> = new Set();
59+
for (const match of code.matchAll(pattern)) {
60+
splitImports(match[1]).forEach((_import) => { imports.add(_import); });
61+
}
62+
63+
return imports;
64+
}
65+
66+
export function matchFromImports(code: string) {
67+
const pattern = new RegExp(
68+
[
69+
'^',
70+
'(?:{func})?'.replace(
71+
/{func}/g,
72+
funcPattern({
73+
lineStart: false,
74+
captureName: false,
75+
captureArgs: false
76+
})
77+
),
78+
' *from +({module}) +import'.replace(
79+
/{module}/g,
80+
modulePattern
81+
),
82+
'(?: *\\(([^)]+)\\)| +({name}(?: *, *{name})*))'.replace(
83+
/{name}/g,
84+
namePattern
85+
),
86+
' *(?:#.*)?',
87+
'$'
88+
].join(''),
89+
'gm'
90+
);
91+
92+
const fromImports: Record<string, Set<string>> = {};
93+
for (const match of code.matchAll(pattern)) {
94+
let imports: Set<string>;
95+
if (match[3] === undefined) {
96+
// Get imports as string and remove comments.
97+
let importsString = match[2].replace(
98+
/#.*(\r|\n|\r\n|$)/g,
99+
''
100+
);
101+
102+
// If imports have a trailing comma, remove it.
103+
importsString = importsString.trim();
104+
if (importsString.endsWith(',')) {
105+
importsString = importsString.slice(0, -1);
106+
}
107+
108+
// Split imports by comma.
109+
imports = splitImports(importsString);
110+
111+
// If any imports are invalid, don't save them.
112+
const importPattern = new RegExp(`^${namePattern}$`, 'gm')
113+
if (imports.has('') ||
114+
[...imports].every((_import) => importPattern.test(_import))
115+
) {
116+
continue;
117+
}
118+
} else {
119+
imports = splitImports(match[3]);
120+
}
121+
122+
fromImports[match[1]] = imports;
123+
}
124+
125+
return fromImports;
126+
}

game_frontend/src/pyodide/webWorker.ts

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
/* eslint-env worker */
2-
import { expose } from 'threads/worker'
3-
import { checkIfBadgeEarned, filterByWorksheet } from './badges'
4-
import ComputedTurnResult from './computedTurnResult'
2+
import { expose } from 'threads/worker';
3+
import { checkIfBadgeEarned, filterByWorksheet } from './badges';
4+
import ComputedTurnResult from './computedTurnResult';
5+
import { matchFromImports, matchImports } from './syntax';
56

67
let pyodide: Pyodide
78

@@ -98,6 +99,31 @@ export function simplifyErrorMessageInLog(log: string): string {
9899
return log.split('\n').slice(-2).join('\n')
99100
}
100101

102+
const IMPORT_WHITE_LIST: Array<{
103+
name: string
104+
allowAnySubmodule: boolean
105+
from?: Set<string>
106+
}> = [
107+
{
108+
name: 'random',
109+
allowAnySubmodule: true,
110+
}
111+
];
112+
113+
function validateImportInWhiteList(_import: string, turnCount: number) {
114+
if (IMPORT_WHITE_LIST.every(({ name, allowAnySubmodule }) =>
115+
_import !== name || (_import.startsWith(name) && !allowAnySubmodule)
116+
)) {
117+
return Promise.resolve({
118+
action: { action_type: 'wait' },
119+
log: `Import "${_import}" is not allowed.`,
120+
turnCount: turnCount,
121+
})
122+
}
123+
124+
return undefined;
125+
}
126+
101127
export async function updateAvatarCode(
102128
userCode: string,
103129
gameState: any,
@@ -109,6 +135,17 @@ export async function updateAvatarCode(
109135
}
110136

111137
try {
138+
for (const _import of matchImports(userCode)) {
139+
const promise = validateImportInWhiteList(_import, turnCount);
140+
if (promise) return promise;
141+
}
142+
143+
for (const _import in matchFromImports(userCode)) {
144+
const promise = validateImportInWhiteList(_import, turnCount);
145+
if (promise) return promise;
146+
// TODO: validate from imports
147+
}
148+
112149
await pyodide.runPythonAsync(userCode)
113150
if (gameState) {
114151
return computeNextAction(gameState, playerAvatarID, false)

0 commit comments

Comments
 (0)