本文へ移動
myowchick

btoaは日本語で落ちて、caféでは黙って壊れる

btoa('あ')は落ちる。InvalidCharacterErrorが飛んでくる。ここまではよく知られている。

厄介なのはbtoa('café')のほうで、これは落ちない。落ちないまま、UTF-8ではないバイト列のBase64を返してくる。

エンコーディング変換ツールでこのへんを踏み直した。どこに線が引かれているのかを、Node 24.19.0で確かめながら見ていく。

btoaが読んでいるのは文字ではなくコードユニット

btoaの引数は文字列だが、中身はバイト列のつもりで読まれる。文字列の各コードユニットを1バイトとみなし、0〜255に収まらないものが混じっていたらInvalidCharacterErrorを投げる。いわゆるbinary stringで、実質Latin-1の範囲しか通らない。

Node 24.19.0で境目を確かめた。

入力コードポイント結果
éU+00E96Q==
ÿU+00FF/w==
ĀU+0100InvalidCharacterError
あU+3042InvalidCharacterError

U+00FFとU+0100のあいだにきれいに線が引かれている。日本語が落ちるのは日本語だからではなく、単に256を超えているからだ。

落ちてくれるうちはまだいい

問題はcaféのように、たまたま全部がLatin-1に収まってしまう文字列。

btoa('café')
// 'Y2Fm6Q=='  → バイト列は 63 61 66 e9

new TextEncoder().encode('café')
// Uint8Array(5) [99, 97, 102, 195, 169]  → 63 61 66 c3 a9 → 'Y2Fmw6k='

éはUTF-8ではc3 a9の2バイトなのに、btoaはe9の1バイトとして書き出す。これはUTF-8ではなくLatin-1のバイト列で、受け取った側がUTF-8として読めば当然壊れる。

const bytes = Uint8Array.from(atob('Y2Fm6Q=='), (c) => c.charCodeAt(0))
new TextDecoder('utf-8').decode(bytes)
// 'caf�'

例外も出ず、cafまではちゃんと読める。ASCIIしか入れずにテストしていると気づけない類のずれで、btoaが投げてくれる日本語のほうがよほど親切だと思う。

TextEncoderでバイト列にしてから詰め直す

なので、Base64にしたい文字列はまずTextEncoderでUTF-8のバイト列にして、それを1バイト1文字のbinary stringに組み直してからbtoaに渡す。ツールの実装もこの形にしてある。

function encodeBase64(input: string): string {
  const bytes = new TextEncoder().encode(input)
  let binary = ''

  bytes.forEach((byte) => {
    binary += String.fromCharCode(byte)
  })

  return btoa(binary)
}

String.fromCharCode(...bytes)と一発で書きたくなるところだが、こちらはスプレッドが引数リストになるので入力が大きいと落ちる。手元で長さを変えながら試したら、125,000バイトは通って130,000バイトでRangeError: Maximum call stack size exceededになった。上限は環境とスタックの状況次第で動くので、そもそも引数に積まないほうがいい1。

復号側、atobは思ったより寛容

atobはbtoaの逆をやるだけなので、返ってくるのはbinary string、つまり「1文字1バイトのつもりの文字列」だ。ここからUint8Arrayに詰め直してTextDecoderに渡すことになる。

で、このatobが意外と何でも通す。

入力結果
44GCe3 81 82
44GC\ne3 81 82(空白は捨てられる)
4 4 G Ce3 81 82
44Ge3 81(パディング無しでも通る)
QQ==41
QQ=InvalidCharacterError
44GC==InvalidCharacterError

パディングが足りないのは許すのに、多すぎたり半端だったりすると弾く。この「足りないぶんには通る」が後で効いてくる。

途中で切れたBase64は、Base64としては正しい

あいうえおをBase64にすると44GC44GE44GG44GI44GKになる。これを先頭から1文字ずつ切ってatobに通してみた。

切った長さatobが返すバイト列fatal無しのデコード
4e3 81 82あ
5InvalidCharacterError—
6e3 81 82 e3あ�
7e3 81 82 e3 81あ�
8e3 81 82 e3 81 84あい
9InvalidCharacterError—

弾かれるのは長さを4で割った余りが1のときだけで、残りの4分の3は通る。つまりコピペで末尾が欠けたBase64は、たいていBase64としては受理されてしまう。壊れているのはBase64ではなくその中身のUTF-8なので、atobの責任範囲の外なのだ。

fatalを立てるかどうかがここで効く

TextDecoderは既定だと不正なバイト列をU+FFFD(REPLACEMENT CHARACTER)に置き換えて、何事もなかったように文字列を返す。壊れたデータを壊れたまま通してしまうので、変換ツールとしては困る。

fatal: trueを立てると、代わりにTypeError: The encoded data was not valid for encoding utf-8を投げてくれる。ツールの復号はこの形にしてある。

function decodeBase64(input: string): DecodeResult {
  try {
    const binary = atob(input)
    const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0))

    return { ok: true, value: new TextDecoder('utf-8', { fatal: true }).decode(bytes) }
  } catch {
    return { ok: false, error: 'Base64として、あるいは中身のUTF-8として読み取れない。' }
  }
}

atobの失敗もTextDecoderの失敗も同じcatchで受けている。利用者から見れば「このBase64は読めない」で同じことなので、区別せずに1本のメッセージにした。

どのくらい弾けるのか

fatal: trueがどれだけ仕事をするのか気になったので、短いバイト列を総当たりしてUTF-8として妥当な割合を数えてみた。

長さUTF-8として妥当割合
1バイト128 / 25650.0%
2バイト18,304 / 65,53627.9%
3バイト2,650,112 / 16,777,21615.8%

長くなるほど妥当な組み合わせは相対的に減っていく。3バイトなら、適当なバイト列を投げ込んだ8割方はfatalが捕まえてくれる計算になる。

ただし、すり抜けるものはある。7Zevはed 97 afで、これはU+D5EFの헯として妥当なUTF-8だ。fatalでも通る。バイト列として妥当かどうかしか見ていない以上、これは検出のしようがない。

同じ理由で、U+FFFDそのものはUTF-8のef bf bdという正当な3バイトなので、「U+FFFDが出てきたら壊れている」という判定にも寄りかかれない。壊れた入力を弾きたいなら、置換文字を後から探すのではなく最初からfatalを立てておくしかない。

btoaとatobが扱っているのは最初から文字ではなくバイトで、そこにLatin-1の皮をかぶせているだけだった。日本語で落ちるのは親切なほうで、caféのように落ちないまま別のバイト列になるほうが厄介、という順に厄介さが並んでいる。符号化はTextEncoderを通してから渡し、復号はatobが通したあとにfatal: trueでもう一度疑う。

なおUint8Array.fromBase64とUint8Array.prototype.toBase64という提案が進んでいて、これが行き渡ればbinary stringを経由する必要はなくなる。手元のNode 24.19.0では両方ともundefinedだったので、まだしばらくは上の手順のままでいる。

Footnotes

  1. 昔の定番だったbtoa(unescape(encodeURIComponent(s)))でも同じBase64は得られる(実測で3手とも44GCY2Fmw6nwn5iAで一致した)。ただしunescapeはAnnex Bの遺物だし、ペアになっていないサロゲートを渡すとURIError: URI malformedで落ちる。TextEncoderはそこをU+FFFDのef bf bdに置き換えて通す。 ↩

← 一覧へ戻る