Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 45 additions & 16 deletions src/analyze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>,
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(
Expand Down Expand Up @@ -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, '..') },
Expand Down Expand Up @@ -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;
}

Expand Down
108 changes: 108 additions & 0 deletions test/static-eval-vars.test.js
Original file line number Diff line number Diff line change
@@ -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' });
});
Loading