本文へ移動
myowchick

JSON.parseは16桁から整数を黙って書き換える

JSON整形ツールを書いていて、17桁のIDを貼ったら出力の末尾が1つずれた。

入れたのは12345678901234567で、出てきたのは12345678901234568。

整形は入力をJSON.parseで読んでJSON.stringifyで書き直すだけなので、丸めはその1往復のあいだに起きている。例外も警告も出ない。

サイトには結局置かなかったツールなんだけど、書いている途中で踏んだ細部が面白かったので残しておく。以下の実測はぜんぶNode 24.19.0(V8 13.6)。

出力を見ても丸めには気づけない

整形ツールは「読んで書き直す」道具なので、書き直した結果しか手元に残らない。

const input = '{"id": 12345678901234567}'
JSON.stringify(JSON.parse(input), null, 2)
// '{\n  "id": 12345678901234568\n}'

出力の12345678901234568はそれ自体としては何もおかしくない。整数だし、桁も合っている。入力と並べて末尾を突き合わせない限り、変わったことは分からない。

だから警告は出力側からは拾えない。JSON.parseを通した時点で情報は落ちているので、入力の文字列そのものを見にいくしかない。

何桁から危ないのか

境界は桁ではなく2の53乗、つまり9007199254740992にある。これが16桁なので、15桁以下(最大999999999999999)は必ず安全で、16桁の途中から壊れはじめる。往復して値が変わらない最大の整数が9007199254740992、変わる最小の整数が9007199254740993。

桁数ごとに、その桁の整数を等間隔に10万点取って往復させた結果。

const survives = (s) => String(JSON.parse(s)) === s
桁数往復しても値が変わらない割合
14桁100.0%
15桁100.0%
16桁94.5%
17桁16.4%
18桁1.7%
19桁0.2%
20桁0.0%

16桁が94.5%なのは、16桁の約89%が2の53乗より下にあって、残りは刻み幅が2なので半分だけ生き残るから。以降は刻み幅が倍々になっていくので、割合も倍々で落ちていく。

19桁が0.2%というのは、要するにその桁のIDは実質ぜんぶ壊れるということ。IDを数値で持つAPIを叩くとき、JSONのまま素通しできない理由がこれ。

ツール側は16を閾値にした。2の53乗を直に比較するには先にNumberへ落とすことになって本末転倒なので、桁数で保守的に切っている。15桁以下を誤って警告することは無い。

警告は入力の文字列から拾う

入力から16桁以上の整数リテラルを探す。ここで/-?\d{16,}/gを1発掛けると、数字が並んでいるだけの場所に片っ端から当たる。

入力正規表現1発文字列を読み飛ばす走査
{"id": 12345678901234567}拾う拾う
{"note": "注文番号 12345678901234567"}拾う拾わない
{"12345678901234567": 1}拾う拾わない
{"a": 0.12345678901234567}拾う拾わない

壊れた例を8件並べて試したら、正規表現が誤爆したのは上の表の下3行と同じ形の3件。文字列の中身とキーは丸めと関係ないし、小数部の桁は倍精度の有効桁の話であって整数の丸めではない。

なので、文字列リテラルを読み飛ばしながら1文字ずつ走る。細部が2つある。

エスケープは2文字進める

文字列の中では、\を見たらindex += 2で次の1文字ごと飛ばす。飛ばさないと\"の"を文字列の終わりと読んでしまい、そこから先の内と外がぜんぶ入れ替わる。

// エスケープを飛ばさない版
findRoundingRiskNumbers('{"a": "\\"12345678901234567\\"", "b": 1}')
// => ['12345678901234567']   ← 文字列の中身なのに拾ってしまう

\\(バックスラッシュ2つ)でも同じで、2文字目を文字列の終わりと誤読しないために2文字進める必要がある。

数値の後ろは小数部と指数部ごと読み飛ばす

整数部を読み終えて、次が.かeかEならその数値は整数ではないので警告しない。ただしそこで走査を止めると、小数部の数字が次の数値として数え直される。

