Rubyのエンコーディングその2

この前「Rubyのエンコーディング」という記事を書いたのですが、それをネタに 8/25 の NSEG で発表しました。

この中で、エンコーディングが原因で予期しないところで落ちてしまうことが結構あるという話もしたんですが、今回はプログラムが落ちないようにするにはどうすればいいかを考えてみます。

エンコーディングが原因で落ちてしまうのは大体次のパターンのようです。

  • 文字列中に不正な文字が含まれている

文字列や正規表現エンコーディングが異なる

正規表現リテラルで生成していれば、エンコーディングは敢えて指定しない限りは普通はスクリプトエンコーディングになってると思うので、問題は文字列の方です。

特にファイルから読み込んだ文字列のエンコーディングが何になっているかに注意しましょう。

読み込み時の外部エンコーディングを指定しないと、プログラムの外部(オプションやロケール)によって変更され得るので、ファイルオープン時に指定しておいた方がいいでしょう。プログラムの最初の方で Encoding.default_external を設定しておくのもいいと思います。

読み込み時はメソッドによって指定したエンコーディングではなく ASCII-8BIT になっていることがあるので注意が必要です。

f = File.open('nanika.txt', 'r:utf-8')
f.gets      #=> UTF-8 文字列
f.read(10)  #=> ASCII-8BIT 文字列

内部エンコーディングがあると読み込み時に自動的にエンコーディングを変更されますが、その時にエラーになってしまうことがあるので、内部エンコーディングは指定しない方がいいと個人的には思ってます。

f = File.open('/dev/urandom', 'r:cp932:utf-8')
f.gets  #=> Encoding::InvalidByteSequenceError

[追記] 自動変換でエラーにしたくない場合はファイルオープン時に String#encode と同じオプションを指定できます。

f = File.open('/dev/urandom', 'r:cp932:utf-8', :invalid=>:replace, :undef=>:replace)
f.gets

Socket はインスタンス生成時にエンコーディングを指定することはできません。Encoding.default_external の影響は受けず、常に ASCII-8BIT になっています。なので Socket から読み込んだ場合は gets 等のテキスト読み込みメソッドを使用しても ASCII-8BIT エンコーディングです。

require 'socket'
Encoding.default_external = 'utf-8'
sock = TCPSocket.new('localhost', 25)
sock.gets.encoding  #=> #<Encoding:ASCII-8BIT>

通信データのエンコーディングが特定できるのであれば、Socket インスタンス生成後に外部エンコーディングを指定するのもいいかもしれません。

require 'socket'
sock = TCPSocket.new('localhost', 25)
sock.set_encoding 'utf-8'
sock.gets.encoding  #=> #<Encoding:UTF-8>

マニアックなところでは、IO.sysread はバイナリ読み込みメソッドなので、読み込んだ文字列は通常は外部エンコーディングに関わらず ASCII-8BIT になるのですが、第二引数でバッファ文字列を指定した場合は、その文字列のエンコーディングになります。

# coding: utf-8
f = File.open('/dev/urandom', 'r:utf-8')
buf = ''
p f.sysread(10).encoding       #=> #<Encoding:ASCII-8BIT>
p f.sysread(10, buf).encoding  #=> #<Encoding:UTF-8>

文字列中に不正な文字が含まれている

エンコーディングが意図したものになっていたとしても、文字列中にそのエンコーディングとして不正な文字が含まれている場合があります。

String#valid_encoding? を使えば、文字列中に不正な文字が含まれていないかどうかを判定することができます。

文字列リテラルで "\xFF" とか書いたりすると簡単に不正な文字を含む文字列を作れます。

# coding: utf-8
s = "\xFF"
s.valid_encoding?  #=> false

リテラルでわざわざそのようなことをする人はいないとは思いますが、ファイルから読み込んだ文字列は、外部エンコーディングで指定したエンコーディングになっていても、文字列の中身が正当かどうかはわかりません。

s = File.open("/dev/urandom", "r:utf-8").gets
s.encoding         #=> #<Encoding:UTF-8>
s.valid_encoding?  #=> false

また、なんらかの都合で本来のものと異なったエンコーディングになってしまっている文字列のエンコーディングを指定したい場合には String#force_encoding を使用しますが、この場合も不正な文字が入り得ます。

不正な文字が含まれていた場合は、入力エラーにするか、不正な文字を「?」等に置換して無害化するかのどちらかで対処することになるでしょう。

入力エラーにする場合は String#valid_encoding? で判定して適切な例外をあげればよいでしょう。

問題は不正な文字を別の文字に置換したい場合です。

異なるエンコーディングであれば、String#encode でエンコーディングを変換する時にオプションに :invalid=>:replace, :replace=>'?' を指定すれば不正な文字を置換できます。

# coding: utf-8
s = "\xFF"
s2 = s.encode('cp932', :invalid=>:replace, :replace=>'?')
s2   #=> CP932 で "?"

ですが、同一エンコーディングに対しては変換処理が行われないので、不正文字の置換も行われません。

# coding: utf-8
s = "\xFF"
s2 = s.encode('utf-8', :invalid=>:replace, :replace=>'?')
s2   #=> UTF-8 で "\xFF" のまま

Iconv は同じエンコーディングを指定しても変換処理を行ってくれたので、不正文字を置換することもできたのですが、String#encode や Encoding::Converter ではそれはできません。

最適な解かどうかはわかりませんが、該当のエンコーディングに含まれる文字を包含する別のエンコーディングを介して変換することで置換が可能です。

UTF-8 以外であれば UTF-8 を介して、UTF-8 であれば UTF-16 を介して変換すればいいと思います。

# coding: utf-8
s = "\xFF"
s2 = s.encode('utf-16be', :invalid=>:replace, :replace=>'?').encode('utf-8')
s2   #=> UTF-8 で "?"

その他

今はもう使っている人はあまりいないかもしれませんが、CGI プログラムを作る際に便利な cgi.rb というライブラリがあります。

CGI.new でクライアントから渡されたパラメータをパースするのですが、その際に指定したエンコーディングとして不正な文字があるとエラーになってしまいます。

require 'cgi'
cgi = CGI.new(:accept_charset => 'utf-8')
...

上記の CGI プログラムに対して GET /cgi-bin/hoge?a=%FF のようなリクエストを発行すると、CGI.new が CGI::InvalidEncoding 例外になります。

CGI.new で例外が発生し得るということを意識してプログラムを書いた方がいいでしょう。

require 'cgi'
begin
  cgi = CGI.new(:accept_charset => 'utf-8')
rescue CGI::InvalidEncoding
  # パラメータのエラー処理
end
...

または、どんな文字が入力されてもエラーにならない ASCII-8BIT で一旦受けて、自前で本来のエンコーディングに変換するのもいいかもしれません。

require 'cgi'
cgi = CGI.new(:accept_charset => 'ascii-8bit')
params = {}
cgi.params.each do |name, values|
  params[name] = values.map do |value|
    value.force_encoding('utf-8').
      encode('utf-16be', :invalid=>:replace, :replace=>'?').
      encode('utf-8')
  end
end
...

このように使用するライブラリが外部から入力されたデータを扱っている場合は、エンコーディングをどのように扱っているか調べておいた方が良いでしょう。

Rack や Rails で不正な文字列がどのように扱われているのかは調べてません。

テスト

エンコーディングが異なっている文字列同士や文字列と正規表現の比較はエラーになるのですが、ASCII 互換エンコーディングでかつ ASCII 文字だけで構成されている場合はエラーになりません。

u = 'ABC'.force_encoding('utf-8')
re = /ABC/s
p u =~ re

つまり、テスト時に ASCII 文字だけでテストしていたらエラーを検出できません。非ASCII 文字が入りうる文字列はちゃんと非ASCII文字でもテストしておきましょう。

まとめ

Ruby 1.9エンコーディングまわりでプログラムが異常終了してしまわないようにする方法を考えてみました。

上であげたような方法以外にも良い方法があったら教えてください。

「不正な文字が入力された時には、どうせ不正なんだしプログラムが落ちてしまってもいいじゃん」…というのも、もしかしたら潔くて良いかもしれません。