UTF-8 validationとmruby/c

これは mrubyファミリー Advent Calendar 2023 の2日目の記事です。

こんにちは。ima1zumiです。

私はmruby/cでUTF-8を使えるように実装しています。そのなかでRubyString#valid_encoding みたいな機能を実装しているのでその背景とコードについて書きます。

mruby/c についての説明は昨日のはすみさんの記事がわかりやすかったので、そちらをご覧ください。

しまもん | はすみきん | mrubyファミリーの歩き方(を装ったビルドシステムの話)

現在のmruby/cの文字コード

現在のmruby/cの文字コードはCRubyでいうASCII-8BITのみ使える状態です。文字をバイナリとして格納しているので、どんな文字でも入れられます。ただし、文字数を取るようなメソッドではバイト単位で判定されます。例えば "あ" のようにUTF-8でマルチバイト文字を作ると "あ".size # => 3 となります。

これはUTF-8での \xE3\x81\x82 で3バイトあるため3文字と判定されています。その他にもString#indexなどが文字単位ではなくバイト単位で動いてしまいます。

UTF-8を使えるようにする

というわけでUTF-8を使えるように実装しているのですが、一つ問題がありました。mruby/cはなるべく軽量に作りたいので、Encodingクラスを作りたくありません。そのためビルドオプションでStringのEncodingをASCII-8BIT相当のものか、UTF-8かを切り替える方針にしようとしています。

しかしUTF-8にはinvalidなバイト列があります。たとえば以下のように、 \xE3\x80\x80 はvalidですが、 \xE3\x80\x7F はinvalidです。

String.new("\xE3\x80\x80", encoding: Encoding::UTF_8).valid_encoding?
# => true
String.new("\xE3\x80\x7F", encoding: Encoding::UTF_8).valid_encoding?
# => false

UTF-8にはこのバイトで始まった場合続くバイトはこれしか取れない、というルールがあります。 \xE3\x80\x7F はそれに違反しているためinvalidな文字列です。

仕様はこちらに書いてあります。

Table 3-7. Well-Formed UTF-8 Byte Sequences

https://www.unicode.org/versions/Unicode15.1.0/ch03.pdf

CRubyでは違反した場合文字列を作成できないか、invalidな文字列だというフラグが立つようになっています。しかしビルドオプションでEncodingを切り替えるmruby/cでは、その設定だとUTF-8を有効にしたときにバイト列をStringで作れなくなってしまい困ります。セキュリティ上invalidな文字列を扱うのはよくないのですが、mruby/cではワンチップマイコン向けの言語ということもあり、

ということで、Stringはどんな文字列でも作成できるが、valid_encoding?でvalidかどうか検査できるようにします。

実装

素朴にTable 3-7を実装しました。ビット演算でできるところはビット演算にして、命令を減らすようにしています。

ちなみにUTF-8 validationはいろいろ高速化の方法が研究されている*1ようで、これはあまり最適ではありません。

static void c_string_valid_encoding(struct VM *vm, mrbc_value v[], int argc)
{
#if MRBC_USE_STRING_UTF8
  unsigned char *str = (unsigned char *)mrbc_string_cstr(&v[0]);
  int len = mrbc_string_size(&v[0]);
  int i = 0;

  if (len == 0) {
    SET_TRUE_RETURN();
    return;
  }

  while (i < len) {
    // 1 byte
    if (str[i] <= 0x7F) {
      i++;
    // 2 bytes
    // First byte: C0..DF
    // Second byte: 80..BF
    } else if ((0xC2 <= str[i]) && (str[i] <= 0xDF) && (str[i+1] & 0xC0) == 0x80) {
      i += 2;
    // 3 bytes
    // First byte:  E0
    // Second byte: A0..BF
    // Third byte:  80..BF
    } else if ((str[i] == 0xE0) && (str[i+1] & 0xE0) == 0xA0 && (str[i+2] & 0xC0) == 0x80) {
      i += 3;
    // First byte:  E1..EC
    // Second byte: 80..BF
    // Third byte:  80..BF
    } else if (0xE1 <= str[i] && str[i] <= 0xEC && (str[i+1] & 0xC0) == 0x80 && (str[i+2] & 0xC0) == 0x80) {
      i += 3;
    // First byte: ED
    // Second byte: 80..9F
    // Third byte: 80..BF
    } else if ((str[i] == 0xED) && ((str[i+1] & 0xE0) == 0x80) && (str[i+2] & 0xC0) == 0x80) {
      i += 3;
    // First byte:  EE..EF
    // Second byte: 80..BF
    // Third byte:  80..BF
    } else if ((str[i] == 0xEE || str[i] == 0xEF) && (str[i+1] & 0xC0) == 0x80 && (str[i+2] & 0xC0) == 0x80) {
      i += 3;
    // 4 bytes
    // First byte:  F0
    // Second byte: 90..BF
    // Third byte:  80..BF
    // Fourth byte: 80..BF
    } else if ((str[i] == 0xF0) && (0x90 <= str[i+1]) && (str[i+1] <= 0xBF) && (str[i+2] & 0xC0) == 0x80 && (str[i+3] & 0xC0) == 0x80) {
      i += 4;
    // First byte:  F1..F3
    // Second byte: 80..BF
    // Third byte:  80..BF
    // Fourth byte: 80..BF
    } else if ((0xF1 <= str[i]) && (str[i] <= 0xF3) && (str[i+1] & 0xC0) == 0x80 && (str[i+2] & 0xC0) == 0x80 && (str[i+3] & 0xC0) == 0x80) {
      i += 4;
    // First byte:  F4
    // Second byte: 80..8F
    // Third byte:  80..BF
    // Fourth byte: 80..BF
    } else if ((str[i] == 0xF4) && (str[i+1] & 0xF0) == 0x80 && (str[i+2] & 0xC0) == 0x80 && (str[i+3] & 0xC0) == 0x80) {
      i += 4;
    } else {
      SET_FALSE_RETURN();
      return;
    }
  }
  SET_TRUE_RETURN();
#else
  SET_TRUE_RETURN();
#endif
}

3日目は執筆者募集中です!