Files
pdfme/packages/common/__tests__/expression.test.ts
Mani 6bef40c67c fix: two unbounded-cache memory leaks in common and schemas (#1426)
* fix: two unbounded-cache memory leaks in common and schemas

Two module-level Map caches that never evict and store multi-MB strings
as keys, silently leaking for the entire lifetime of any consumer.

1. packages/common/src/expression.ts — parseDataCache

   parseData() was memoized via a module-level parseDataCache keyed by
   JSON.stringify(data). replacePlaceholders() calls it with a merged
   { ...schemaNameDefaults, ...variables } object where values may be
   arbitrary strings from the caller. Whenever inputs contain base64
   (image schemas with embedded data URLs, embedded fonts, large text),
   the cache key is a multi-MB JSON string that gets pinned permanently;
   every unique inputs state adds its own key, never collected. Parsing
   is O(fields) and cheap, so removing the cache is strictly a win.

   Regression test: packages/common/__tests__/expression.test.ts
   'replacePlaceholders memory safety > does not retain call inputs in
   a module-level cache' — runs 30 replacePlaceholders() calls with
   unique ~500 KB payloads, captures a V8 heap snapshot via
   v8.writeHeapSnapshot, aggregates string nodes >= 200 KB and asserts
   the total retained size is below 2 MB. Pre-fix: ~30 MB retained
   (FAILS). Post-fix: 0 bytes retained (passes).

2. packages/schemas/src/graphics/image.ts — getCacheKey

   getCacheKey(schema, input) returned `${schema.type}${input}`, using
   the full base64 bytes of the image as part of the cache key. Every
   unique image processed by the PDF render path added a permanent Map
   entry whose key byte length matched the image itself.

   Replaced with a short fingerprint that samples the total length plus
   three 16-char regions (first, middle, last). The middle-region
   sample is essential: base64 PNGs share a common header and IEND
   trailer, so distinct images of the same size would collide if only
   first/last regions were sampled. Middle bytes are pixel data and
   differ between distinct images with overwhelming probability. Keys
   stay under 80 chars regardless of input size.

   Regression tests: packages/schemas/__tests__/image.test.ts
   - 'does not pin the full base64 input as a cache key' — asserts
     key length < 100 chars. Pre-fix: 139 chars for a minimal PNG and
     proportionally more for realistic images (FAILS).
   - 'distinguishes different images via the fingerprint' — guards
     against future over-shortening of the fingerprint that could
     reintroduce collisions between distinct images.

Both leaks were originally identified via a V8 heap-snapshot diff taken
across a UI workload (typing + field tabbing) against a consumer app
with image schemas carrying base64 content. Before the fix, the top two
growing allocations by retained size were multi-MB string entries — one
per module-level cache in this PR — together accounting for hundreds of
MB of retained JS heap in a single 3-iteration run. After the fix, both
string entries disappear from the top 25 growing allocations and
aggregate JS heap is net flat / slightly shrinking across iterations.

No public API change. No behavioral change for consumers. Both caches
were module-local implementation details.

* fix(schemas): harden image cache key with FNV-1a hash; fix stale test comments

Addresses Greptile review on #1426:

- Replace 3-region sampling fingerprint in getCacheKey with an FNV-1a
  32-bit hash over the full input. The old first-16 slice was a
  constant data-URI prefix for any image of the same MIME type,
  contributing no entropy; hashing every byte removes that weakness
  at the same O(n) cost without retaining any slice as a Map key.
  Key format is now `${type}:${len}:${fnv1a-hex}` (~40 chars).
- Rewrite stale comments in image.test.ts that referred to a
  padding/mutation scheme the test never performs, and update the
  fingerprint-format comment to match the new hash-based key.
- Add trailing newline to expression.test.ts.

All pre-existing and new tests still pass.
2026-04-27 16:30:32 +09:00

650 lines
28 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { replacePlaceholders } from '../src/expression.js';
import { SchemaPageArray } from '../src/index.js';
describe('replacePlaceholders', () => {
it('should return content as is if there are no placeholders', () => {
const content = 'Hello, world!';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
expect(result).toBe(content);
});
it('should replace placeholders with variables', () => {
const content = 'Hello, {name}!';
const variables = { name: 'Alice' };
const result = replacePlaceholders({ content, variables, schemas: [] });
expect(result).toBe('Hello, Alice!');
});
it('should evaluate expressions within placeholders', () => {
const content = 'The sum is {1 + 2}.';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
expect(result).toBe('The sum is 3.');
});
it('should handle date and dateTime placeholders', () => {
const content = 'Today is {date} and now is {dateTime}.';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
const date = new Date();
const padZero = (num: number) => String(num).padStart(2, '0');
const formattedDate = `${date.getFullYear()}/${padZero(date.getMonth() + 1)}/${padZero(
date.getDate()
)}`;
const formattedDateTime = `${formattedDate} ${padZero(date.getHours())}:${padZero(
date.getMinutes()
)}`;
expect(result).toBe(`Today is ${formattedDate} and now is ${formattedDateTime}.`);
});
it('should handle data from schemas', () => {
const content = 'Schema content: {name}';
const variables = {};
const schemas = [
[
{
name: 'name',
type: 'text',
content: 'SchemaName',
readOnly: true,
},
],
] as SchemaPageArray;
const result = replacePlaceholders({ content, variables, schemas });
expect(result).toBe('Schema content: SchemaName');
});
it('should prioritize variables over schemas', () => {
const content = 'Name: {name}';
const variables = { name: 'VariableName' };
const schemas = [
[
{
name: 'name',
type: 'text',
content: 'SchemaName',
readOnly: true,
},
],
] as SchemaPageArray;
const result = replacePlaceholders({ content, variables, schemas });
expect(result).toBe('Name: VariableName');
});
it('should handle nested placeholders in variables', () => {
const content = 'Nested variable: {greeting}';
const variables = { greeting: 'Hello, {name}!' };
const schemas = [
[
{
name: 'name',
type: 'text',
content: 'Bob',
readOnly: true,
},
],
] as SchemaPageArray;
const result = replacePlaceholders({ content, variables, schemas });
expect(result).toBe('Nested variable: Hello, Bob!');
});
it('should return content unchanged when placeholders are invalid', () => {
const content = 'Invalid placeholder: {name';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
expect(result).toBe('Invalid placeholder: {name');
});
it('should evaluate expressions even if they result in Infinity', () => {
const content = 'Divide by zero: {1 / 0}';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
expect(result).toBe('Divide by zero: Infinity');
});
it('should handle complex expressions', () => {
const content = 'Result: {Math.max(1, 2, 3)}';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
expect(result).toBe('Result: 3');
});
it('should parse JSON strings in variables', () => {
const content = 'Data: {data.value}';
const variables = { data: '{"value": "42"}' };
const result = replacePlaceholders({ content, variables, schemas: [] });
expect(result).toBe('Data: 42');
});
it('should handle variables of different types', () => {
const content = 'Number: {num}, Boolean: {bool}, Array: {arr[0]}, Object: {obj.key}';
const variables = {
num: 42,
bool: true,
arr: ['first', 'second'],
obj: { key: 'value' },
};
const result = replacePlaceholders({ content, variables, schemas: [] });
expect(result).toBe('Number: 42, Boolean: true, Array: first, Object: value');
});
it('should use content from readOnly schemas', () => {
const content = 'Content: {readOnlyField}';
const variables = {};
const schemas = [
[
{
name: 'readOnlyField',
type: 'text',
content: 'ReadOnlyContent',
readOnly: true,
},
],
] as SchemaPageArray;
const result = replacePlaceholders({ content, variables, schemas });
expect(result).toBe('Content: ReadOnlyContent');
});
it('should use empty string for non-readOnly schema content', () => {
const content = 'Content: {editableField}';
const variables = {};
const schemas = [
[
{
name: 'editableField',
type: 'text',
content: 'Should not be used',
readOnly: false,
},
],
] as SchemaPageArray;
const result = replacePlaceholders({ content, variables, schemas });
expect(result).toBe('Content: ');
});
it('should allow method chaining on permitted global objects', () => {
const content = 'Chained: {Math.random().toString()}';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
// Math.random() generates a random number, which is then converted to a string using toString()
const regex = /^Chained: \d+\.\d+$/;
expect(regex.test(result)).toBe(true);
});
});
describe('replacePlaceholders - Security Tests', () => {
it('should prevent access to __proto__ property', () => {
const content = 'Proto: {__proto__}';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
// Since __proto__ access is prohibited, the placeholder should remain unchanged
expect(result).toBe('Proto: {__proto__}');
});
it('should prevent access to constructor property', () => {
const content = 'Constructor: {constructor}';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
// 'constructor' is allowed if defined in context or globals; assuming it's not, placeholder remains
expect(result).toBe('Constructor: {constructor}');
});
it('should prevent access to prototype property', () => {
const content = 'Prototype: {prototype}';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
// 'prototype' access is prohibited
expect(result).toBe('Prototype: {prototype}');
});
it('should prevent access to nested prohibited properties', () => {
const content = 'Nested: {user.__proto__.polluted}';
const variables = { user: {} };
const result = replacePlaceholders({ content, variables, schemas: [] });
// Access to '__proto__' is prohibited; placeholder remains unchanged
expect(result).toBe('Nested: {user.__proto__.polluted}');
});
it('should prevent use of Function constructor', () => {
const content = 'Function: {Function("return 42")()}';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
// Use of Function constructor is not allowed; placeholder remains unchanged
expect(result).toBe('Function: {Function("return 42")()}');
});
it('should prevent access to disallowed global variables', () => {
const content = 'Process: {process.env}';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
// 'process' is not in allowedGlobals; placeholder remains unchanged
expect(result).toBe('Process: {process.env}');
});
it('should prevent prototype pollution via JSON.parse', () => {
const content = 'Polluted: {JSON.parse(\'{"__proto__":{"polluted":true}}\').polluted}';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
// Even if 'polluted' is accessed, the prototype is not polluted, so undefined is returned.
expect(result).toBe('Polluted: undefined');
});
it('should prevent accessing nested prohibited properties in functions', () => {
const content = 'Access: {( () => { return this.constructor } )()}';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
// Attempting to access 'constructor' via 'this' should be prohibited; placeholder remains unchanged
expect(result).toBe('Access: {( () => { return this.constructor } )()}');
});
it('should prevent accessing global objects not in allowedGlobals', () => {
const content = 'Global: {global}';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
// 'global' is not in allowedGlobals; placeholder remains unchanged
expect(result).toBe('Global: {global}');
});
it('should prevent accessing Object constructor via allowed globals', () => {
const content = 'ObjectConstructor: {Object.constructor}';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
// Accessing 'constructor' of 'Object' is prohibited
expect(result).toBe('ObjectConstructor: {Object.constructor}');
});
it('should prevent accessing Function from allowed globals', () => {
const content = 'FunctionAccess: {Function("return 42")()}';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
// 'Function' is not in allowedGlobals, so this should fail
expect(result).toBe('FunctionAccess: {Function("return 42")()}');
});
it('should prevent accessing nested properties via allowed globals', () => {
const content = 'NestedAccess: {Math.__proto__.polluted}';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
// Accessing '__proto__' of allowed global 'Math' is prohibited
expect(result).toBe('NestedAccess: {Math.__proto__.polluted}');
});
it('should prevent execution of arbitrary code via ternary operator', () => {
const content = 'ArbitraryCode: {true ? (() => { return "Hacked" })() : "Safe"}';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
// Execution of arbitrary functions is not allowed; placeholder remains unchanged
expect(result).toBe('ArbitraryCode: {true ? (() => { return "Hacked" })() : "Safe"}');
});
it('should handle attempts to override context variables', () => {
const content = 'Override: {date = "Hacked"} {date}';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
// Assignment operations are not supported; placeholders remain unchanged
const date = new Date();
const padZero = (num: number) => String(num).padStart(2, '0');
const dateFmt = `${date.getFullYear()}/${padZero(date.getMonth() + 1)}/${padZero(
date.getDate()
)}`;
expect(result).toBe(`Override: {date = "Hacked"} ${dateFmt}`);
});
it('should prevent using eval-like expressions', () => {
const content = 'Eval: {eval("2 + 2")';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
// 'eval' is not in allowedGlobals; placeholder remains unchanged
expect(result).toBe('Eval: {eval("2 + 2")');
});
it('should prevent accessing undefined variables', () => {
const content = 'Undefined: {undefinedVar}';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
// 'undefinedVar' is not defined; placeholder remains unchanged
expect(result).toBe('Undefined: {undefinedVar}');
});
it('should prevent accessing nested properties of undefined variables', () => {
const content = 'NestedUndefined: {user.name}';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
// 'user' is undefined; accessing 'name' should fail and placeholder remains unchanged
expect(result).toBe('NestedUndefined: {user.name}');
});
it('should prevent accessing nested prohibited properties in objects', () => {
const content = 'Nested: {user.__proto__.polluted}';
const variables = { user: {} };
const result = replacePlaceholders({ content, variables, schemas: [] });
// Since access to '__proto__' is prohibited, the placeholder remains unchanged.
expect(result).toBe('Nested: {user.__proto__.polluted}');
});
it('should prevent using Function constructor', () => {
const content = 'Function: {Function("return 42")()}';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
// Since 'Function' is not included in allowedGlobals, the placeholder remains unchanged.
expect(result).toBe('Function: {Function("return 42")()}');
});
});
describe('replacePlaceholders - Comparison Operators Tests', () => {
it('should evaluate expressions with == operator', () => {
const content = 'Equals: {1 == 1}';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
expect(result).toBe('Equals: true');
});
it('should evaluate expressions with != operator', () => {
const content = 'NotEquals: {1 != 2}';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
expect(result).toBe('NotEquals: true');
});
it('should evaluate expressions with === operator', () => {
const content = 'StrictEquals: {1 === 1}';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
expect(result).toBe('StrictEquals: true');
});
it('should evaluate expressions with !== operator', () => {
const content = 'StrictNotEquals: {1 !== "1"}';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
expect(result).toBe('StrictNotEquals: true');
});
it('should evaluate expressions with < operator', () => {
const content = 'LessThan: {1 < 2}';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
expect(result).toBe('LessThan: true');
});
it('should evaluate expressions with > operator', () => {
const content = 'GreaterThan: {2 > 1}';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
expect(result).toBe('GreaterThan: true');
});
it('should evaluate expressions with <= operator', () => {
const content = 'LessThanOrEquals: {1 <= 1}';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
expect(result).toBe('LessThanOrEquals: true');
});
it('should evaluate expressions with >= operator', () => {
const content = 'GreaterThanOrEquals: {2 >= 1}';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
expect(result).toBe('GreaterThanOrEquals: true');
});
it('should handle complex expressions with comparison operators', () => {
const content = 'Complex: {1 + 2 > 2 && 4 - 1 < 5}';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
expect(result).toBe('Complex: true');
});
it('should prevent execution of arbitrary code via comparison operators', () => {
const content = 'ArbitraryCode: {1 < (() => { return "Hacked" })()}';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
// Execution of arbitrary functions is not allowed; placeholder remains unchanged
expect(result).toBe('ArbitraryCode: {1 < (() => { return "Hacked" })()}');
});
});
describe('replacePlaceholders - XSS Vulnerability Prevention Tests', () => {
it('should prevent XSS via Object.getOwnPropertyDescriptor and Object.getPrototypeOf (CVE payload 1)', () => {
const content = '{ ((f, g) => f(g(Object), "constructor").value)(Object.getOwnPropertyDescriptor, Object.getPrototypeOf)("alert(location)")() }';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
// The dangerous expression should not be evaluated and should return as-is
expect(result).toBe(content);
});
it('should prevent XSS via object property assignment (CVE payload 2)', () => {
const content = '{ { f: Object.getOwnPropertyDescriptor }.f({ g: Object.getPrototypeOf }.g(Object), "constructor").value("alert(location)")() }';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
// The dangerous expression should not be evaluated and should return as-is
expect(result).toBe(content);
});
it('should prevent direct access to Object.getOwnPropertyDescriptor', () => {
const content = '{ Object.getOwnPropertyDescriptor(Object, "constructor") }';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
// Direct access to dangerous method should be blocked
expect(result).toBe(content);
});
it('should prevent direct access to Object.getPrototypeOf', () => {
const content = '{ Object.getPrototypeOf(Object) }';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
// Direct access to dangerous method should be blocked
expect(result).toBe(content);
});
it('should prevent access to Object.setPrototypeOf', () => {
const content = '{ Object.setPrototypeOf({}, null) }';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
// Direct access to dangerous method should be blocked
expect(result).toBe(content);
});
it('should prevent access to Object.defineProperty', () => {
const content = '{ Object.defineProperty({}, "prop", { value: 42 }) }';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
// Direct access to dangerous method should be blocked
expect(result).toBe(content);
});
it('should prevent access to Object.defineProperties', () => {
const content = '{ Object.defineProperties({}, { prop: { value: 42 } }) }';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
// Direct access to dangerous method should be blocked
expect(result).toBe(content);
});
it('should prevent access to Object.getOwnPropertyNames', () => {
const content = '{ Object.getOwnPropertyNames(Object) }';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
// Direct access to dangerous method should be blocked
expect(result).toBe(content);
});
it('should prevent access to Object.getOwnPropertySymbols', () => {
const content = '{ Object.getOwnPropertySymbols(Object) }';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
// Direct access to dangerous method should be blocked
expect(result).toBe(content);
});
it('should allow safe Object methods', () => {
// Test Object.keys
const keysContent = '{ Object.keys({ a: 1, b: 2 }) }';
const keysResult = replacePlaceholders({ content: keysContent, variables: {}, schemas: [] });
expect(keysResult).toBe('a,b');
// Test Object.values
const valuesContent = '{ Object.values({ a: 1, b: 2 }) }';
const valuesResult = replacePlaceholders({ content: valuesContent, variables: {}, schemas: [] });
expect(valuesResult).toBe('1,2');
// Test Object.entries
const entriesContent = '{ Object.entries({ a: 1 })[0] }';
const entriesResult = replacePlaceholders({ content: entriesContent, variables: {}, schemas: [] });
expect(entriesResult).toBe('a,1');
// Test safe Object.assign
const assignContent = '{ Object.assign({}, { a: 1 }, { b: 2 }).a }';
const assignResult = replacePlaceholders({ content: assignContent, variables: {}, schemas: [] });
expect(assignResult).toBe('1'); // Safe assign should work
});
it('should prevent complex XSS attempts via nested function calls', () => {
const content = '{ [].map.call("abc", Object.getOwnPropertyDescriptor) }';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
// Complex attempts to access dangerous functions should be blocked
expect(result).toBe(content);
});
it('should prevent Function constructor access via constructor property', () => {
const content = '{ "".constructor.constructor("alert(1)")() }';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
// Attempts to access Function constructor should be blocked
expect(result).toBe(content);
});
it('should prevent prototype pollution via Object.assign and __lookupGetter__', () => {
const content = '{ { assign: Object.assign }.assign({ f: {}.__lookupGetter__("__proto__") }.f(), { polluted: "yes" }) }';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
// The dangerous expression should not be evaluated due to __lookupGetter__ being blocked
expect(result).toBe(content);
// Verify that prototype is not polluted
expect(({} as any).polluted).toBeUndefined();
});
it('should prevent access to __lookupGetter__', () => {
const content = '{ {}.__lookupGetter__("__proto__") }';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
expect(result).toBe(content);
});
it('should prevent access to __lookupSetter__', () => {
const content = '{ {}.__lookupSetter__("__proto__") }';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
expect(result).toBe(content);
});
it('should prevent access to __defineGetter__', () => {
const content = '{ {}.__defineGetter__("test", () => "hacked") }';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
expect(result).toBe(content);
});
it('should prevent access to __defineSetter__', () => {
const content = '{ {}.__defineSetter__("test", () => {}) }';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
expect(result).toBe(content);
});
it('should allow safe Object.assign but prevent prototype pollution', () => {
const content = '{ Object.assign({}, { a: 1 }) }';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
// Safe assign should work
expect(result).toBe('[object Object]');
});
it('should prevent prototype pollution via Object.assign', () => {
const pollutionContent = '{ Object.assign({}, { "__proto__": { polluted: "yes" } }) }';
const result = replacePlaceholders({ content: pollutionContent, variables: {}, schemas: [] });
// Should execute but not pollute prototype
expect(result).toBe('[object Object]');
expect(({} as any).polluted).toBeUndefined();
// Test with constructor
const constructorContent = '{ Object.assign({}, { "constructor": { polluted: "yes" } }) }';
const result2 = replacePlaceholders({ content: constructorContent, variables: {}, schemas: [] });
expect(result2).toBe('[object Object]');
expect(({} as any).constructor.polluted).toBeUndefined();
});
it('should no longer allow Object.create due to security concerns', () => {
const content = '{ Object.create(null) }';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
// Object.create is now blocked
expect(result).toBe(content);
});
it('should no longer allow Object.freeze due to security concerns', () => {
const content = '{ Object.freeze({}) }';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
// Object.freeze is now blocked
expect(result).toBe(content);
});
it('should no longer allow Object.seal due to security concerns', () => {
const content = '{ Object.seal({}) }';
const result = replacePlaceholders({ content, variables: {}, schemas: [] });
// Object.seal is now blocked
expect(result).toBe(content);
});
});
describe('replacePlaceholders memory safety', () => {
// Regression guard: before the fix, `parseData` memoized results in a
// module-level `parseDataCache` keyed by `JSON.stringify(data)` that was
// never evicted. Every unique inputs state added a permanent Map key
// whose byte length matched the input byte length, so any consumer
// passing varying large string values (image base64 in schema content,
// embedded fonts, large text) leaked memory for the app's lifetime.
//
// This test stress-tests the API with many unique large inputs and
// asserts that no multi-hundred-KB strings are retained afterwards. It
// uses `v8.writeHeapSnapshot` to capture ground truth from V8 instead
// of relying on `process.memoryUsage()` (which is too coarse and noisy
// to catch a cache that grows by tens of MB per burst).
//
// The test is deterministic in direction: pre-fix, retained "big
// string" bytes is O(inputs × inputSize) ≈ 15 MB, far above the
// threshold. Post-fix, retained is effectively zero (the strings are
// temporaries freed when the call returns).
it('does not retain call inputs in a module-level cache', () => {
const schemas: SchemaPageArray = [
[
{
name: 'blob',
type: 'text',
content: '',
position: { x: 0, y: 0 },
width: 100,
height: 10,
},
],
] as unknown as SchemaPageArray;
// 30 unique payloads × ~500 KB each. If the fix regresses, at least
// 15 MB of string data will be pinned as parseDataCache keys. The
// content MUST contain a placeholder (`{blob}`) — otherwise
// replacePlaceholders short-circuits and parseData is never called.
const PAYLOAD_COUNT = 30;
const PAYLOAD_SIZE = 500_000;
for (let i = 0; i < PAYLOAD_COUNT; i++) {
replacePlaceholders({
content: 'value: {blob}',
variables: { blob: `${i}-${'x'.repeat(PAYLOAD_SIZE)}` },
schemas,
});
}
// Force full GC if `--expose-gc` is available. If not, V8 will still
// have collected most transients by the time writeHeapSnapshot runs,
// and the threshold is generous enough that transient tails don't
// matter. On CI where --expose-gc is absent the test can still
// distinguish +15 MB (leak) from <1 MB (fixed).
const gc = (globalThis as { gc?: () => void }).gc;
if (gc) gc();
// v8.writeHeapSnapshot is available in all Node versions the pdfme
// monorepo supports. The snapshot JSON can be tens of MB; we parse it
// once and discard.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const v8: typeof import('v8') = require('v8');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const fs: typeof import('fs') = require('fs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const os: typeof import('os') = require('os');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const path: typeof import('path') = require('path');
const snapPath = path.join(
os.tmpdir(),
`pdfme-expression-memtest-${process.pid}-${Date.now()}.heapsnapshot`,
);
v8.writeHeapSnapshot(snapPath);
const rawSnap = fs.readFileSync(snapPath, 'utf8');
fs.unlinkSync(snapPath);
const snap = JSON.parse(rawSnap) as {
nodes: number[];
snapshot: { meta: { node_fields: string[]; node_types: string[][] } };
};
const fieldCount = snap.snapshot.meta.node_fields.length;
const typeFieldIdx = snap.snapshot.meta.node_fields.indexOf('type');
const selfSizeIdx = snap.snapshot.meta.node_fields.indexOf('self_size');
const typeNames = snap.snapshot.meta.node_types[typeFieldIdx];
// Sum self_size of all 'string' nodes whose size exceeds 200 KB. A
// healthy post-fix run has ~0 such nodes (strings are freed after
// each call). A leaking pre-fix run has at least PAYLOAD_COUNT
// nodes totalling >= PAYLOAD_COUNT * PAYLOAD_SIZE bytes.
const MIN_STRING_BYTES = 200_000;
let retainedLargeStringBytes = 0;
for (let i = 0; i < snap.nodes.length; i += fieldCount) {
if (typeNames[snap.nodes[i + typeFieldIdx]] !== 'string') continue;
const size = snap.nodes[i + selfSizeIdx];
if (size >= MIN_STRING_BYTES) retainedLargeStringBytes += size;
}
// Generous threshold: healthy is ~0, leak is ~15 MB. 2 MB cleanly
// separates the two without being sensitive to unrelated large
// strings (e.g. inline source maps in test fixtures).
expect(retainedLargeStringBytes).toBeLessThan(2_000_000);
}, 60_000);
});