Skip to content

Commit 1cb974a

Browse files
committed
feat(evasive-transform): expose createEvasiveTransformPass for use with @endo/parser-pipeline
This exposes a new function, `createEvasiveTransformPass()`, which can be used with `@endo/parser-pipeline` to unify AST parsing. Refactored out some common code to maximize logic reuse. Both the new function and the existing `transformAst()` use the new `makeTransformCommentsVisitor()` to build the visitors for `@babel/traverse`.
1 parent 93e7e2c commit 1cb974a

9 files changed

Lines changed: 193 additions & 41 deletions

File tree

packages/evasive-transform/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"devDependencies": {
4141
"@babel/types": "~7.28.2",
4242
"@endo/ses-ava": "workspace:^",
43+
"@types/babel__generator": "~7.27.0",
4344
"ava": "catalog:dev",
4445
"c8": "catalog:dev",
4546
"eslint": "catalog:dev",
@@ -74,6 +75,7 @@
7475
"dependencies": {
7576
"@babel/generator": "^7.28.3",
7677
"@babel/parser": "~7.28.3",
77-
"@babel/traverse": "~7.28.3"
78+
"@babel/traverse": "~7.28.3",
79+
"@types/babel__traverse": "~7.28.0"
7880
}
7981
}

packages/evasive-transform/src/generate.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ const generator = /** @type {typeof import('@babel/generator')['default']} */ (
5454

5555
/**
5656
* Generates new code from a Babel AST; returns code and source map
57-
*@overload
57+
* @overload
5858
* @param {import('@babel/types').File} ast - Babel "File" AST
5959
* @param {GenerateAstOptionsWithSourceMap} options - Options for the transform
6060
* @returns {TransformedResultWithSourceMap}
@@ -63,7 +63,7 @@ const generator = /** @type {typeof import('@babel/generator')['default']} */ (
6363

6464
/**
6565
* Generates new code from a Babel AST; returns code only
66-
*@overload
66+
* @overload
6767
* @param {import('@babel/types').File} ast - Babel "File" AST
6868
* @param {GenerateAstOptions} [options] - Options for the transform
6969
* @returns {TransformedResult}
@@ -89,6 +89,7 @@ export const generate = (ast, options) => {
8989
{
9090
sourceFileName: sourceUrl,
9191
sourceMaps: Boolean(sourceUrl),
92+
// @ts-expect-error - what is this, even
9293
inputSourceMap,
9394
retainLines: true,
9495
...(source === undefined ? {} : { experimental_preserveFormat: true }),

packages/evasive-transform/src/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,5 @@ export function evadeCensorSync(source, options) {
123123
export async function evadeCensor(source, options) {
124124
return evadeCensorSync(source, options);
125125
}
126+
127+
export { createEvasiveTransformPass } from './transform-pass.js';

packages/evasive-transform/src/transform-ast.js

Lines changed: 44 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ import {
1313
evadeRegexpLiteral,
1414
} from './transform-code.js';
1515

16+
/**
17+
* @import {Visitor} from '@babel/traverse'
18+
* @import {Comment, CommentBlock, CommentLine, File} from '@babel/types'
19+
*/
20+
1621
// TODO The following is sufficient on Node.js, but for compatibility with
1722
// `node -r esm`, we must use the pattern below.
1823
// Restore after https://github.com/Agoric/agoric-sdk/issues/8671.
@@ -38,13 +43,47 @@ const traverse = /** @type {typeof import('@babel/traverse')['default']} */ (
3843
* @property {boolean} [onlyComments]
3944
*/
4045

46+
/**
47+
* Options for {@link makeTransformCommentsVisitor}
48+
*
49+
* @internal
50+
* @typedef TransformCommentsVisitorOptions
51+
* @property {(node: Comment|CommentBlock|CommentLine) => void} transformComment
52+
* @property {boolean} [onlyComments]
53+
*/
54+
55+
/**
56+
* @param {TransformCommentsVisitorOptions} options
57+
* @returns {Visitor}
58+
*/
59+
export const makeTransformCommentsVisitor = ({
60+
transformComment,
61+
onlyComments = false,
62+
}) => ({
63+
enter(p) {
64+
const { leadingComments, innerComments, trailingComments } = p.node;
65+
if ('comments' in p.node) {
66+
(p.node.comments || []).forEach(node => transformComment(node));
67+
}
68+
leadingComments?.forEach(node => transformComment(node));
69+
innerComments?.forEach(node => transformComment(node));
70+
trailingComments?.forEach(node => transformComment(node));
71+
if (!onlyComments) {
72+
evadeStrings(p);
73+
evadeTemplates(p);
74+
evadeRegexpLiteral(p);
75+
evadeDecrementGreater(p);
76+
}
77+
},
78+
});
79+
4180
/**
4281
* Performs transformations on the given AST
4382
*
4483
* This function mutates `ast`.
4584
*
4685
* @internal
47-
* @param {import('@babel/types').File} ast - AST, as generated by Babel
86+
* @param {File} ast - AST, as generated by Babel
4887
* @param {TransformAstOptions} [opts]
4988
* @returns {void}
5089
*/
@@ -53,26 +92,9 @@ export function transformAst(
5392
{ elideComments = false, onlyComments = false } = {},
5493
) {
5594
const transformComment = elideComments ? elideComment : evadeComment;
56-
traverse(ast, {
57-
enter(p) {
58-
const { leadingComments, innerComments, trailingComments, type } = p.node;
59-
// discriminated union
60-
if ('comments' in p.node) {
61-
(p.node.comments || []).forEach(node => transformComment(node));
62-
}
63-
// Rewrite all comments.
64-
(leadingComments || []).forEach(node => transformComment(node));
65-
if (type.startsWith('Comment')) {
66-
transformComment(p.node);
67-
}
68-
(innerComments || []).forEach(node => transformComment(node));
69-
(trailingComments || []).forEach(node => transformComment(node));
70-
if (!onlyComments) {
71-
evadeStrings(p);
72-
evadeTemplates(p);
73-
evadeRegexpLiteral(p);
74-
evadeDecrementGreater(p);
75-
}
76-
},
95+
const visitor = makeTransformCommentsVisitor({
96+
transformComment,
97+
onlyComments,
7798
});
99+
traverse(ast, visitor);
78100
}

packages/evasive-transform/src/transform-code.js

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
/**
2+
* @import {BinaryExpression, Expression, Node, TemplateElement, TemplateLiteral} from '@babel/types'
3+
* @import {NodePath} from '@babel/traverse'
4+
*/
5+
16
const evadeRegexp = /import\s*\(|<!--|-->/g;
27
// The replacement collection for regexp patterns matching the evadeRegexp is only applied to the first matched character, so it is necessary for the regexpReplacements to be maintained together with the evadeRegexp.
38
const regexpReplacements = {
@@ -11,8 +16,8 @@ const regexpReplacements = {
1116
* to sever references), updating the target's end position as if it had zero
1217
* length.
1318
*
14-
* @param {import('@babel/types').Node} target
15-
* @param {import('@babel/types').Node} src
19+
* @param {Node} target
20+
* @param {Node} src
1621
*/
1722
const adoptStartFrom = (target, src) => {
1823
try {
@@ -37,9 +42,9 @@ const adoptStartFrom = (target, src) => {
3742
/**
3843
* Creates a BinaryExpression adding two expressions
3944
*
40-
* @param {import('@babel/types').Expression} left
45+
* @param {Expression} left
4146
* @param {string} rightString
42-
* @returns {import('@babel/types').BinaryExpression}
47+
* @returns {BinaryExpression}
4348
*/
4449
const addStringToExpressions = (left, rightString) => ({
4550
type: 'BinaryExpression',
@@ -55,15 +60,15 @@ const addStringToExpressions = (left, rightString) => ({
5560
* Break up problematic substrings into concatenation expressions, e.g.
5661
* `"import("` -> `"im"+"port("`.
5762
*
58-
* @param {import('@babel/traverse').NodePath} p
63+
* @param {NodePath} p
5964
*/
6065
export const evadeStrings = p => {
6166
const { node } = p;
6267
if (node.type !== 'StringLiteral') {
6368
return;
6469
}
6570
const { value } = node;
66-
/** @type {import('@babel/types').Expression | undefined} */
71+
/** @type {Expression | undefined} */
6772
let expr;
6873
let lastIndex = 0;
6974
for (const match of value.matchAll(evadeRegexp)) {
@@ -85,10 +90,9 @@ export const evadeStrings = p => {
8590
* Break up problematic substrings in template literals with empty-string
8691
* expressions, e.g. `import(` -> `im${''}port(`.
8792
*
88-
* @param {import('@babel/traverse').NodePath} p
93+
* @param {NodePath} p
8994
*/
9095
export const evadeTemplates = p => {
91-
/** @type {import('@babel/types').TemplateLiteral} */
9296
const node = p.node;
9397
// The transform is only meaning-preserving if not part of a
9498
// TaggedTemplateExpression, so these need to be excluded until a motivating
@@ -107,11 +111,14 @@ export const evadeTemplates = p => {
107111
// Check if any quasi needs transformation
108112
if (!quasis.some(quasi => quasi.value.raw.search(evadeRegexp) !== -1)) return;
109113

110-
/** @type {import('@babel/types').TemplateElement[]} */
114+
/** @type {TemplateElement[]} */
111115
const newQuasis = [];
112-
/** @type {import('@babel/types').Expression[]} */
116+
/** @type {Expression[]} */
113117
const newExpressions = [];
114118

119+
/**
120+
* @param {string} quasiValue
121+
*/
115122
const addQuasi = quasiValue => {
116123
// Insert empty expression to break the pattern
117124
newExpressions.push({
@@ -130,6 +137,7 @@ export const evadeTemplates = p => {
130137
});
131138
};
132139

140+
// eslint-disable-next-line @endo/restrict-comparison-operands
133141
for (let i = 0; i < quasis.length; i += 1) {
134142
const quasi = quasis[i];
135143
// We're not currently preserving raw vs. cooked literal data.
@@ -158,6 +166,7 @@ export const evadeTemplates = p => {
158166
}
159167

160168
// Add original expression between quasis
169+
// eslint-disable-next-line @endo/restrict-comparison-operands
161170
if (i < node.expressions.length) {
162171
// @ts-ignore whatever was there, must still be allowed.
163172
newExpressions.push(node.expressions[i]);
@@ -169,7 +178,7 @@ export const evadeTemplates = p => {
169178
newQuasis[newQuasis.length - 1].tail = true;
170179
}
171180

172-
/** @type {import('@babel/types').Node} */
181+
/** @type {Node} */
173182
const replacement = {
174183
type: 'TemplateLiteral',
175184
quasis: newQuasis,
@@ -185,7 +194,7 @@ export const evadeTemplates = p => {
185194
*
186195
* `/import(/` -> `/im[p]ort(/`
187196
*
188-
* @param {import('@babel/traverse').NodePath} p
197+
* @param {NodePath} p
189198
* @returns {void}
190199
*/
191200
export const evadeRegexpLiteral = p => {
@@ -207,7 +216,7 @@ export const evadeRegexpLiteral = p => {
207216
* Prevents `-->` from appearing in output by transforming
208217
* `x-->y` to `(0,x--)>y`.
209218
*
210-
* @param {import('@babel/traverse').NodePath} p
219+
* @param {NodePath} p
211220
* @returns {void}
212221
*/
213222
export const evadeDecrementGreater = p => {
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* Types for `createEvasiveTransformPass`.
3+
*
4+
* @module
5+
*/
6+
7+
import type { Visitor } from '@babel/traverse';
8+
9+
/**
10+
* A mutating transform pass that rewrites AST nodes in place.
11+
*
12+
* Matches the `TransformPass` interface from `@endo/parser-pipeline`.
13+
*/
14+
export interface TransformPass {
15+
readonly visitor: Visitor;
16+
}
17+
18+
/**
19+
* Options for {@link createEvasiveTransformPass}.
20+
*/
21+
export interface CreateEvasiveTransformPassOptions {
22+
/**
23+
* Empties comments but preserves interior newlines.
24+
*/
25+
elideComments?: boolean;
26+
27+
/**
28+
* If true, limits transformation to comment contents, preserving code
29+
* positions within each line.
30+
*/
31+
onlyComments?: boolean;
32+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* Provides {@link createEvasiveTransformPass}, a {@link TransformPass} adapter
3+
* for evasive transforms.
4+
*
5+
* @module
6+
*/
7+
8+
/**
9+
* @import {TransformPass, CreateEvasiveTransformPassOptions} from './transform-pass-types.js'
10+
*/
11+
12+
import { evadeComment, elideComment } from './transform-comment.js';
13+
import { makeTransformCommentsVisitor } from './transform-ast.js';
14+
15+
/**
16+
* Creates a {@link TransformPass} compatible with `@endo/parser-pipeline` that
17+
* applies SES censorship evasion transforms as AST mutations.
18+
*
19+
* The visitor applies the same transforms as {@link evadeCensorSync} but
20+
* operates on an existing AST rather than parsing from source.
21+
*
22+
* @param {CreateEvasiveTransformPassOptions} [options]
23+
* @returns {TransformPass}
24+
*/
25+
export const createEvasiveTransformPass = (options = {}) => {
26+
const { elideComments = false, onlyComments = false } = options;
27+
const transformComment = elideComments ? elideComment : evadeComment;
28+
29+
return {
30+
visitor: makeTransformCommentsVisitor({ transformComment, onlyComments }),
31+
};
32+
};
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import test from 'ava';
2+
import { parse } from '@babel/parser';
3+
import babelTraverse from '@babel/traverse';
4+
import babelGenerate from '@babel/generator';
5+
import { createEvasiveTransformPass } from '../src/transform-pass.js';
6+
7+
const traverse = babelTraverse.default || babelTraverse;
8+
const generate = babelGenerate.default || babelGenerate;
9+
10+
test('createEvasiveTransformPass returns a TransformPass', t => {
11+
const pass = createEvasiveTransformPass();
12+
t.is(typeof pass.visitor, 'object');
13+
t.truthy(pass.visitor.enter);
14+
});
15+
16+
test('evasive transform pass defangs import() in strings', t => {
17+
const source = `const x = "import('foo')";`;
18+
const ast = parse(source, { sourceType: 'module' });
19+
const pass = createEvasiveTransformPass();
20+
traverse(ast, pass.visitor);
21+
const { code } = generate(ast);
22+
t.false(code.includes(`import('foo')`));
23+
});
24+
25+
test('evasive transform pass rewrites HTML comments', t => {
26+
const source = `const x = 1; /* <!-- comment --> */`;
27+
const ast = parse(source, { sourceType: 'module' });
28+
const pass = createEvasiveTransformPass();
29+
traverse(ast, pass.visitor);
30+
const { code } = generate(ast);
31+
t.false(code.includes('<!--'));
32+
});
33+
34+
test('evasive transform pass respects elideComments option', t => {
35+
const source = `/* some comment */ const x = 1;`;
36+
const ast = parse(source, { sourceType: 'module' });
37+
const pass = createEvasiveTransformPass({ elideComments: true });
38+
traverse(ast, pass.visitor);
39+
const { code } = generate(ast);
40+
t.false(code.includes('some comment'));
41+
});
42+
43+
test('evasive transform pass with onlyComments skips code transforms', t => {
44+
const source = `const x = "import('foo')"; /* <!-- --> */`;
45+
const ast = parse(source, { sourceType: 'module' });
46+
const pass = createEvasiveTransformPass({ onlyComments: true });
47+
traverse(ast, pass.visitor);
48+
const { code } = generate(ast);
49+
t.true(code.includes(`import('foo')`), 'string should not be modified');
50+
t.false(code.includes('<!--'), 'comment should still be rewritten');
51+
});
Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
{
2-
"extends": "../../tsconfig.eslint-base.json",
32
"compilerOptions": {
43
"allowJs": true,
5-
"allowSyntheticDefaultImports": true
4+
"allowSyntheticDefaultImports": true,
5+
"stripInternal": true
66
},
7+
"extends": "../../tsconfig.eslint-base.json",
78
"include": [
89
"*.js",
910
"*.ts",
1011
"src",
1112
"test"
1213
]
13-
}
14+
}

0 commit comments

Comments
 (0)