Ruby の YAML.load が非互換になる(かもしれない)

タイトルは釣りっぽいんですが Psych v4.0.0 で『 Psych.loadPsych.safe_load を使用するようになった』事で普段利用している YAML.loadYAML.load_file が今後非互換になってしまう、という話です。
この変更により今まで読み込むことができていた YAML ファイルが今後読み込みエラーになる可能性があります。
先にまとめだけ書いておくと

  • Psych v4.0.0 で YAML.load が非互換になる
  • なので既存の YAML データが読み込めずにエラーになる可能性がある
  • もし急に YAML データが読み込めなくなったら Psych のバージョンを確認しよう
  • 回避する場合は YAML.unsafe_load などが利用できる

また、この記事の内容は記事を書いた当時の話なので今後変わっているかもしれないので注意してください。

NOTE: これは Ruby 本体の話というよりは『Psych という gem が非互換になった』という話なので使用する Ruby のバージョン云々というよりかは『どの Psych のバージョンを使用するのか』という話になってきます。

Psych って何?

PsychRuby 本体の default gem で YAML ライブラリのバックエンド実装になります。
YAML 自体が Psych に依存する形で実装されています。

require "yaml"

# YAML.laod は実際には Psyche.load を呼び出している
pp YAML.method(:load)
# => #<Method: Psych.load(yaml, permitted_classes: ..., permitted_symbols: ..., aliases: ..., filename: ..., fallback: ..., symbolize_names: ..., freeze: ...) /home/worker/.rbenv/versions/3.0.0/lib/ruby/gems/3.0.0/gems/psych-4.0.0/lib/psych.rb:368>

# そもそも YAML 自体が Psych になっている
pp YAML
# => Psych

なので本記事で書かれている Psych.load などは実質 YAML.load のことを指していることになります。

NOTE: default gem とは Ruby 本体に同梱されている Ruby のオフィシャルな gem です。
Ruby 本体とは別に管理されており、gem 単体でバージョンアップしたり古いバージョンを使用することができます。

Psych v4.0.0 で何が変わったのか

Psych.load が内部で Psych.safe_load を使用するように変更されました。
変更された背景としては Psych.load は安全ではなく脆弱性につながるのでデフォルトで Psych.safe_load を使用するようにしよう、みたいな話っぽいです。
また、既存の Psych.loadPsych.unsafe_load という名前のメソッドで使用することができます。
実際に変更が行われた PR で議論などがされていたので気になる方はそちらを参照ください。


NOTE: Psych.safe_load は以前から存在している『安全に YAML データを読み込むため』のメソッドです。

Psych.loadPsych.safe_load の違いって?

Psych.safe_loadPsych.load よりもより安全に YAML データを読み込むためのメソッドです。
デフォルトでは以下のクラスのオブジェクトしか変換しません。

  • TrueClass
  • FalseClass
  • NilClass
  • Numeric
  • String
  • Array
  • Hash
require "psych"
require "date"

pp Psych::VERSION  # => "3.3.0"


# String は問題ない
pp Psych.safe_load("name: homu")
# => {"name"=>"homu"}

# Date は変換できずにエラーになる
# error: `find': Tried to load unspecified class: Date (Psych::DisallowedClass)
pp Psych.safe_load("date: 2020-01-01")


# .load であれば問題ない
pp Psych.load("date: 2020-01-01")
# => {"date"=>#<Date: 2020-01-01 ((2458850j,0s,0n),+0s,2299161j)>}

これは明示的に『変換を許容するクラス』を第2引数や permitted_classes: キーワード引数に渡すことで緩和することができます。

require "psych"
require "date"

pp Psych::VERSION  # => "3.3.0"


# 明示的に Date を指定することで変換できる
pp Psych.safe_load("date: 2020-01-01", [Date])
# => {"date"=>#<Date: 2020-01-01 ((2458850j,0s,0n),+0s,2299161j)>}

# Psych v3.1.0 (Ruby 2.6)以降であればキーワード引数で渡せる
pp Psych.safe_load("date: 2020-01-01", permitted_classes: [Date])

また、 &default のようなエイリアスもデフォルトでは許可されていません。

require "psych"
require "date"

pp Psych::VERSION  # => "3.3.0"

data = <<~EOS
default: &default
  aaa: aaa
  bbb: bbb
development:
  <<: *default
test:
  <<: *default
production:
  <<: *default
EOS

# OK: 読み込める
pp Psych.load(data)
# => {"default"=>{"aaa"=>"aaa", "bbb"=>"bbb"},
#     "development"=>{"aaa"=>"aaa", "bbb"=>"bbb"},
#     "test"=>{"aaa"=>"aaa", "bbb"=>"bbb"},
#     "production"=>{"aaa"=>"aaa", "bbb"=>"bbb"}}

# NG: 読み込めない
# error: Unknown alias: default (Psych::BadAlias)
pp Psych.safe_load(data)

これは Psych.safe_load(data) の第4引数や aliases: true を渡すことで許容する事ができます。

require "psych"
require "date"

pp Psych::VERSION  # => "3.3.0"

data = <<~EOS
default: &default
  aaa: aaa
  bbb: bbb
development:
  <<: *default
test:
  <<: *default
production:
  <<: *default
EOS