// 数値の残りを飛ばさない版
findRoundingRiskNumbers('{"ratio": 0.12345678901234567}')
// => ['12345678901234567']   ← 小数部を17桁の整数として拾う
findRoundingRiskNumbers('{"tiny": 1.5e-1234567890123456}')
// => ['-1234567890123456']   ← 指数部を負の整数として拾う

-を数値の一部として飛ばすので、指数の-も巻き込める。

parseエラーの位置は取れるときと取れないときがある

失敗したときは、どこで読めなくなったかを行と列で出したい。JSON.parseの例外メッセージにはat position 8と書いてあるので、そこから割り出せる……と思って/position (\d+)/を前提に書くと落ちる。

壊れたJSONを33種類食わせて分類した結果、位置が載ったのは18件で、載らなかったのが15件。

位置が載る側。

{"a" 1}    Expected ':' after property name in JSON at position 5 (line 1 column 6)
"abc       Unterminated string in JSON at position 4 (line 1 column 5)
{"a":1,}   Expected double-quoted property name in JSON at position 7 (line 1 column 8)
01         Unexpected number in JSON at position 1 (line 1 column 2)
1e         Exponent part is missing a number in JSON at position 2 (line 1 column 3)

載らない側。

(空)       Unexpected end of JSON input
[          Unexpected end of JSON input
{"a":}     Unexpected token '}', "{"a":}" is not valid JSON
[1,2,]     Unexpected token ']', "[1,2,]" is not valid JSON
undefined  "undefined" is not valid JSON

見た限りでは、V8が「何を期待していたか」を言えるエラーには位置が載って、「予期しないトークン」と「入力の終わり」には載らない。{はExpected property name or '}' in JSON at position 1で位置つき、[はUnexpected end of JSON inputで位置なし、という非対称もここから来ている。

末尾カンマも閉じ忘れも日常的な壊れ方なので、位置が無い側は例外的な経路ではない。取れないときは行と列を名乗らず、メッセージだけ出す。

export function describeParseFailure(input: string, message: string): JsonResult {
  const matched = /position (\d+)/.exec(message)
  if (matched === null) return { ok: false, message }

  return { ok: false, message, ...toLineColumn(input, Number(matched[1])) }
}

位置が載らない側は、代わりに入力そのものを引用してくる。この引用も入力が21文字以上になると前後を...で削る。

["AAAAAAAAAAAAAAAAAAAA",XXX,"BBBBBBBBBBBBBBBBBBBB"]
→ Unexpected token 'X', ..."AAAAAAAA",XXX,"BBBBB"... is not valid JSON

抜粋の中の"とメッセージ側の引用符が混ざるうえ、抜粋のどこが問題の位置なのかは示されない。位置の代わりにはならない。

positionは「文字目」ではない

位置が取れた側にも罠がある。positionはUTF-16のコード単位で数えられているので、サロゲートペアが入ると人間の数える文字数からずれる。

JSON.parse('{"a":"🐣🐣🐣","b" 1}')
// Expected ':' after property name in JSON at position 18 (line 1 column 19)
// 見た目には16文字目

絵文字3つで3ずれる。input.slice(0, position)で切ってから行と列を数え直しても、切る単位が同じなので同じだけずれる。ここを合わせにいくなら[...input]で数え直すことになるけれど、そうすると今度はエディタの列表示とずれる。ツールでは合わせにいかないことにした。

なお(line 1 column 6)はV8が最近書き足したもので、自分で数え直さなくても読める。ただしブラウザのエンジンごとにメッセージの形は違うし、ここで測ったのはNodeのV8だけなので、フォールバックを外す気にはならなかった。

まとめ

  • JSON.parseは2の53乗を超えた整数を黙って丸める。境界は16桁の途中で、19桁のIDは実測で99.8%が壊れる
  • 整形ツールは読んで書き直すので、丸めは出力から検出できない。警告は入力の文字列を走査して拾う
  • 走査は文字列リテラルを読み飛ばす。エスケープは2文字進める、数値の後ろは小数部と指数部ごと飛ばす
  • JSON.parseのエラーメッセージには位置が載る系統と載らない系統がある。positionが取れない前提を書いておく
← 一覧へ戻る