diff --git a/src/analyze.ts b/src/analyze.ts index a4ba9862..1937b852 100644 --- a/src/analyze.ts +++ b/src/analyze.ts @@ -267,6 +267,44 @@ export interface AnalyzeResult { isESM: boolean; } +type KnownBindings = Record< + string, + { shadowDepth: number; value: StaticValue | ConditionalValue } +>; + +// The vars that static-eval reads, looked up on demand. Copying every binding +// into a new object on each evaluation made analysis quadratic in the file +// size. Same lookup order as that copy: import.meta, then known bindings +// (shadowed ones resolve to undefined), then globals. Lookups are live, so +// bindings added or removed later are seen. +export function createStaticEvalVars( + knownBindings: KnownBindings, + globals: Record, + importMetaUrl: string, +) { + const hasOwn = Object.prototype.hasOwnProperty; + function getDescriptor(name: string | symbol) { + if (typeof name !== 'string') return undefined; + let value; + if (name === 'import.meta') value = { url: importMetaUrl }; + else if (hasOwn.call(knownBindings, name)) { + const binding = knownBindings[name]; + value = binding.shadowDepth === 0 ? binding.value : undefined; + } else if (hasOwn.call(globals, name)) value = { value: globals[name] }; + else return undefined; + // A proxy can only report a property its target lacks as configurable. + return { value, writable: true, enumerable: true, configurable: true }; + } + return new Proxy(Object.create(null), { + getOwnPropertyDescriptor: (_target, name) => getDescriptor(name), + get: (_target, name) => getDescriptor(name)?.value, + has: (_target, name) => getDescriptor(name) !== undefined, + set: () => { + throw new Error('static eval vars are read-only'); + }, + }); +} + // Helper: process pino transport config object // Extracts target strings from { target: '...' }, { targets: [...] }, or { pipeline: [...] } async function processPinoTransportObject( @@ -400,13 +438,7 @@ export default async function analyze( const importMetaUrl = pathToFileURL(id).href; - const knownBindings: Record< - string, - { - shadowDepth: number; - value: StaticValue | ConditionalValue; - } - > = Object.assign(Object.create(null), { + const knownBindings: KnownBindings = Object.assign(Object.create(null), { __dirname: { shadowDepth: 0, value: { value: path.resolve(id, '..') }, @@ -510,17 +542,14 @@ export default async function analyze( } } + const staticEvalVars = createStaticEvalVars( + knownBindings, + globalBindings, + importMetaUrl, + ); async function computePureStaticValue(expr: Node, computeBranches = true) { - const vars = Object.create(null); - Object.keys(globalBindings).forEach((name) => { - vars[name] = { value: globalBindings[name] }; - }); - Object.keys(knownBindings).forEach((name) => { - vars[name] = getKnownBinding(name); - }); - vars['import.meta'] = { url: importMetaUrl }; // evaluate returns undefined for non-statically-analyzable - const result = await evaluate(expr, vars, computeBranches); + const result = await evaluate(expr, staticEvalVars, computeBranches); return result; } diff --git a/test/static-eval-vars.test.js b/test/static-eval-vars.test.js new file mode 100644 index 00000000..a4bf1a64 --- /dev/null +++ b/test/static-eval-vars.test.js @@ -0,0 +1,108 @@ +const { createStaticEvalVars } = require('../out/analyze.js'); + +const hasOwn = (obj, name) => Object.prototype.hasOwnProperty.call(obj, name); + +// The eager copy that computePureStaticValue used to build on every call. +function copyVars(knownBindings, globals, importMetaUrl) { + const vars = Object.create(null); + for (const name of Object.keys(globals)) + vars[name] = { value: globals[name] }; + for (const name of Object.keys(knownBindings)) { + const binding = knownBindings[name]; + vars[name] = binding.shadowDepth === 0 ? binding.value : undefined; + } + vars['import.meta'] = { url: importMetaUrl }; + return vars; +} + +function setup() { + const knownBindings = Object.assign(Object.create(null), { + __dirname: { shadowDepth: 0, value: { value: '/app' } }, + URL: { shadowDepth: 0, value: { value: 'known URL' } }, + process: { shadowDepth: 1, value: { value: 'shadowed process' } }, + }); + const globals = { + URL: 'global URL', + process: 'global process', + Buffer: 'global Buffer', + MONGOOSE_DRIVER_PATH: undefined, + }; + const importMetaUrl = 'file:///app/index.js'; + const vars = createStaticEvalVars(knownBindings, globals, importMetaUrl); + return { knownBindings, globals, importMetaUrl, vars }; +} + +const PROTOTYPE_NAMES = [ + 'toString', + 'constructor', + 'hasOwnProperty', + '__proto__', +]; + +test('matches the eager copy for every lookup', () => { + const { knownBindings, globals, importMetaUrl, vars } = setup(); + const copy = copyVars(knownBindings, globals, importMetaUrl); + const names = [ + 'import.meta', + ...Object.keys(knownBindings), + ...Object.keys(globals), + 'missing', + ...PROTOTYPE_NAMES, + ]; + for (const name of names) { + expect([name, hasOwn(vars, name), name in vars, vars[name]]).toEqual([ + name, + hasOwn(copy, name), + name in copy, + copy[name], + ]); + } +}); + +test('looks up import.meta, then known bindings, then globals', () => { + const { vars } = setup(); + expect(vars['import.meta']).toEqual({ url: 'file:///app/index.js' }); + expect(vars.URL).toEqual({ value: 'known URL' }); + expect(vars.Buffer).toEqual({ value: 'global Buffer' }); +}); + +test('a shadowed binding is present with an undefined value', () => { + const { vars } = setup(); + expect(hasOwn(vars, 'process')).toBe(true); + expect('process' in vars).toBe(true); + expect(vars.process).toBeUndefined(); +}); + +test('a global with an undefined value is present', () => { + const { vars } = setup(); + expect(hasOwn(vars, 'MONGOOSE_DRIVER_PATH')).toBe(true); + expect('MONGOOSE_DRIVER_PATH' in vars).toBe(true); + expect(vars.MONGOOSE_DRIVER_PATH).toEqual({ value: undefined }); +}); + +test('Object.prototype members and symbols do not leak in', () => { + const { vars } = setup(); + for (const name of [...PROTOTYPE_NAMES, Symbol.iterator]) { + expect(hasOwn(vars, name)).toBe(false); + expect(name in vars).toBe(false); + expect(vars[name]).toBeUndefined(); + } +}); + +test('sees bindings that change after creation', () => { + const { knownBindings, vars } = setup(); + knownBindings.later = { shadowDepth: 0, value: { value: 'later' } }; + knownBindings.process.shadowDepth = 0; + delete knownBindings.__dirname; + expect(vars.later).toEqual({ value: 'later' }); + expect(vars.process).toEqual({ value: 'shadowed process' }); + expect('__dirname' in vars).toBe(false); +}); + +test('rejects writes', () => { + const { vars } = setup(); + expect(() => { + vars.URL = { value: 'overwritten' }; + }).toThrow('read-only'); + expect(vars.URL).toEqual({ value: 'known URL' }); +});