自分の手元の環境でこんなことが起きた。
$ ruby -v ruby 3.1.2p20 (2022-04-12 revision 4491bb740a) [arm64-darwin21]
$ irb irb(main):001:0> "\x01\x80\x00\x00".index("\x01") => 0 irb(main):002:0> "\x01\x80\x00\x00".rindex("\x01") => 1
\x01
は 0
番目にしかないのだから、 .index
でも .rindex
でも 0
が返ってくるはずではないの??
先に結論
バイナリデータを扱うときには必ずEncodingを ASCII-8BIT
に指定しておくこと!
きっかけ
roo
というgem (記事時点で 2.9.0
)を使って、Excelファイルを開こうとした。
require 'roo' p Roo::Excelx.new("hoge.xlsx")
これは問題ないが、 #initialize
の引数は file_or_strem
ということでファイルを open
したものを渡しても良いはず、と
require 'roo' File.open("hoge.xlsx") do |f| p Roo::Excelx.new(f) end
ということをすると以下のような謎のエラーを吐いて落ちる。
/Users/sugyan/.rbenv/versions/3.1.2/lib/ruby/gems/3.1.0/gems/rubyzip-2.3.2/lib/zip/entry.rb:365:in `check_c_dir_entry_static_header_length': undefined method `bytesize' for nil:NilClass (NoMethodError) return if buf.bytesize == ::Zip::CDIR_ENTRY_STATIC_HEADER_LENGTH ^^^^^^^^^
で困っていたので他の人に聞いてみたところ「自分のところではそんな問題は起きていない」と言われ。 試しにDockerでRuby環境作って実行してみると、確かに問題なく動く…何故? と調べはじめた。
String#rindex の謎挙動
自分の環境と問題なく動く環境とで比較しながら見てみると、roo
が使っている rubyzip
(記事時点で 2.3.2
) の中でファイル内容を読み取った結果の値が違っていた。
module Zip class CentralDirectory include Enumerable END_OF_CDS = 0x06054b50 ... def get_e_o_c_d(buf) #:nodoc: sig_index = buf.rindex([END_OF_CDS].pack('V')) ...
buf
の中から 0x06054b50
をpackしたもの つまり "PK\x05\x06"
のデータを末尾から探すために .rindex()
を呼んでいるのだが、手元の環境と 問題なく動く環境ではその値が 1
ズレている。。
何故?と思って色々試しているうちに冒頭の例のようなものに辿り着いた。
もう少し深く追う
少なくとも \x80
などを含まないASCIIだけのものであればおかしな挙動にはならない。
irb(main):001:0> "\x01\x7F\x00\x00".rindex("\x01") => 0
また、これが増えるとズレもどんどん大きくなる。
irb(main):001:0> "\x01\x80".rindex("\x01") => 0 irb(main):002:0> "\x01\x80\x80".rindex("\x01") => 1 irb(main):003:0> "\x01\x80\x80\x80".rindex("\x01") => 2 irb(main):004:0> "\x01\x80\x80\x80\x80".rindex("\x01") => 3
逆から走査する際に異常な文字が検出された際に読み飛ばし、その結果の走査数を文字列長から引いたものを返したことにより値がおかしくなる、というのが予想される。
ソースを読んでみる。 rstring
関連はこのへんのようだ。
#ifdef HAVE_MEMRCHR
によって実装が分かれている。
memrchr
というのがglibcに入っているもので、自分のmacOS環境では使えなくて Docker内のLinux環境などでは使える、これによって環境によって挙動が変わったりする、のかもしれない。
で、使えない環境の方での実装は。
対象が見つかるまで while
loop の中で rb_enc_prev_char
で前の文字を走査している、という感じのようだ。encodingの影響を受けそう…?
while (s) { if (memcmp(s, t, slen) == 0) { return pos; } if (pos == 0) break; pos--; s = rb_enc_prev_char(sbeg, s, e, enc); }
Encodingと実行環境
Encodingが関係しているようだったので色々調べてみた。
Rubyではバイナリデータ(バイト列)も String
で扱う。
バイナリの取扱い
Ruby の String は、文字の列を扱うためだけでなく、バイトの列を扱うためにも使われます。しかし、Ruby M17N には直接にバイナリを表すエンコーディングは存在しません。このため、バイナリを String で扱う際には、ASCII 互換オクテット列を意味する
ASCII-8BIT
を用います。これにより、ASCII 互換であるこの String は 7bit クリーンな文字列と比較・結合が可能となります。
https://docs.ruby-lang.org/ja/latest/doc/spec=2fm17n.html
ということで、バイナリの場合は本来は ASCII-8BIT
のEncodingを持つ文字列として検索しなければならないのに、手元の環境では UTF-8
のままで検索しようとしていた、というところに「使い方の問題」があったようだ。
irb(main):001:0> "\x01\x80\x00\x00".encoding => #<Encoding:UTF-8>
Encodingが ASCII-8BIT
になっていれば、rindex
でも正しい値を得ることができそうだ。
全然知らなかったが String#b
で ASCII-8BIT
の複製を得ることもできるらしい。
https://docs.ruby-lang.org/ja/latest/method/String/i/b.html
irb(main):001:0> "\x01\x80\x00\x00".rindex("\x01") => 1 irb(main):002:0> "\x01\x80\x00\x00".force_encoding(Encoding::ASCII_8BIT).rindex("\x01") => 0 irb(main):003:0> "\x01\x80\x00\x00".b.rindex("\x01") => 0
なるほど〜。
ではこの Encoding は何で決まるのか?というのも上記リンクに書いてある。
文字列リテラル、正規表現リテラルそしてシンボルリテラルから生成されるオブジェクトのエンコーディングはスクリプトエンコーディングになります。
またスクリプトエンコーディングが US-ASCII である場合、7bit クリーンではないバックスラッシュ記法で表記されたリテラルのエンコーディングは ASCII-8BIT になります。
さらに Unicode エスケープ (\uXXXX) を含む場合、リテラルのエンコーディングは UTF-8 になります。
複雑。。。
とにかく通常はスクリプトエンコーディングがまず大事のようだ。
スクリプトエンコーディングとは Ruby スクリプトを書くのに使われているエンコーディングです。スクリプトエンコーディングは マジックコメントを用いて指定します。スクリプトエンコーディングには ASCII 互換エンコーディングを用いることができます。 ASCII 非互換のエンコーディングや、ダミーエンコーディングは用いることができません。
さらに、magic commentによってこのスクリプトエンコーディングを決定でき、それが無い場合の挙動も書かれている。
マジックコメントが指定されなかった場合、コマンド引数 -K, RUBYOPT およびファイルの shebang からスクリプトエンコーディングは以下のように決定されます。左が優先です。
magic comment(最優先) > -K > RUBYOPTの-K > shebang
上のどれもが指定されていない場合、通常のスクリプトなら UTF-8、-e や stdin から実行されたものなら locale がスクリプトエンコーディングになります。 -K オプションが複数指定されていた場合は、後のものが優先されます。
通常のスクリプトか -e
かどうか、でも変わったりするのね…。そして特に指定ない場合は最終的にはlocaleが使われる。
ので、 LC_CTYPE
などでも挙動が変わり得るようだ。
$ ruby -e 's="\x01\x80\x00\x00"; p __ENCODING__, s.encoding, s.rindex("\x01")' #<Encoding:UTF-8> #<Encoding:UTF-8> 1 $ ruby -Kn -e 's="\x01\x80\x00\x00"; p __ENCODING__, s.encoding, s.rindex("\x01")' #<Encoding:ASCII-8BIT> #<Encoding:ASCII-8BIT> 0 $ RUBYOPT="-Kn" ruby -e 's="\x01\x80\x00\x00"; p __ENCODING__, s.encoding, s.rindex("\x01")' #<Encoding:ASCII-8BIT> #<Encoding:ASCII-8BIT> 0 $ LC_CTYPE=C ruby -e 's="\x01\x80\x00\x00"; p __ENCODING__, s.encoding, s.rindex("\x01")' #<Encoding:US-ASCII> #<Encoding:ASCII-8BIT> 0
という具合に、何もしないと UTF-8
文字列として扱ってしまうが、オプションや環境変数によって リテラルの Encoding を変えることもできる。
こうやって正しく ASCII-8BIT
のEncodingを持つ文字列として検索すれば、 String#rindex
の値がズレるということはなさそうだ。
つまり再現条件は
手元の環境で String#rindex
の値がズレたのは2つの要因があって
memrchr
が使えない環境でビルドしたRubyで実行していてASCII-8BIT
でないEncodingの文字列に対してrindex
をかけていた
ということになる。2つ揃っていないと起きないものと思われる。
Rooの問題
前述の、Rooでエラーが起きる件について考える。
そもそもEncodingが不明で渡ってくるものに対して安易に rindex
を使うものではない、ということで rubyzip
側に落ち度がありそうではある。が 現在の master branch では リリースされている 2.3.2
とは大きく変わっていて、もう同じことは起きないのかもしれない。詳しくは追っていないので分からないが、今回のと関連するissueがあった。
既にCloseされているが、今回調べた Zip::CentralDirectory
は直接使用するものではなく Zip::File
を使ってください、ということのようだ。
つまりこの場合、 Roo 側の使い方が悪い、ということになりそう。 で、Rooの方も調べてみると ちゃんとそれに対応しようとしていると思われる修正があった。
これが取り込まれれば手元の環境でも問題なく動くようになるかな…?
もしくは現時点でも、使う側が渡す引数のEncodingを明示的に指定してやることで一応回避できそうではある。
require 'roo' File.open("hoge.xlsx", "r:ASCII-8BIT") do |f| p Roo::Excelx.new(f) end
Rubyのバグではないの?
しかし Encodingを正しく指定していなかったために起きているとしても、 String#index
も String#rindex
も検索した対象の開始indexを返すものであるはずなのだから、それがズレるのはおかしいのではないのか…?という気はする。
さらに memrchr
が使えるか否かのビルド環境によってだけで結果が変わる(ちゃんと確かめてないけど おそらくそう…)、というのも気持ち悪い。
かなり昔から現在の実装になっているようだし 今からでは挙動を変えづらそうではある。 仕様といってしまえばそれまでだけど…。 このあたりはRuby開発陣の方々の見解も聞いてみたいところではあります。
3.2
ちなみに Ruby 3.2 からは String#byteindex
と String#byterindex
が追加されるそうです。今回のようなバイナリデータに対する検索にピッタリ使えそうですね。