Skip to content

fix: emit large integer-valued numbers as <real> to avoid silent corruption - #175

Open
spokodev wants to merge 2 commits into
TooTallNate:masterfrom
spokodev:fix/large-integer-exponential-corruption
Open

spokodev wants to merge 2 commits into
TooTallNate:masterfrom
spokodev:fix/large-integer-exponential-corruption

Conversation

@spokodev

Copy link
Copy Markdown

Problem

Integer-valued numbers with magnitude >= 1e21 are silently corrupted on a build -> parse round-trip.

plist.parse(plist.build(1e21))    // => 1      (expected 1e21)
plist.parse(plist.build(-1e21))   // => -1     (expected -1e21)
plist.parse(plist.build(1.23e30)) // => 1      (expected 1.23e30)

No error is thrown, so callers have no signal that 21+ orders of magnitude were lost.

Cause

build classifies any integer-valued number as <integer> and serializes it with Number.prototype.toString():

const tag_type = next % 1 === 0 ? 'integer' : 'real';
next_child.ele(tag_type).txt(next.toString());

For |x| >= 1e21, toString() returns exponential notation, e.g. (1e21).toString() === "1e+21", producing <integer>1e+21</integer>. On parse, the <integer> branch does parseInt(value, 10), which stops at the e and returns 1.

The boundary is exactly 1e21: values below it (including 1e20 and 562341325190349060000) stringify in plain decimal and round-trip fine. The binary path (buildBinary/parseBinary) round-trips 1e21 correctly, so this is an XML-text serialization defect, not a representational limit.

Apple's PLIST DTD defines <integer> and <real> as the two numeric element types; a value that cannot be expressed as a plain-decimal integer string belongs in <real>.

Fix

Classify a number as <integer> only when it is a true integer below the exponential threshold; otherwise emit <real>:

const tag_type =
  Number.isInteger(next) && Math.abs(next) < 1e21 ? 'integer' : 'real';

The <real> parse branch already uses parseFloat, which recovers 1e+21 exactly. Values below 1e21 keep the existing <integer> output, so there is no behavior change for the common case.

Test

Added a round-trip test under parse() > integer. It fails on master (expected 1 to be 1e+21) and passes with the fix. Full suite: 113 passing.

Note

src/build.browser.ts (line ~76) has the same classification logic and the same defect. I scoped this PR to the Node path that the repro exercises; let me know if you want the browser build fixed in this PR or a follow-up.

…uption

Integer-valued numbers with magnitude >= 1e21 were tagged <integer> and
serialized via Number.prototype.toString(), which uses exponential notation
for such values (e.g. "1e+21"). On parse, the <integer> branch uses
parseInt(value, 10), which stops at the "e" and returns 1, silently dropping
21+ orders of magnitude with no error.

Classify a number as <integer> only when Number.isInteger(next) and
Math.abs(next) < 1e21; otherwise emit <real>. The <real> parse branch uses
parseFloat, which recovers "1e+21" exactly. Values below 1e21 are unaffected.

The binary plist path already round-trips these values correctly, confirming
this is an XML-text serialization defect rather than a representational limit.
@vercel

vercel Bot commented Jun 30, 2026

Copy link
Copy Markdown

@spokodev is attempting to deploy a commit to the TooTallNate's Team Team on Vercel.

A member of the Team first needs to authorize it.

@changeset-bot

changeset-bot Bot commented Jun 30, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 01f9ffb

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
plist Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant