Cargo.toml を壊れたままにしない

Rust で Cargo.toml により依存パッケージのバージョン指定をしますが、これが壊れている場合が見受けられます。 このような問題の解説や、気付いて直すための方法についての話です。

この記事は Rust Advent Calendar 2020 の14日目の記事です。 前日になっても枠が空いていたので、最近思うところを急遽突っ込むことにしました。
前日の記事は『Dhallの型定義からRustの型を自動生成するマクロ』でした。

TL;DR

もう少し詳しくは まとめ を参照のこと。
  • 依存バージョンを指定するときは、マイナーバージョンやパッチバージョンまで含めて最新のものを指定しましょう。
  • cargo +nightly update -Z minimal-versions で、制約を満たす最小バージョンへの依存を使った Cargo.lock を生成し、ビルドやテストを実行しましょう。
  • 最小バージョンでのテストを CI で自動実行しましょう。
  • 依存先に問題を見付けたら報告・修正しましょう。
  • README.mdfoo = "1" などのような不正確な案内をするのをやめましょう。

Cargo.toml に記載する依存バージョン

まず Cargo.toml に記載するバージョン指定について説明します。 ……といっても The Cargo Book の該当ページに全て書いてあるのですが。

まず caret (^) による指定。 semantic versioning (semver) に基いて互換性のあるバージョンを許容する制約です。 ^ は省略可能なので、単にバージョン番号を書いた場合はこの解釈になります。

^1.2.3  :=  >=1.2.3, <2.0.0
^1.2    :=  >=1.2.0, <2.0.0
^1      :=  >=1.0.0, <2.0.0
^0.2.3  :=  >=0.2.3, <0.3.0
^0.2    :=  >=0.2.0, <0.3.0
^0.0.3  :=  >=0.0.3, <0.0.4
^0.0    :=  >=0.0.0, <0.1.0
^0      :=  >=0.0.0, <1.0.0
The Cargo Book より

たとえば libflate = "1" あるいは libflate = "^1" (どちらも全く同じ意味です) と指定すると、「libflate crate の v1.0.0 あるいはその代わりに使えるバージョンを使う」という意味になります。 であれば v1.0.3 ですが、将来的に v1.2.0 とか 1.9999.9999 が出た場合、それらのバージョンも選択肢に入ります。

他にも tilde (~) により指定する方法もあります。 これはパッチバージョンの上昇のみを認める制約で、雰囲気としては「指定された分の詳細度で」という感じです (ただし誤りの修正を意味するパッチバージョンを除く)。 たとえば semver では 1.2.3 のコードの代わりに 1.3.0 のコードを利用することが可能ですが、 ~1.2.3 はこれを受け入れません。 「1.2 と言ったら 1.2 にしてくれ、 1.3 は頼んでない」というわけです。

tilde requirements は、通常のユーザが利用する必要のないものです。 これは「ひとつのプロジェクトだが複数の crate に分けた」などの場合 (つまり別 crate の仕様についての完全な知識を用いており、知らない機能追加などがあっては困る場合など) に有用かもしれません[0]が、単なる crate のユーザとしてこの指定を利用するのは賢明ではありません。 よって本記事ではこれ以上の言及はしません。 caret, tilde 以外による指定についても同様に、本記事では言及しません。

古い crate と壊れるビルド

Rust では互換性を大事にしていますが、それでも互換性破壊が起きることはあります。 特に第三者が作った crate では、その semver 準拠 (つまり正しい互換性管理) は crate の開発者に一任されており、コンパイラや言語仕様の都合ばかりでなく、開発者が見落としや誤ったバージョン付けを行った場合でもユーザがその被害を被ることになります。

具体例

具体例を見てみましょう。

            [package]
name = "test-dep"
version = "0.0.0"
edition = "2018"

[dependencies]
libflate = "1"
          
Cargo.toml
            $ cargo check
    Updating crates.io index
   Compiling crc32fast v1.2.1
    Checking cfg-if v1.0.0
    Checking rle-decode-fast v1.0.1
    Checking libflate_lz77 v1.0.0
    Checking adler32 v1.2.0
    Checking libflate v1.0.3
    Checking test-dep v0.0.0 (/run/user/1000/deptest/po)
    Finished dev [unoptimized + debuginfo] target(s) in 3.21s
$
          
cargo check の実行結果
            $ cargo tree
test-dep v0.0.0 (/run/user/1000/deptest/po)
└── libflate v1.0.3
    ├── adler32 v1.2.0
    ├── crc32fast v1.2.1
    │   └── cfg-if v1.0.0
    ├── libflate_lz77 v1.0.0
    └── rle-decode-fast v1.0.1
$
          
cargo tree の実行結果。 libflate v1.0.3 が使われているのがわかる。
libflate1 への依存を主張する crate のビルド結果

libflate = "1" と指定すると、先に解説したように 1.0.0 と互換な任意のバージョンを使えると cargo に伝えることになります。 cargo は最初のビルドや cargo update 等の機会に、利用可能な中で最新のバージョンを取得します。 時点では v1.0.3 が最新で、これが利用されていることがメッセージからわかります。

さて、これでコンパイルが通ったからといって cargo publish してしまう人は多いのでしょうが、実はこの Cargo.toml壊れているのです。 実際に確認してみましょう。

            $ cargo +nightly update -Z minimal-versions
    Updating crates.io index
    Updating adler32 v1.2.0 -> v1.0.0
    Removing cfg-if v1.0.0
    Updating crc32fast v1.2.1 -> v1.0.1
    Updating libflate v1.0.3 -> v1.0.0
    Updating rle-decode-fast v1.0.1 -> v1.0.0
      Adding rustc_version v0.2.0
      Adding semver v0.6.0
      Adding semver-parser v0.7.0
$
          
依存 crate について、互換性のある最新バージョンではなく、互換性のある最古バージョンを用いるようにする
            $ cargo tree
test-dep v0.0.0 (/run/user/1000/deptest/po)
└── libflate v1.0.0
    ├── adler32 v1.0.0
    ├── crc32fast v1.0.1
    │   [build-dependencies]
    │   └── rustc_version v0.2.0
    │       └── semver v0.6.0
    │           └── semver-parser v0.7.0
    ├── libflate_lz77 v1.0.0
    └── rle-decode-fast v1.0.0
$
          
cargo tree の実行結果。 libflate として最新の v1.0.3 でなく可能な最古の v1.0.0 が使われているのがわかる。
            $ cargo check
   Compiling semver-parser v0.7.0
    Checking rle-decode-fast v1.0.0
    Checking adler32 v1.0.0
   Compiling semver v0.6.0
   Compiling rustc_version v0.2.0
   Compiling crc32fast v1.0.1
error[E0277]: the trait bound `Version: From<({integer}, {integer}, {integer})>` is not satisfied
 --> /home/lo48576/.cargo/registry/src/github.com-1ecc6299db9ec823/crc32fast-1.0.1/build.rs:8:30
  |
8 |     if version >= (1, 27, 0).into() {
  |                              ^^^^ the trait `From<({integer}, {integer}, {integer})>` is not implemented for `Version`
  |
  = help: the following implementations were found:
            <Version as From<semver_parser::version::Version>>
  = note: required because of the requirements on the impl of `Into<Version>` for `({integer}, {integer}, {integer})`

error: aborting due to previous error

For more information about this error, try `rustc --explain E0277`.
error: could not compile `crc32fast`

To learn more, run the command again with --verbose.
$
          
ビルドが通らない
Cargo.toml の指定に従っているのにビルドが失敗する例

何が起きているかというと、 Rust コンパイラである rustc--version オプションの出力が rustc 1.29.0 から変化したのですが、ビルド時依存である rustc_verison crate の v0.2.0 がこれに対応しておらず、結果コンパイルエラーとなるのです。 この問題は crc32fast crate の v1.1.1 以降で対処されているのですが、当然この問題が存在しなかった時代の人々が Cargo.toml で事前にこれに対処することはできません。 つまり後から壊れてしまったビルドは、後世の人々が気付いて修正するしかないのです。

この例の場合であれば libflate v1.0.1 で対処済[1]ですから、 Cargo.tomllibflate = "1.0.1" を指定してやれば問題なくコンパイルが通るであろうと予想できます。

            [package]
name = "test-dep"
version = "0.0.0"
edition = "2018"

[dependencies]
libflate = "1.0.1"
          
ビルドが通る正しいバージョンを指定した Cargo.toml
            $ cargo +nightly update -Z minimal-versions
    Updating crates.io index
    Updating crc32fast v1.0.1 -> v1.1.1
    Updating libflate v1.0.0 -> v1.0.1
    Removing rustc_version v0.2.0
    Removing semver v0.6.0
    Removing semver-parser v0.7.0
$
          
更新された Cargo.toml に従って、改めて最小バージョンを使うよう設定する
            $ cargo tree
test-dep v0.0.0 (/run/user/1000/deptest/po)
└── libflate v1.0.1
    ├── adler32 v1.0.0
    ├── crc32fast v1.1.1
    ├── libflate_lz77 v1.0.0
    └── rle-decode-fast v1.0.0
$
          
cargo tree の実行結果。 crc32fast v1.1.1 が利用されており、 rustc_version crate への依存がなくなった
            $ cargo check
   Compiling crc32fast v1.1.1
    Checking libflate v1.0.1
    Checking test-dep v0.0.0 (/run/user/1000/deptest/po)
    Finished dev [unoptimized + debuginfo] target(s) in 0.82s
$
          
要求を満たす最小バージョンの crate でビルドしても、問題なくビルドできるようになった
依存バージョン指定を「正しく」記載することで、ビルドが通るようになった

Cargo.tomlが壊れているということ

先の具体例で見たように、文法的に正しく、普通に実行してもビルドが通るような Cargo.toml についても、サイレントに壊れている場合があります。 ここでいう壊れているとは、Cargo.toml で指定された制約に従って依存 crate を準備したのにビルドが通らないということです。 こういった壊れ方は cargo update 等により症状が隠れてしまう場合もあり、見逃されがちです。

このような壊れた Cargo.toml の発生要因としては、以下のようなものが考えられます。

Cargo.toml で不必要に緩いバージョンを指定した

一番ありそうな原因です。 たとえば今から libflate crate に依存するプロジェクトを書くとして、 libflate = "1.0.3" と指定すればいいところを、要らぬ気をきかせて libflate = "1" と指定してしまうと、一部の環境や特殊な条件下でのビルドで失敗するような壊れた Cargo.toml のできあがりです。

Rust や rustc のバージョンアップで依存している crate が壊れた

次いでありそうな原因です。 この場合、問題に気付いてから依存バージョンを(問題修正済のものに)上げるなり、当該 crate への依存自体を回避するようコード書き換えるなりする必要があります。 ただ、これも後述の CI などで日常的に検査しておかないと、問題修正済の crate がリリースされた後で問題に気付くことは困難です。

依存している crate が semver に従わない非互換な更新をした

あまり信じたくはない話ですが、まあ残念ながらなくもないです。 その原因は様々で、単に更新内容に見落としがあったとか、互換性破壊が発生することに気付かなかったとか、そもそも互換性破壊に相当するかコンセンサスが形成されていない[2]とか、いろいろ考えられます。 気付いたら泣きながら依存バージョンを上げましょう。

この壊れ方の場合は先に挙げた例とは逆に、 cargo update により問題が顕在化するようになります。 それゆえ問題に気付くチャンスも増えるでしょう。

Cargo.toml を壊さないために、何をすべきか

Cargo.toml を壊さない、壊れたことに気付くための方法はいくつかあります。

新しい依存を追加するときは、古いものではなく最新のバージョンを明示的に指定する

依存 crate のバージョンは不必要に緩くすべきではありません。 現状で利用可能な、あるいは利用するつもりの API を持つバージョンのうちで、最新のものをパッチバージョンまで含めて (つまり "x.y.z" の形で) 指定しましょう。

たとえば libflate への依存を新たに追加したいのであれば、 libflate = "1" と書くのではなく libflate = "1.0.3" のように最新のものを指定すべきでしょう。 なぜならあなたはこれから libflate v1.0.0 を用いて実装するのではなく、 v1.0.3 で実装と動作確認を行うことになるからです。

あるいは serde を例に出してもいいかもしれません。 気をきかせたつもりで serde = "1" と書くのは簡単ですが、本当に serde v1.0.0 や serde_derive v1.0.0 でビルドできることを保証したいのですか? serde v1.0.1 以降の追加・修正を必要としないコードを書いている確信があるのですか? そのつもりがないなら、軽率に serde = "1" のような緩い制約を指定すべきではありません。

ローカルで検査する

要は制約が不必要に緩いのが問題なのであって、こういう場合手っ取り早くやるにはエッジケースでテストをするものと相場が決まっています [要出典]。 ここでいうエッジケースとは、すなわち最新のバージョンと最古のバージョンです[3]

最新のバージョンを cargo update で利用できるのは言うまでもありませんが、最古のバージョンを利用させる Cargo.lock の生成には nightly ツールチェインが必要です。 これは Cargo.lock の生成だけに必要で、一度生成してしまえば他の部分で nightly を使う必要はありません。

cargo +nightly update -Z minimal-versions を実行して Cargo.lock を生成し、しかるのちに cargo checkcargo testなどしましょう。 それだけです。

また、もし feature flag などによって追加の依存が発生することがあるなら、ビルド時に適宜 --no-default-features とか --all-features とかの有無のバリエーションも含めてテストするのが良いかと思います。 特定の feature を有効化したらビルドできなくなったとか悲しいので。

CI で自動的に検査する

GitHub や GitLab などメジャーな git リポジトリホスティングサービスは CI との連携機能を提供しており、 OSS については無料で利用可能な場合が多いです。 せっかくなので、単に最新の依存でビルドできるかだけではなく、制約を満たす最古のバージョンでビルドできるかも自動的に検査しましょう。

手順としては簡単で、ビルドやテストの前に rust の nightly ツールチェインをインストールし、 cargo +nightly update -Z minimal-versions を実行するだけです。 手動でやることとほとんど変わりません。 これにより所望の Cargo.lock が生成されるので、後の工程では nightly ツールチェインは不要です。 普通に stable なり beta なり MSRV なりで cargo checkcargo test を実行しましょう。

          # Template for test stage.
.test_template: &test
  stage: test
  before_script:
    # Use dependencies with minimal versions, if `USE_MINIMAL_VERSIONS` is nonzero.
    - if [ "${USE_MINIMAL_VERSIONS:-0}" -ne 0 ] ; then
        rustup install nightly &&
        cargo +nightly update -Z minimal-versions ;
      fi

# (snip)...

test:msrv:all-features-minimal-versions:
  <<: *test_msrv
  variables:
    FEATURES: --all-features
    USE_MINIMAL_VERSIONS: 1
        
GitLab CI での設定例
          before_install:
  # (snip)...
  - |
    if [ "${TEST_MINIMAL_VERSIONS:-0}" -ne 0 ] ; then
        rustup install nightly
    fi
before_script:
  # Use dependencies with minimal versions.
  - |
    if [ "${TEST_MINIMAL_VERSIONS:-0}" -ne 0 ] ; then
        cargo +nightly update -Z minimal-versions
    fi
        
Travis CI での設定例

依存 crate に問題を発見したら報告する (可能ならパッチを投げる)

残念ながら、自分の書いた Cargo.toml でなく依存 crate での指定に問題があった場合、自分のプロジェクトでの CI が失敗することがあります。 このような場合、問題を報告しましょう。 気付いたあなたが直すのです。

というか私も何度かそういうパッチを投げたことがあり、もしや Cargo.toml の破壊という概念に誰も気付いていないのでは??? と思ったのでこの記事を書いています。 各位たのむ〜。

とはいえ、依存 crate の問題とはいっても、依存の連鎖を深く辿らないと原因の crate が出てこなかったり、問題が起きなくなる最小のバージョンを二分探索や CHANGELOG 確認で探したり、複数 crate が同時に壊れていたりなど、解決に手間がかかるケースもあります。 べつに本質的な難しさがあるわけではないのですが単純に面倒だったりするので、無理に頑張って諦めたり放置するくらいならまずは issue だけでも報告してしまいましょう。

README.md 等で不正確なバージョンでの案内をしない

よく README.md で「Cargo.tomlfoo = "1.2" と書くと、この crate が利用できるようになりますよ〜」みたいな案内がある crate をよく見掛けます。 ここで不正確なバージョン番号を提示してあると、何も考えずそれを使って将来的に壊れるなどというケースがありえるので、ちゃんと正確な ("1.2.3" のような) バージョンを記載しましょう。 リリース前のバージョン番号更新忘れにも注意です。

そもそもの話をしてしまうと、私はあの案内は普通に不要だと思うのです。 単にコマンドをインストールしたいユーザならともかく、 Rust で物を書こうという人はたぶん TRPL とかを読んでると思うんですよね。 リリース毎に更新すべき箇所を断片的に増やして案の定忘れたりするくらいなら、最初からこんな基礎的で誤りやすい案内はない方がマシだと思います。 README で案内せずとも crates.io に Cargo.toml に追加すべき記述は掲載されているわけですからね。

README で最新のバージョンを提示するだけなら https://img.shields.io/crates/v/{{crate_name}}.svg を使うことでバッヂ画像を取得できます。 たとえば serde なら https://img.shields.io/crates/v/serde.svg といった具合です。 これを README の天辺に貼り付けて crates.io へのリンクにしておけば、バージョンの案内としては十分でしょう。

          [![Latest version](https://img.shields.io/crates/v/datetime-string.svg)](https://crates.io/crates/datetime-string)
        
バッヂにより、 README で誤りの心配のないバージョン案内を出している例

まとめ

  • Cargo.toml は壊れることがあります。 しかも cargo update で問題が隠れてしまう場合があります。

  • 依存バージョンの指定を不必要に緩くすると、壊れた Cargo.toml になる場合があります。 これを回避するため、パッチバージョン (ピリオド区切りの3つめ、最後の数字) まで含めて、できるだけ新しいバージョンを明示的に指定しましょう。

  • nightly の rust をインストールし cargo +nightly update -Z minimal-versions を実行することで、制約を満たす最小バージョンへの依存を使った Cargo.lock を生成できます。 このコマンドの後で cargo checkcargo test を実行することで、 Cargo.toml の記述が壊れていないか検査できます。

  • cargo +nightly update -Z minimal-versions で生成した Cargo.lock による検査を自動的に行えるよう、 CI を設定しましょう。

  • 最小依存バージョンでのビルドを試してみたら失敗することがあるかもしれません。 もし依存している crate が原因であれば、 issue で報告しましょう。

    • Cargo.toml を適切に修正してパッチを提供できると尚良いのですが、手間がかかることもあります。 面倒になって放置するくらいなら最初から issue だけでも報告されていた方が助かるので、気力や余裕がなければ、まずは無理せず報告してみましょう。

  • README で foo = "1" などのような不正確な案内をするのをやめましょう。 基礎的すぎて誰に向けた案内なのか微妙なうえ、更新忘れなどのミスも発生しやすくなります。

    • README で最新バージョンを提示したいのであれば、自動生成されるバッヂ画像と crates.io へのリンクで対応しましょう。

    • markdown なら ![Latest version](https://img.shields.io/crates/v/{{crate_name}}.svg)](https://crates.io/crates/{{crate_name}}) のようなものを追加しておくだけです。

あと本題ではありませんが微妙に関係するかもしれない話として、サポートする最小の Rust バージョン (MSRV, Minimum Supported Rust Version) も README とかで明示したうえで CI で検査してもらえると、パッチを投げる側としては大変助かります。 各位たのむ〜。