幅のチェックが漢字を壊した

名前をターミナルの表に入力したところ、文字化けして出力された。名字は「𠮷田」だった。

最初の文字は一般的な「吉」ではない。「𠮷」(U+20BB7) である。これは実際の日本の名字に使われる珍しい字体だ。表は列の幅に収めるためにセルを切り詰めた(truncate)。その結果、名前ではなく文字化けした文字が表示された。漢字が半分に割れてしまったのだ。

バグは、たった一行のショートカットの中に潜んでいた。コードは実際に切り詰める前に、インデックスで文字列を切り取っても安全かどうかを判断していた。このロジックは、JavaScriptの文字列の扱い方によって失敗した。

JavaScriptの文字列には、3つの異なる「長さ」がある:

  • コードユニット長: "𠮷".length は 2。これはUTF-16ユニットをカウントする。
  • コードポイント数: [..."𠮷"].length は 1。これは実際の文字数をカウントする。
  • 表示幅: ターミナルで占有する列数は 2。

通常の英語テキストでは、これらの数値は一致する。"abc" は3ユニット、3ポイント、3列である。ほとんどのコードは、この偶然の一致をルールだと想定している。

文字「𠮷」はそのルールを破る。これは2つのコードユニットを持ち、2列を占有する。数値は一致しているが、その理由は異なる。コードは「2は2である」と判断し、インデックスによる文字列の切り出しという高速なパス(fast path)を使用した。

インデックス3で文字列を切り取ったとき、最初の文字は完全に取り出されたが、2番目の文字は半分しか取り出されなかった。これにより、孤立したサロゲート(lone surrogate)が残された。ターミナルでは、これが文字化けした四角として表示される。

「漢」のような一般的な漢字は安全だ。これらは1つのコードユニットで2列を占有する。1は2ではないため、コードは問題のあるショートカットを回避する。このバグは、珍しい文字や絵文字に対してのみ発生する。

これを修正するには、以下の対応が必要である:

  • 高位サロゲート(high surrogates)を含む文字列を拒否するように、高速パスをガードする。
  • コードユニットではなく、コードポイント単位でトリミングを行う。

Array.from(str) を使用すれば、コードポイントごとに反復処理が行われるため、この問題は解決する。これにより、文字を一つの完全なユニットとして扱うことができる。

教訓は単純だ。ある単位で計測し、別の単位で切り取ってはならない。表示幅を計測しながらコードユニットのインデックスで切り取ると、ユーザーのデータを壊してしまうことになる。

珍しいCJK文字や絵文字を使ってコードをテストすること。ASCII文字ではこれらのエラーは現れない。コードが「恐れている」ような入力を与える必要がある。

Source: https://dev.to/greymothjp/a-width-check-said-the-string-was-safe-to-cut-it-split-a-kanji-in-half-4hjk

Optional learning community: https://greymoth-jp.github.io/cjk-failure-corpus/