# OK: 読み込める
pp Psych.safe_load(data, [], [], true)

# Psych v3.1.0 (Ruby 2.6)以降であればキーワード引数で渡せる
pp Psych.safe_load(data, aliases: true)

このような違いが Psych.loadPsych.safe_load にはあるため Psych.loadPsych.safe_load を使用するようになると『今まで読み込めていた YAML データが読み込めなくなってしまう』可能性があります。
実際に先程のコードを Psych v4.0.0 で使用するとエラーになります。

require "psych"
require "date"

pp Psych::VERSION  # => "4.0.0"

# Date は変換できずにエラーになる
# error: `find': Tried to load unspecified class: Date (Psych::DisallowedClass)
pp Psych.load("date: 2020-01-01")
require "psych"
require "date"

pp Psych::VERSION  # => "4.0.0"

data = <<~EOS
default: &default
  aaa: aaa
  bbb: bbb
development:
  <<: *default
test:
  <<: *default
production:
  <<: *default
EOS

# 読み込めない
# error: Unknown alias: default (Psych::BadAlias)
pp Psych.load(data)

NOTE: Psych v3.1.0 (Ruby 2.6)以降であれば Psych.safe_loadpermitted_classesaliases などキーワード引数で渡せるようになりました。
また、以前のオプション引数は v4.0.0 から『削除された』ので注意する必要があります。
基本的にはキーワード引数を使っておけば大丈夫そう。

どういう影響があるのか

これに関しては bugs.ruby に以下のチケットができていました。

このチケットでは先程書いたエイリアスに関する非互換な変更が問題視されているみたいですね。
実際に Rails や Rubocop でもこの変更に対する対応がすでに行われているようです。

余談:エイリアスはなぜ安全ではない?

件の bugs.ruby に載っていたんですが例えばエイリアスを使うと次のように再帰的なデータ構造を定義することができます。

require "psych"
require "date"

pp Psych::VERSION  # => "3.3.0"

input = <<~EOS
default: &default
  - *default
EOS

# 再帰的なデータ構造になる
result = Psych.load(input)
pp result
# => {"default"=>[[...]]}

# データが永続的に続く
pp result["default"][0][0][0][0]

このようなデータを次のように再帰的に読み込もうとするとクラッシュしてしまします。

require "psych"
require "date"

pp Psych::VERSION  # => "3.3.0"

def process thing
  thing.each do |item|
    case item
    when Array
      process item
    when Hash
      # ...
    when String
      # ...
    end
  end
end

input = <<~EOS
default: &default
  - *default
EOS

# error: stack level too deep (SystemStackError)
process Psych.load(input)

このような問題があるため Psych.safe_load ではエイリアスが許可されていません。

この変更に対する対処方法

以前と同じように YAML データを読み込む対象方法として以下の2つがあります。

1. バージョン指定して古いバージョンを使用する

Psych 自体は gem なのでそれ単体でバージョンを指定して使用する事ができます。
意図的に v4.0.0 未満を使用したい場合は以下のように Gemfile に書くなどすれば回避できます。

gem "psych", "< 4.0.0"

# 逆に 4.0.0 以上を使用したい場合はこんな感じ
# gem "psych", ">= 4.0.0"

これは一時的な対処なのでできれば次に紹介する対処方法を行ったほうがよいでしょう。

2. Psych.unsafe_load を使用する

Psych.load ではなくて Psych.unsafe_load を使用することで Psych v4.0.0 以降でも以前の Psych.load を模倣する事ができます。

require "psych"
require "date"


pp Psych::VERSION  # => "4.0.0"

data = <<~EOS
default: &default
  aaa: aaa
  bbb: bbb
development:
  <<: *default
test:
  <<: *default
production:
  <<: *default
EOS

# OK: unsafe_load を使うと以前と同じように読み込める
pp Psych.unsafe_load(data)

また、ファイルから YAML データを読み込む場合は Psych.load_file の代わりに Psych.unsafe_load_file も利用できるので必要に応じて使い分けるとよいと思います。
RailsRubocop はこの Psych.unsafe_load を使用して対応を行っています。
基本的にはこの Psych.unsafe_loadPsych.unsafe_load_file を使って対処することになると思います。
また Psych.unsafe_loadPsych.unsafe_load_file なのですがこのメソッドは Psych v3.3.2 で追加されています。
なので移行パスとしては

1. Psych v3.3.2 に上げる
2. 必要に応じて Psych.unsafe_loadPsych.unsafe_load_file を使うように変更する
3. Psych v4.0.0 に上げる

という風に更新するといいのではないでしょうか。

結局今後どうなるの?

件の bugs.ruby のチケットでは『Ruby 3.0 と 3.1 で互換性を維持したいので Ruby 3.1 では Psych v4.0.0 を使用しない』という旨のコメントがされています。
なので意図的に Psych のバージョンを上げない限りは特に問題はでないかも…?
あくまでも現時点(2021/05/23)での方向性なので今後もしかしたら方針が変わるかもしれませんので注意してください。

まとめ

  • Psych v4.0.0 で YAML.load が非互換になる
  • なので既存の YAML データが読み込めずにエラーになる可能性がある
  • もし急に YAML データが読み込めなくなったら Psych のバージョンを確認しよう
  • 回避する場合は YAML.unsafe_load などが利用できる