diff --git a/packages/compat/src/resolver-transform.ts b/packages/compat/src/resolver-transform.ts index 12e082c81..229cf2aa2 100644 --- a/packages/compat/src/resolver-transform.ts +++ b/packages/compat/src/resolver-transform.ts @@ -74,6 +74,10 @@ export function makeResolverTransform(resolver: Resolver) { handleComponentHelper(node.params[0], resolver, filename, scopeStack); return; } + if (node.path.original === 'helper' && node.params.length > 0) { + handleDynamicHelper(node.params[0], resolver, filename); + return; + } resolver.resolveSubExpression(node.path.original, filename, node.path.loc); }, MustacheStatement(node: ASTv1.MustacheStatement) { @@ -97,6 +101,10 @@ export function makeResolverTransform(resolver: Resolver) { handleComponentHelper(node.params[0], resolver, filename, scopeStack); return; } + if (node.path.original === 'helper' && node.params.length > 0) { + handleDynamicHelper(node.params[0], resolver, filename); + return; + } let hasArgs = node.params.length > 0 || node.hash.pairs.length > 0; let resolution = resolver.resolveMustache(node.path.original, hasArgs, filename, node.path.loc); if (resolution && resolution.type === 'component') { @@ -320,3 +328,16 @@ function handleComponentHelper( resolver.resolveComponentHelper(locator, moduleName, param.loc, impliedBecause); } + +function handleDynamicHelper(param: ASTv1.Node, resolver: Resolver, moduleName: string): void { + switch (param.type) { + case 'StringLiteral': + resolver.resolveDynamicHelper({ type: 'literal', path: param.value }, moduleName, param.loc); + break; + case 'TextNode': + resolver.resolveDynamicHelper({ type: 'literal', path: param.chars }, moduleName, param.loc); + break; + default: + resolver.resolveDynamicHelper({ type: 'other' }, moduleName, param.loc); + } +} diff --git a/packages/compat/src/resolver.ts b/packages/compat/src/resolver.ts index fc3f5c6c4..1633151d8 100644 --- a/packages/compat/src/resolver.ts +++ b/packages/compat/src/resolver.ts @@ -87,12 +87,14 @@ const builtInHelpers = [ 'hasBlock', 'hasBlockParams', 'hash', + 'helper', 'if', 'input', 'let', 'link-to', 'loc', 'log', + // 'modifier', 'mount', 'mut', 'on', @@ -108,7 +110,6 @@ const builtInHelpers = [ ]; const builtInComponents = ['input', 'link-to', 'textarea']; - const builtInModifiers = ['action', 'on']; // this is a subset of the full Options. We care about serializability, and we @@ -821,6 +822,43 @@ export default class CompatResolver implements Resolver { from ); } + + resolveDynamicHelper(helper: ComponentLocator, from: string, loc: Loc): Resolution | null { + if (!this.staticHelpersEnabled) { + return null; + } + + if (helper.type === 'literal') { + let helperName = helper.path; + if (builtInHelpers.includes(helperName)) { + return null; + } + + let found = this.tryHelper(helperName, from); + if (found) { + return this.add(found, from); + } + return this.add( + { + type: 'error', + message: `Missing helper`, + detail: helperName, + loc, + }, + from + ); + } else { + return this.add( + { + type: 'error', + message: 'Unsafe dynamic helper', + detail: `cannot statically analyze this expression`, + loc, + }, + from + ); + } + } } function humanReadableFile(root: string, file: string) { diff --git a/packages/compat/tests/resolver.test.ts b/packages/compat/tests/resolver.test.ts index 66dc183b9..8e435a4c9 100644 --- a/packages/compat/tests/resolver.test.ts +++ b/packages/compat/tests/resolver.test.ts @@ -519,6 +519,18 @@ describe('compat-resolver', function () { }, ]); }); + test('string literal passed to `helper` helper in content position', function () { + let findDependencies = configure({ + staticHelpers: true, + }); + givenFile('helpers/hello-world.js'); + expect(findDependencies('templates/application.hbs', `{{helper "hello-world"}}`)).toEqual([ + { + path: '../helpers/hello-world.js', + runtimeName: 'the-app/helpers/hello-world', + }, + ]); + }); test('built-in components are ignored when used with the component helper', function () { let findDependencies = configure({ staticComponents: true, @@ -534,6 +546,21 @@ describe('compat-resolver', function () { ) ).toEqual([]); }); + test('built-in helpers are ignored when used with the "helper" helper', function () { + let findDependencies = configure({ + staticHelpers: true, + }); + expect( + findDependencies( + 'templates/application.hbs', + ` + {{helper "fn"}} + {{helper "array"}} + {{helper "concat"}} + ` + ) + ).toEqual([]); + }); test('component helper with direct addon package reference', function () { let findDependencies = configure({ staticComponents: true, @@ -635,6 +662,25 @@ describe('compat-resolver', function () { }, ]); }); + test('string literal passed to "helper" helper in helper position', function () { + let findDependencies = configure({ staticHelpers: true }); + givenFile('helpers/hello-world.js'); + expect( + findDependencies( + 'templates/application.hbs', + ` + {{#let (helper "hello-world") as |helloWorld|}} + {{helloWorld}} + {{/let}} + ` + ) + ).toEqual([ + { + path: '../helpers/hello-world.js', + runtimeName: 'the-app/helpers/hello-world', + }, + ]); + }); test('string literal passed to component helper fails to resolve', function () { let findDependencies = configure({ staticComponents: true }); givenFile('components/my-thing.js'); @@ -642,11 +688,21 @@ describe('compat-resolver', function () { findDependencies('templates/application.hbs', `{{my-thing header=(component "hello-world") }}`); }).toThrow(new RegExp(`Missing component: hello-world in templates/application.hbs`)); }); + test('string literal passed to "helper" helper fails to resolve', function () { + let findDependencies = configure({ staticHelpers: true }); + expect(() => { + findDependencies('templates/application.hbs', `{{helper "hello-world"}}`); + }).toThrow(new RegExp(`Missing helper: hello-world in templates/application.hbs`)); + }); test('string literal passed to component helper fails to resolve when staticComponents is off', function () { let findDependencies = configure({ staticComponents: false }); givenFile('components/my-thing.js'); expect(findDependencies('templates/application.hbs', `{{my-thing header=(component "hello-world") }}`)).toEqual([]); }); + test('string literal passed to "helper" helper fails to resolve when staticHelpers is off', function () { + let findDependencies = configure({ staticHelpers: false }); + expect(findDependencies('templates/application.hbs', `{{helper "hello-world"}}`)).toEqual([]); + }); test('dynamic component helper error in content position', function () { let findDependencies = configure({ staticComponents: true }); givenFile('components/hello-world.js'); @@ -1708,6 +1764,20 @@ describe('compat-resolver', function () { ); }); + test('rejects arbitrary expression in "helper" helper', function () { + let findDependencies = configure({ staticHelpers: true }); + expect(() => findDependencies('templates/application.hbs', `{{helper (some-helper this.which) }}`)).toThrow( + `Unsafe dynamic helper: cannot statically analyze this expression` + ); + }); + + test('rejects any non-string-literal in "helper" helper', function () { + let findDependencies = configure({ staticHelpers: true }); + expect(() => findDependencies('templates/application.hbs', `{{helper this.which }}`)).toThrow( + `Unsafe dynamic helper: cannot statically analyze this expression` + ); + }); + test('trusts inline ensure-safe-component helper', function () { let findDependencies = configure({ staticComponents: true }); expect(findDependencies('templates/application.hbs', `{{component (ensure-safe-component this.which) }}`)).toEqual(