diff --git a/src/analyzers/scope-analyzer/ScopeAnalyzer.ts b/src/analyzers/scope-analyzer/ScopeAnalyzer.ts index 169e3339..b4001cf6 100644 --- a/src/analyzers/scope-analyzer/ScopeAnalyzer.ts +++ b/src/analyzers/scope-analyzer/ScopeAnalyzer.ts @@ -84,6 +84,11 @@ export class ScopeAnalyzer implements IScopeAnalyzer { sourceType: ScopeAnalyzer.sourceTypes[i] }); + // Fix Annex B function hoisting references + // eslint-scope doesn't implement Annex B semantics where function declarations + // in blocks also create a var-hoisted binding in the enclosing function scope + this.fixAnnexBFunctionHoisting(); + return; } catch (error) { if (i < sourceTypeLength - 1) { @@ -117,6 +122,101 @@ export class ScopeAnalyzer implements IScopeAnalyzer { return scope; } + /** + * Fix Annex B function hoisting references. + * + * In non-strict mode, function declarations in blocks have dual binding: + * 1. A block-scoped binding (handled by eslint-scope) + * 2. A var-hoisted binding in the enclosing function scope (NOT handled by eslint-scope) + * + * This method merges block-scoped function declarations into the enclosing + * function scope and links unresolved references. + */ + private fixAnnexBFunctionHoisting(): void { + if (!this.scopeManager) { + return; + } + + this.walkScopes(this.scopeManager.globalScope, (scope: eslintScope.Scope) => { + if (scope.type !== 'block' && scope.type !== 'switch') { + return; + } + + // Skip strict mode scopes - Annex B doesn't apply + if (scope.isStrict) { + return; + } + + const functionScope = scope.variableScope; + + if (!functionScope) { + return; + } + + for (let i = scope.variables.length - 1; i >= 0; i--) { + const variable = scope.variables[i]; + + const isFunctionDeclaration = variable.defs.some( + (def) => def.type === 'FunctionName' && def.node?.type === 'FunctionDeclaration' + ); + + if (!isFunctionDeclaration) { + continue; + } + + // Find existing variable with the same name in function scope (shadowing case) + const outerVariable = functionScope.variables.find((v) => v.name === variable.name && v !== variable); + + // Per Annex B.3.3, hoisting only applies if outer binding is var/function (not let/const) + const isOuterLetOrConst = outerVariable?.defs.some( + (def) => def.type === 'Variable' && (def.parent?.kind === 'let' || def.parent?.kind === 'const') + ); + + // Skip Annex B hoisting if there's a let/const with the same name + if (isOuterLetOrConst) { + continue; + } + + const targetVariable = outerVariable ?? variable; + + if (outerVariable) { + // Merge inner function's identifiers and references into outer + outerVariable.identifiers.push(...variable.identifiers); + outerVariable.references.push(...variable.references); + } else { + // Move variable to function scope so references can find it + functionScope.variables.push(variable); + } + + // Remove from block scope + scope.variables.splice(i, 1); + + // Link "through" references with matching name to the target variable + this.linkThroughReferences(variable.name, functionScope, targetVariable); + } + }); + } + + /** + * Link unresolved "through" references to a variable. + * + * @param {string} name - The variable name to match + * @param {Scope} scope - The scope to start searching from + * @param {Variable} targetVariable - The variable to link references to + */ + private linkThroughReferences(name: string, scope: eslintScope.Scope, targetVariable: eslintScope.Variable): void { + for (let i = scope.through.length - 1; i >= 0; i--) { + if (scope.through[i].identifier.name === name) { + targetVariable.references.push(scope.through[i]); + scope.through.splice(i, 1); + } + } + + for (const childScope of scope.childScopes) { + this.linkThroughReferences(name, childScope, targetVariable); + } + } + /** * @param {Scope} scope */ @@ -150,4 +250,18 @@ export class ScopeAnalyzer implements IScopeAnalyzer { this.sanitizeScopes(childScope); } } + + /** + * Walk through all scopes in the scope tree + * + * @param {Scope} scope - Starting scope + * @param {Function} callback - Function to call for each scope + */ + private walkScopes(scope: eslintScope.Scope, callback: (scope: eslintScope.Scope) => void): void { + callback(scope); + + for (const childScope of scope.childScopes) { + this.walkScopes(childScope, callback); + } + } } diff --git a/test/functional-tests/analyzers/scope-analyzer/ScopeAnalyzer.spec.ts b/test/functional-tests/analyzers/scope-analyzer/ScopeAnalyzer.spec.ts index b0f82d72..544cd3d0 100644 --- a/test/functional-tests/analyzers/scope-analyzer/ScopeAnalyzer.spec.ts +++ b/test/functional-tests/analyzers/scope-analyzer/ScopeAnalyzer.spec.ts @@ -2,6 +2,7 @@ import 'reflect-metadata'; import { assert } from 'chai'; +import { evalLocal } from '../../../helpers/evalLocal'; import { readFileAsString } from '../../../helpers/readFileAsString'; import { JavaScriptObfuscator } from '../../../../src/JavaScriptObfuscatorFacade'; @@ -43,5 +44,104 @@ describe('ScopeAnalyzer', () => { assert.equal(error, null); }); }); + + describe('Variant #2: Annex B function hoisting', () => { + describe('Variant #1: basic block-scoped function hoisting', () => { + const samplesCount: number = 50; + + let testFunc: () => void; + + beforeEach(() => { + const code: string = readFileAsString(__dirname + '/fixtures/annex-b-function-hoisting.js'); + + testFunc = () => { + for (let i = 0; i < samplesCount; i++) { + const obfuscatedCode: string = JavaScriptObfuscator.obfuscate(code, { + seed: i + }).getObfuscatedCode(); + + const result = evalLocal(obfuscatedCode); + + if (result.test1 !== 'foo') { + throw new Error('test1 failed: expected foo, got ' + result.test1); + } + + if (result.test2 !== 'bar') { + throw new Error('test2 failed: expected bar, got ' + result.test2); + } + } + }; + }); + + it('should correctly handle Annex B function hoisting references', () => { + assert.doesNotThrow(testFunc); + }); + }); + + describe('Variant #2: strict mode should not apply Annex B hoisting', () => { + let testFunc: () => void; + + beforeEach(() => { + const code: string = ` + 'use strict'; + function test() { + let foo; + if (true) { + function foo() { return 'inner'; } + foo(); // This refers to block-scoped foo + } + // foo here is the outer let, which is undefined + return typeof foo; + } + test(); + `; + + testFunc = () => { + const obfuscatedCode: string = JavaScriptObfuscator.obfuscate(code, { + seed: 12345 + }).getObfuscatedCode(); + + eval(obfuscatedCode); + }; + }); + + it('should correctly handle strict mode block-scoped functions', () => { + assert.doesNotThrow(testFunc); + }); + }); + + describe('Variant #3: let/const shadowing should prevent Annex B hoisting', () => { + let testFunc: () => void; + + beforeEach(() => { + const code: string = ` + function test() { + let foo = 'outer'; + if (true) { + function foo() { return 'inner'; } + foo(); // block-scoped foo + } + return foo; // should be 'outer', not the function + } + test(); + `; + + testFunc = () => { + const obfuscatedCode: string = JavaScriptObfuscator.obfuscate(code, { + seed: 12345 + }).getObfuscatedCode(); + + const result = eval(obfuscatedCode); + if (result !== 'outer') { + throw new Error('Expected outer, got: ' + result); + } + }; + }); + + it('should not hoist when let/const shadows the function name', () => { + assert.doesNotThrow(testFunc); + }); + }); + }); }); }); diff --git a/test/functional-tests/analyzers/scope-analyzer/fixtures/annex-b-function-hoisting.js b/test/functional-tests/analyzers/scope-analyzer/fixtures/annex-b-function-hoisting.js new file mode 100644 index 00000000..0dc18de1 --- /dev/null +++ b/test/functional-tests/analyzers/scope-analyzer/fixtures/annex-b-function-hoisting.js @@ -0,0 +1,39 @@ +// Basic Annex B case: function in block referenced after block +function test1() { + if (true) { + function foo() { + return 'foo'; + } + } + return foo(); +} + +// Function in switch case +function test2(x) { + switch (x) { + case 1: + function bar() { + return 'bar'; + } + break; + } + return bar(); +} + +// Multiple blocks with same function name +function test3() { + if (true) { + function baz() { + return 'first'; + } + } + if (false) { + function baz() { + return 'second'; + } + } + return baz(); +} + +// Return results for testing +({ test1: test1(), test2: test2(1) }); diff --git a/test/helpers/evalLocal.ts b/test/helpers/evalLocal.ts new file mode 100644 index 00000000..237c669a --- /dev/null +++ b/test/helpers/evalLocal.ts @@ -0,0 +1,9 @@ +/** + * Evaluates code using indirect eval. + * Indirect eval runs in global scope without inheriting strict mode from the calling context. + * This is needed for testing features like Annex B function hoisting. + * + * @param {string} code + * @returns {any} + */ +export const evalLocal = (code: string): any => (0, eval)(code);