Skip to content

User methods named like Date/Number/Array built-ins (getTime, toFixed, toISOString, toSorted, …) are compiled as the built-in regardless of the receiver — the user method never runs #10476

Description

@proggeramlug

Found by the package audit (compiling real npm packages from source instead of Perry's native bindings) on
Perry e6dcb62 (v0.5.1587), Linux x64. A method call whose NAME matches a Date.prototype, Number.prototype or
some Array.prototype methods is lowered to that built-in by name, whatever the receiver is. The user's method is never
called: you get NaN, [object Object], Invalid Date or RangeError: Invalid time value instead.

Reproduction

package.json

{"name":"repro","private":true,"type":"module"}

dayjs_like.js (dayjs 1.11.x toJSON shape)

function Dayjs(ms) { this.$ms = ms; }
Dayjs.prototype.toISOString = function () { return 'Dayjs.toISOString(' + this.$ms + ')'; };
Dayjs.prototype.toJSON = function () { return this.toISOString(); };
try { console.log('js this.toISOString() in toJSON', new Dayjs(0).toJSON()); } catch (e) { console.log('js this.toISOString() in toJSON THREW', String(e)); }

main.ts

import './dayjs_like.js';
const t = (label: string, f: () => unknown) => { try { console.log(label, String(f())); } catch (e) { console.log(label, 'THREW', String(e)); } };

function Money(this: any, v: number) { this.v = v; }
Money.prototype.toFixed = function (d: number) { return 'Money.toFixed'; };
Money.prototype.getTime = function () { return 'Money.getTime'; };
Money.prototype.toISOString = function () { return 'Money.toISOString'; };
const m: any = new (Money as any)(1);
t('function-ctor m.toFixed(2)   ', () => m.toFixed(2));
t('function-ctor m.getTime()    ', () => m.getTime());
t('function-ctor m.toISOString()', () => m.toISOString());

class Clock {
  getTime() { return 'Clock.getTime'; }
  toFixed(d: number) { return 'Clock.toFixed'; }
  setHours(h: number) { return 'Clock.setHours'; }
}
const c = new Clock();
t('class c.getTime()            ', () => c.getTime());
t('class c.toFixed(2)           ', () => c.toFixed(2));
t('class c.setHours(1)          ', () => c.setHours(1));

const lit = { getFullYear() { return 'lit.getFullYear'; }, toPrecision(p: number) { return 'lit.toPrecision'; } };
t('object literal getFullYear() ', () => lit.getFullYear());
t('object literal toPrecision(3)', () => lit.toPrecision(3));
node main.ts
perry compile main.ts -o out && ./out

Expected (Node 26.5.1)

js this.toISOString() in toJSON Dayjs.toISOString(0)
function-ctor m.toFixed(2)    Money.toFixed
function-ctor m.getTime()     Money.getTime
function-ctor m.toISOString() Money.toISOString
class c.getTime()             Clock.getTime
class c.toFixed(2)            Clock.toFixed
class c.setHours(1)           Clock.setHours
object literal getFullYear()  lit.getFullYear
object literal toPrecision(3) lit.toPrecision

Actual (Perry)

Same output with and without PERRY_NO_AUTO_OPTIMIZE=1:

js this.toISOString() in toJSON THREW RangeError: Invalid time value
function-ctor m.toFixed(2)    NaN
function-ctor m.getTime()     [object Object]
function-ctor m.toISOString() THREW RangeError: Invalid time value
class c.getTime()             [object Object]
class c.toFixed(2)            NaN
class c.setHours(1)           NaN
object literal getFullYear()  NaN
object literal toPrecision(3) NaN

A zero-argument call of a user method named endsWith, includes or startsWith does not even compile. endswith.js
imported from main.ts:

function Path() {}
Path.prototype.endsWith = function () { return 'Path.endsWith'; };
console.log(new Path().endsWith());
Error compiling module 'endswith.js' (...) with --backend llvm: lowering entry of module 'endswith.js': lowering init statements of non-entry module 'endswith.js': perry-codegen: String.endsWith expects 1 or 2 args, got 0

(Node prints Path.endsWith.)

Impact

  • dayjs 1.11.21: d.toISOString(), d.toJSON() (esm/index.js:496, return this.isValid() ? this.toISOString() : null) throw RangeError: Invalid time value, so JSON.stringify(dayjs()) fails.
  • moment 2.30.1: same path (moment.js:4329 toJSON), plus the toISOString call on a moment instance.
  • decimal.js 10.6.0 (P.toFixed = function (dp, rm), decimal.js:2025) and bignumber.js 11.1.5 (P.toFixed, dist/bignumber.js:2673): toFixed/toExponential/toPrecision return "NaN". That also breaks every money-formatting chain.
  • Any library class exposing getTime(), setTime(), toFixed() or toSorted(), e.g. timers, clocks, fixed-point and vector types. The class form is affected too, not only ES5 prototypes.

Notes

I swept all 185 identifier-named methods of the Date/Number/String/Array/RegExp/Map/Set/Promise/Object/Function/
Boolean/Symbol/Error/ArrayBuffer/TypedArray/WeakMap/BigInt/DataView prototypes. Each was defined as a user method
returning a sentinel and called in these forms: new F().m(), new F().m(2), this.m() inside another prototype
method, var P = F.prototype; P.m = …, F.prototype = { m }, Object.create({ m }), and class K { m() {} } (JS
module). Methods that never run:

names receivers affected
getDate getDay getFullYear getHours getMilliseconds getMinutes getMonth getSeconds getTime getTimezoneOffset getUTCDate getUTCDay getUTCFullYear getUTCHours getUTCMilliseconds getUTCMinutes getUTCMonth getUTCSeconds and every set* counterpart (setDatesetUTCSeconds, setTime) all forms, including class instances and object literals
toFixed toPrecision toExponential all forms
toSorted toReversed all forms
reduceRight hasOwnProperty propertyIsEnumerable (called with an argument) all forms, including class instances
flatMap toSpliced (called with an argument) Object.create({ m }) receivers
toISOString toDateString toTimeString toUTCString toGMTString, and zero-arg toLocaleString toLocaleDateString toLocaleTimeString this.m() inside a prototype method, and any-typed receivers in TS. A plain local in a JS module and class instances escape.
endsWith includes startsWith with zero args compile error (above), on non-class receivers

All String-named methods other than those three compile and run correctly. So do Map/Set/Promise/RegExp-named methods.

Suspected locations (inferred from reading the code, not bisected):

  • crates/perry-hir/src/lower/expr_call/url_date_instance.rs:149: receiver_may_be_date is true unless the static
    receiver class is one of URL/Object/Buffer/BlockList/SocketAddress/Uint8Array/Uint8ClampedArray/Array. Unknown
    receivers and user classes fall into the Date arms ("getTime" => Expr::DateGetTime, line 231; setters, line 370).
    Only toJSON is gated on a proven Date receiver.
  • crates/perry-codegen/src/lower_call/property_get/number_string.rs:30/46/66: toFixed/toPrecision/toExponential
    lower to js_number_to_* for any receiver that is not provably a string or array ("tests often call it on Any locals").
  • crates/perry-codegen/src/lower_string_method.rs:716: the arity bail! fires before the receiver is known to be a
    string.

Related closed issues for the same name-collision class: #7673 (zod .trim()String.prototype.trim), #5271 (joi
trim), #6065 (charAt/charCodeAt/codePointAt). Sibling audit bug: #10477 (decimal.js/bignumber.js instanceof).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    package-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindings

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions