RubyGemsとnpmにおけるバージョン処理の違い
Gareth Visagie
2016年12月14日
0 分で読めますRubyGemsとnpmは、RubyとNode.jsの事実上の標準パッケージマネージャーです。
一見すると似ているように見えます(実際、似ています)。しかし、両方と連携する製品を構築する際には、考慮すべき細かな違いがあります。
最近、SnykでRubyプロジェクトのサポートを追加しました。その過程で、RubyGemsとnpmのバージョン処理の違いがいくつかの課題をもたらしました。
このブログ記事では、そうした違いと、それによって生じた問題、そして解決方法について説明します。
セマンティックバージョニング
Node.jsやRubyの開発者は、主にこれらのパッケージマネージャーを通じてセマンティックバージョニングの標準を利用します。そのため、各パッケージマネージャーのバージョン処理の方法もSemVer自体の一部だと思いがちです。しかし、これから見るように、そうではありません。
SemVerとは?
セマンティックバージョニング(SemVer)は、ライブラリの新しいバージョンがもたらす影響を、バージョン番号を比較するだけでライブラリ利用者が理解できるようにすることを目指した、ソフトウェアのバージョン番号に関する標準です。
SemVer標準に準拠するソフトウェアでは、複数の要素からなるバージョン番号を使用する必要があります。各要素の変更は、ソフトウェア自体に異なる種類の変更があったことを示します。
http://semver.orgから直接引用します。
バージョン番号をMAJOR.MINOR.PATCHとした場合、次のように番号を増やします。1. 互換性のないAPI変更を行った場合はMAJORバージョン、2. 後方互換性を保ちながら機能を追加した場合はMINORバージョン、3. 後方互換性のあるバグ修正を行った場合はPATCHバージョン。プレリリースやビルドメタデータを示す追加ラベルは、MAJOR.MINOR.PATCH形式の拡張として利用できます。
SemVer標準では、バージョンを並べ替える際の比較方法である優先順位ルールも定めています。
SemVerではないもの
ほとんど(あるいはすべて)のパッケージマネージャーでは、バージョンの「範囲」を指定できます。たとえば、ソフトウェアの依存関係を範囲(>= 5.0 and < 6など)で指定すると、より幅広い状況でソフトウェアライブラリを利用できます。
この「範囲指定」は、比較ルールは含まれますが、SemVerの一部ではありません。そのため、各パッケージマネージャーは独自の構文やルールで範囲を指定できます(例:>= 5、~ 1.2、^2.3.0、>5 || <2)。
RubyGemsとnpmの範囲構文は非常によく似ているため(詳細は後述)、両方を頻繁に使う人は、その構文も標準の一部だと思い込みがちです。しかし、実際にはそうではありません。
npmとRubyGemsのバージョン番号
有効なバージョン番号とは何かについて、npmとRubyGemsでは見解が異なります。
npmでは、バージョンはセマンティックバージョニング2.0に厳密に準拠している必要があります。メジャー、マイナー、パッチの各バージョンが指定されていない番号は無効です。
RubyGemsでは、有効なバージョンの定義がより柔軟で、5.1.0.2.1.0のように、要素数に制限はありません。Gemの作者にはセマンティックバージョニング標準に従うことが推奨されていますが、プログラムによる強制はありません。
それでも、RubyGemsのエコシステムでは4要素のバージョン番号がかなり一般的です。Snykでもよく見かけます。多くのGem(特にRailsとその構成要素)が、セキュリティ修正に4つ目の要素を使っているためです(たとえば、5.0.0の脆弱性は5.0.0.1で修正されます)。
npmとRubyGemsのバージョン範囲
RubyGemsとnpmでは、バージョン範囲を指定する演算子がよく似ています。
RubyGemsでの範囲処理はRubyの標準ライブラリの一部です。npmではsemverパッケージが処理を担います。
基本的な範囲構文
基本的な範囲演算子>、>=、<、>=、=の動作はRubyGemsとnpmで同じです。RubyGemsには!=演算子もありますが、npmではサポートされていません。
高度な範囲構文
npmとRubyGemsはどちらも、複雑な範囲を簡潔に表現できます。npmはRubyGemsより多くの演算子を提供しており、やや幅広い複雑な範囲に対応しています。
どちらも~>演算子を備えており、「悲観的」な範囲を指定できます。大まかには、「semverに照らして安全と考えられる範囲で、このバージョン以降を許可する」という意味です。ただし、RubyGemsとnpmでは正確な動作が微妙に異なります。
RubyGemsでは「最下位のバージョン要素だけが変わる、より新しいバージョンを許可する」と解釈します。一方、npmでは「パッチバージョンが指定されていればパッチの変更のみを許可し、指定されていなければマイナーバージョンの変更のみを許可する」と解釈します。例を見るとよくわかります。
範囲 | RubyGemsでの解釈 | npmでの解釈 |
|---|---|---|
|
| エラー、無効なバージョン |
|
|
|
|
|
|
どのような問題が起きたのか?
当初、RubyGemsのバージョン処理はnpmの機能を包含していると考え、npmとRubyGemsの範囲操作の両方にsemverパッケージを使用していました。
その結果、アルファテストで予想外の結果がいくつか明らかになりました。テストしたRubyリポジトリのほぼすべてが、最新状態のRailsアプリケーションも含め、非常に脆弱であるように見えたのです。
自動テストのレポートを手作業で作成したものと比較すると、多くの誤検知(および少数の見逃し)が見つかりました。さらに調査した結果、原因はRubyGemsとnpmの範囲の意味の違いだとわかりました。
根本的な問題は、RubyGemsで特定の範囲に該当するバージョンが、npmで同じ範囲に該当するとは限らないことでした。
別のアプローチが必要でした。
この問題をどのように解決したのか?
Snykは当初npmのみをサポートしていたため、製品コードではすでにnpmのsemverパッケージをバージョンや範囲の操作に使用していました。
そこで、npmのsemverパッケージと同じAPIを備え、RubyGems形式のバージョン処理を提供する同様のライブラリを作成することにしました。
その後、テスト、監視、修正の対象となるコードで使われているパッケージマネージャーに基づき、システムが使用するライブラリを選択します。
ruby-semverをどのように実装したのか?
RubyGemsのバージョン処理はRubyの標準ライブラリの一部です。自分たちで再実装するのではなく、Opalを使ってRubyGemsの該当部分をJavaScriptにトランスパイルすることにしました。
最初は、Rubyのソースからクラスを直接トランスパイルしようとしました。しかし、JavaScriptとRubyでは正規表現のサポートに違いがあるため、これは不可能だとわかりました。
最終的に、Ruby標準ライブラリから2つのクラスをリポジトリにコピーし、正規表現をJavaScriptに対応するよう変更したうえで、そのRubyソースをJavaScriptにトランスパイルするスクリプトを追加しました。
トランスパイルしたコードがNode.jsのREPLで動作するようになったところで、必要としていたnpm-semver形式のAPI向けに適切なテスト一式を追加しました。そして、npm SemVer APIとトランスパイルしたRubyGemsコードの間をつなぐ、できる限り薄いアダプターを作成しました。
この方法はうまくいきましたが、優れたRubyからJavaScriptへのトランスパイラーに依存しています。手作業で動作を実装することも可能でしたが、より多くの労力が必要だったうえ、さらに重要な点として、RubyGemsですでに処理されている、気づきにくいエッジケースを見落とす可能性がありました。
まとめ
SnykでRubyをサポートしたことで、言語やパッケージマネージャーによってバージョン管理の方法が異なるという、興味深い課題が明らかになりました。SemVerバージョンの範囲は、そもそも定義されていないため、さらに問題を複雑にします。
こうした課題を理解したことで、SemVerとその限界について理解が深まり、最終的には、他のエコシステムへの対応を構築するための基盤を強化できました。
RubyとNode.jsの両方を使っている方は、ruby-semverをご覧ください。誰かの役に立てばという思いから、オープンソースとして公開しました。
RubyプロジェクトにもSnykを追加してみてください。新しい種類のRubyプロジェクト(Gemを含む)への対応を積極的に進めています。今後の展開にご期待ください。
CTFを始めよう
オンデマンドのバーチャル入門ワークショップを視聴して、CTFの課題の解き方を学びましょう。

