knqyf263's blog

自分のためのメモとして残しておくためのブログ。

Semantic Versioningの闇

今回も誰も興味ないシリーズなので今まで書いてこなかったのですが、Semantic Versioningに関して幻想を抱いている人がいる可能性があり、そういう方にどうしても現実を知っておいて欲しかったので書きました。3行要約(と可能なら余談)だけでも読んでいただけると幸いです。

3行要約

  • Semantic Versioning 2.0.0にはバージョン"比較"の定義はあるが、バージョン"制約"(>= 2.1.3みたいなやつ)の定義がない
  • その結果、同じsemver準拠ライブラリでも制約の解釈が異なり結果が真逆になる
  • というかそもそもsemver使ってるエコシステムが少なすぎる

背景

セキュリティアドバイザリでは特定のバージョンが脆弱であることを示すためにバージョン制約が使われることが多いです。例えば >=1.2.0 <1.2.6みたいなやつです。この場合、1.2.5は脆弱だが1.2.6は修正済みということが分かります。そのためバージョン比較は重要なのですが、バージョニングの定義として一番有名であるSemantic Versioning 2.0.0(以下semver)に従っているケースは実はレアで、エコシステムによって全く異なります。

semverについて少し説明すると、3つの数字をドットで区切りそれぞれmajor, minor, patchとします。どういう時にそれぞれのバージョンを上げるのかはドキュメントを参照してほしいのですが、つまり 2.4.1 みたいな形式になります。 1.8 とか 1.9.2.3 とかは許されません。他にも - でPre-releaseを、+でBuild metadataが定義できたりします。1.0.0-alpha+001 のような形式です。

Debian系のOSではdeb-versionが使われていますし、Red Hat系も独自のバージョニングを使っており、どちらもsemverとは大きく異なります。Red Hatに至ってはバージョン比較の仕様書が見つけられず(Naming Conventionはあるけどこれだけでは比較はできない)、ソースコードを読みましたがかなり独特な感じです。これらはそもそもupstreamのソフトウェアのバージョニングに加えてディストリビューターがビルドした際にバージョンを付与する必要があるため、必然的にSemantic Versioningでは対応できないのですが、Alpineも当然のように実装が異なりますし、頼むから誰か統一してくれ...という気持ちです。

話はずれましたが、これらOSパッケージはある程度仕方ないとしてもプログラミング言語のライブラリも厳密にsemverに準拠していないものは多いです。例えばRubyGemsは以下でバージョニングが定義されています。

guides.rubygems.org

最初見たときにはSemantic Versioningと書いてあって、「おっ!」と思ったのですが下を見るとPre-releaseの定義がかなり独特です。

guides.rubygems.org

先程少し上で説明しましたがsemverであれば 1.2.3-alpha のように - 区切りでPre-releaseを定義します。つまり - は特別な意味を持ち、この場合は 1.2.3 より小さい(古い)とみなされます。通常はalpha, beta, rcなどリリース前の非安定版をテストするために使われます。ところがRubyGemsではPre-releaseは文字をドット区切りで入れるだけです。 1.2.3.aのような感じですね。これは 1.2.3 よりも小さくなります。さらに言えば、 1.2.3a とか 1.2.a3 とか 1.2.3.4.5.6.7 とかも許されますし実は全然semver準拠ではありません。あくまでsemverとか使うと良いよ〜と推奨してるだけで、全然誰も守ってません。Ruby on Railsほどの有名なソフトウェアがsemver準拠ではない時点で推して知るべしという感じです(執筆時点の最新は6.0.3.4です)。また、ffiのようにカジュアルに - を使っているソフトウェアもあります。例えば 1.13.1-javaなどがあります。ドキュメントには - について記載がなかったので特に意味ないのかなと思ったのですが、ソースコードを読んだところ内部的には .pre.置換されます。なのでそのあたりを考えてバージョニングをする必要があります。

ちなみに上述したRed Hat, Debianなどはupstreamのバージョンに加えてディストリビューション独自のパッチバージョンを示すために-が使われます。こういうOSパッケージではupstreamで更新があった際にそのまま使うことは少なく、バックポートして修正するためです。つまりupstreamが1.2.3から1.2.4に上げた時にDebianでは1.2.3にバックポートして1.2.3-1などにするということです(実際にはもっと複雑)。これは互換性を保つために行われているのですが、いずれにせよ - はPre-releaseを意味しないため、 1.2.3-11.2.3 より大きい(新しい)です。semverでは 1.2.3-1 の方が小さいため真逆になります。Red HatDebian、そしてAlpineのバージョニングだけで無限に語れるのでここでは一旦この程度の簡易な説明に留めます。

ということで再び脱線しましたが、semverに準拠していないバージョニングを採用している言語は多いです。では、semverなら安全なのか?という話になります。実際にsemverに準拠しているライブラリの挙動を以下で見ていきます。

Semantic Versioning準拠のライブラリ

バージョン比較と制約の違い

まず最初に説明しておくと、バージョン比較と制約は異なるものです。

  • バージョン比較
    • 1.2.32.0.0のどちらが大きいか?
    • -1, 0, 1などを返す
  • バージョン制約
    • 1.2.3>2.0.0 を満たすかどうか?
    • 通常戻り値はtrue/false

一見同じように見えますが、実は全然違うものです。記事を読んでいくと分かるかと思います。

バージョン省略時の扱い

まず、Node.jsはsemverに従っているということで semverライブラリを試してみます。

$ cat compare.js
const semver = require('semver')
console.log(semver.satisfies('1.3.4', '=1.3.4'))

$ node compare.js
true

このようにsemver.satisfiesを使うことで簡単に制約を満たすかどうかを確かめられます。では、以下のケースはどうでしょうか?

console.log(semver.satisfies('1.3.4', '=1'))

この結果をまず考えてみてほしいです。

正解は、trueになります。1行で書くと 1.3.4 = 1 です。これは明らかに直感に反しています。ただ中には「いやいや当然trueでしょう」という方もいると思いますが、それはよく訓練されすぎておかしくなってます。1.3.4 = 1 という式を改めて見て冷静になって欲しいです。

以下のケースはどうでしょう?

console.log(semver.satisfies('1.3.4', '>1'))

これも 1.3.4 > 1 という式で考えてみるとtrueに見えます。しかし実際にはfalseを返します。

ということで引っ張りましたが、これはバージョンの一部を省略した場合の解釈が直感と反することによります。Node.jsではバージョンを省略した場合 * の扱いになります。つまり =1 というのは =1.*.* になります。そのため、 1.3.4 = 1.*.* なので当然trueになります。また、>1>1.*.* になるので、実質 >=2.0.0 と等しくなります。そのため結果はfalseになります。普通は =1=1.0.0 のように0埋めするのかなと考えてしまうため、Node.jsの挙動はややおかしく感じます。

ではGoのライブラリである hashicorp/go-version はどうでしょうか。

github.com

こちらも

Versions used with go-version must follow SemVer.

と言っているのでsemver準拠です。ということで試してみます。

package main

import (
    "fmt"

    "github.com/hashicorp/go-version"
)

func main() {
    v, _ := version.NewVersion("1.3.4")
    constraints, _ := version.NewConstraint("=1")
    fmt.Println(constraints.Check(v))
}

https://play.golang.org/p/GFZnF6Jnp2f

この結果は何とfalseになります。Node.jsのライブラリではtrue、Goのライブラリではfalseで完全に真逆です。これは、hashicorp/go-version にとっては =1 は単に =1.0.0 だからです。そのため、 1.3.4 = 1.0.0 は当然falseです。

何故このようなことが起こるのか?というと、Semantic Versioning 2.0.0にはバージョン"制約"(>= 2.0.0みたいなやつ)の定義がないためです。バージョンのフォーマットや比較方法については定義がありますが、バージョン制約については何も触れられていません。そのため、ライブラリによって解釈の違いが生じます。

ちなみに1というのはsemverに違反しているので、1.3.41の比較は発生しません。一方で1.3.41.0.0 であれば明確に比較方法が定義されているので 1.3.4 が大きいと断言できます。つまり、バージョン比較なら一意に定まるがバージョン制約を満たすかどうかは実装依存ということになります。

ではもう一つのGoライブラリで有名な Masterminds/semver を見てみます。

github.com

package main

import (
    "fmt"
 
    "github.com/Masterminds/semver"
)

func main() {
    v, _ := semver.NewVersion("1.3.4")
    constraints, _ := semver.NewConstraint("=1")
    fmt.Println(constraints.Check(v))
}

https://play.golang.org/p/phNI6vS0VnD

こちらの結果はtrueです。このライブラリにとっては =1=1.*.* 相当ということです。同じGoでsemver準拠のライブラリであっても、異なるライブラリを使えば結果が真逆になります。これは結構恐ろしいので、ライブラリがどちらの解釈なのかを知った上で使う必要があります。ドキュメントには書いてなかったりするので、ソースコードを読みましょう。

Pre-releaseの扱い

では再びNode.jsに戻ります。以下はどうなるでしょう?

console.log(semver.satisfies('1.2.3-alpha', '>1.0.0'))

alpha はついているものの、1.2.31.0.0よりかなり大きいです。そのため、普通に考えたらtrueです。

$ node compare.js
false

しかし実際にはfalseになります。直感に反しまくりです。これは、このライブラリが純粋なバージョン比較ではなく利用者がインストールしたいバージョンを選ぶためのルールとしてバージョン制約を扱う側面があるためです。どういうことかというと、基本的にPre-releaseは安定版ではないためそのライブラリを利用しているユーザとしてはそのバージョンをインストールしたくないケースが多いです。そのためPipenvなどでも pipenv install --pre のようにオプションを付けないと基本的にはPre-releaseなバージョンはインストールされません。Node.jsにおいても同様で、 >1.0.0 というのは1.0.0より大きい安定版のバージョンを意味します。そのため、Pre-releaseがある場合は常にfalseになります。

Goのそれぞれのライブラリを見てみます。

v1, _ := version.NewVersion(ver)
c1, _ := version.NewConstraint(con)
fmt.Println(c1.Check(v1)) // false

v2, _ := semver.NewVersion(ver)
c2, _ := semver.NewConstraint(con)
fmt.Println(c2.Check(v2)) // false

https://play.golang.org/p/M5aJE9YNyCI

どちらもfalseになります。つまりPre-releaseを除外するというのは割と一般的な挙動と言えます。しかしセキュリティアドバイザリとの比較という意味では 1.2.3-alpha は明らかに >1.0.0 を満たしているのでとても困ります。

今のところ Masterminds/semver はNode.jsと同様の挙動をしているため、一緒の振る舞いなのかと思うかもしれません。しかしそれは甘いです。マックスコーヒーぐらい甘い考えです。

ConstraintがPre-releaseの場合

ではNode.jsに戻ってバージョン制約側がPre-releaseの場合を見てみます。以下のケースを考えます。

console.log(semver.satisfies('1.0.0-beta', '>1.0.0-alpha'))

さっきPre-releaseは常にfalseという説明をしたのでfalseと思うかもしれません。しかし実際にはtrueです。これはなぜかと言うと、制約側にPre-releaseを書いているということはPre-releaseを使いたいという意思表示である、そのためPre-releaseをインストールしても良いと解釈されるためです。その結果、普通にbetaとalphaが比較されbetaの方が大きいのでtrueが返ります。

Goはどうでしょうか。

ver := "1.0.0-beta"
con := ">1.0.0-alpha"

v1, _ := version.NewVersion(ver)
c1, _ := version.NewConstraint(con)
fmt.Println(c1.Check(v1)) // true

v2, _ := semver.NewVersion(ver)
c2, _ := semver.NewConstraint(con)
fmt.Println(c2.Check(v2)) // true

両方trueです。Node.jsと一緒!やはり同じ挙動なんだ!!と喜ぶのはまだ早いです。以下のケースを見てみます。

console.log(semver.satisfies('1.2.3-beta', '>1.0.0-alpha'))

明らかに1.2.31.0.0より大きいですし、制約にもPre-releaseが付いている、つまりtrueだと考えるかもしれませんが残念なことにこれはfalseです。なぜかというと、 >1.0.0-alpha1.0.0 のPre-releaseを使う覚悟はあるがそれ以外のPre-releaseが使いたいわけではないとNode.jsでは解釈されるからです。つまり >1.0.0-alpha, <1.0.0 の範囲でのみPre-releaseは許可するということになり、1.0.1-alpha1.2.3-betaなどはいつも通りfalseです。

そしてGoです。

ver := "1.2.3-beta"
con := ">1.0.0-alpha"

v1, _ := version.NewVersion(ver)
c1, _ := version.NewConstraint(con)
fmt.Println(c1.Check(v1)) // false

v2, _ := semver.NewVersion(ver)
c2, _ := semver.NewConstraint(con)
fmt.Println(c2.Check(v2)) // true

https://play.golang.org/p/imrDaPAfFHV

大変悲しい結果となっています。今までNode.jsとことごとく異なる挙動をしてきた hashicorp/go-version がfalse、つまりNode.jsと同じ挙動です。一方、今までNode.jsに寄り添ってきたMasterminds/semver がここで謀反を起こしてtrueとなっています。Masterminds/semver にとっては、制約にPre-releaseを入れた時点で全てのPre-releaseを使うという意思表示であると解釈しているということです。

ドット区切りのPre-release

- のあとのPre-releaseも実はドット区切りが可能です。これはsemverの仕様として定義されています。空よりもバージョンが何かしらある方が大きくなるため、以下のケースはtrueです。

console.log(semver.satisfies('1.0.0-alpha.beta', '>1.0.0-alpha')) // true

Goも見てみます。

ver := "1.0.0-alpha.beta"
con := ">1.0.0-alpha"

v1, _ := version.NewVersion(ver)
c1, _ := version.NewConstraint(con)
fmt.Println(c1.Check(v1)) // false

v2, _ := semver.NewVersion(ver)
c2, _ := semver.NewConstraint(con)
fmt.Println(c2.Check(v2)) // true

もう結果が異なることには何も驚かなくなっていると思いますが、hashicorp/go-version ではfalseになります。

実はこれは解釈の違いではなく、明確にsemver内で定義されています。

Example: 1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-alpha.beta < 1.0.0-beta < 1.0.0-beta.2 < 1.0.0-beta.11 < 1.0.0-rc.1 < 1.0.0.

つまりこれはシンプルに hashicorp/go-version のバグです。解釈の違いにより結果が異なるところと、バグにより結果が異なるところを正確に見定めていく必要があります。

特別なOperator

上記の例では >= など一般的なoperatorを使ってきましたが、実際には各言語はもう少し複雑なバージョン制約を持っています。例えばNode.jsでは ^~ が使えます。~は以下のような制約を意味します。

  • ~1.2.3 := >=1.2.3 <1.(2+1).0 := >=1.2.3 <1.3.0
  • ~1.2 := >=1.2.0 <1.(2+1).0 := >=1.2.0 <1.3.0 (Same as 1.2.x)
  • ~1 := >=1.0.0 <(1+1).0.0 := >=1.0.0 <2.0.0 (Same as 1.x)

RubyGemsでは ~> (Pessimistic Operator)というものが使えます。これはNode.jsの ~ と非常に似ているのですが、若干異なります。

  • ~>1.2.3 := >=1.2.3 <1.3
  • ~>1.2 := >=1.2 <2
  • ~>1 >=1.0.0 <2.0.0

Node.jsのものと比べてほしいのですが、 ~1.2 の場合の範囲が異なります。ですが残念なことに多くのライブラリで同様に扱ってしまい誤った結果を返すことがあります。もちろん ~>RubyGems準拠だ、と言っていない場合は ~ の挙動をしても厳密には誤りとは言えないかもしれませんが、~>という独特なoperatorを使っている以上はRubyGemsに則るべきかなと思います。

ver := "1.6"
con := "~1.4"

v1, _ := version.NewVersion(ver)
c1, _ := version.NewConstraint(con)
fmt.Println(c1.Check(v1)) // true

v2, _ := semver.NewVersion(ver)
c2, _ := semver.NewConstraint(con)
fmt.Println(c2.Check(v2)) // false

この場合は hashicorp/go-version が正しくて Masterminds/semver の挙動がおかしいかなと思います。もっとも、上で述べたようにMastermminds/semver~>RubyGems準拠と言っていないので完全な誤りとは少し言いにくいです。

違いまとめ

実はまだまだ違いがあるのですが、書くの疲れてきたし改めて読み直すとやはり誰も興味ないだろうなと思ったので一旦終わりにします。気分が向いたら追記します。

表にまとめましたが、同じ挙動をしているものが一つもないことが分かるかと思います。

Node.js hashicorp Masterminds
1.3.4 = 1 true false true
1.3.4 > 1 false true false
1.2.3-alpha > 1.0.0 false false false
1.0.0-beta > 1.0.0-alpha true true true
1.2.3-beta > 1.0.0-alpha false false true
1.0.0-alpha.beta > 1.0.0-alpha true false *1 true
1.6 ~> 1.4 false *2 true false

*1 これはただのバグ
*2 ~>~ とした場合の結果

Goの方は比較用のスクリプトをGo Playgroundに置いたのでそちらも合わせてどうぞ。

The Go Playground

余談

Semantic Versioning一つとってもこれだけ大変です。それぞれのディストリビューション・言語は独自のセマンティクスを持つのでただの地獄です。自分の用途だとhashicorpのライブラリのほうが近いのですが、他のPRなども取り込まれていませんしupstreamを改修するのは諦めて自作しました。オプションで*でなく0のパディングしたりPre-releaseを常に比較対象に入れたりとかも出来るようにしてあります。急いで作ったのでまだ完全に動くか自信ないですが、今のところそこそこ思った通りに動いてくれています。

github.com

他にもRubyGems用やnpm用も作りました。全部挙動が異なるのでテストを書きながら混乱して頭がおかしくなりそうになりました。また、1つのケースを通すと別のケースが落ちるというのが多発するのも辛いです。

github.com

github.com

これらを育てていきつつ他の言語も作る予定です。ただaquasecurity/go-version の挙動で他の言語でも割と正しく動くので、一旦PHPやRust、.NETはそれで対応して随時作っていくという感じで行きます。

自分はクラウドネイティブセキュリティの会社にいるため、チームメンバーはeBPFでバリバリ開発してeBPF Summit登壇したり、Kubernetes Operator作ってKubeCon North America 2020登壇したり、ボスは相変わらず基調講演とかで発表しまくったり、という状況の中で自分だけ毎日必死にバージョンを比較し続けていて「くらうどねいてぃぶってなんだ...?」状態になっています。先日のBerkeley DBの記事なども時代を逆行しまくりなわけですが、他の類似OSSがうまく実装できていないこういう細かい所の差が最終的にユーザに伝わると信じて頑張っています。

派手な新機能も良いですが、細かいところでバグが多いとやはり分かる人には分かるんじゃないかと思っています。なので地味であってもコツコツと改善を続けているのですが、実際にOSSにおいてそういったところが本当に重要なのかは自分にもまだ分かりませんし、これが実らなかったら次以降は細かいところは捨て置いて新機能だけ作る人になる予定です。

ちなみに自分のOSSのライバルとしてAnchoreやClairといったものがあるのですが、バージョン比較はずっとバグってました。昔はPR出して直してたのですが今はライバルだしな、と放置していたら結局自作を諦めて自分の作ったOSS使い始めてました。大企業ですら諦めてますし、やはりバージョン比較は地味な割に難しいんだなと思います。単に労力に対して割に合わないという判断かもしれませんが。

まとめ

Semantic Versioningの闇という記事のタイトルにしましたが、実際にはSemantic Versioningそのものが悪いと言うよりはバージョン制約に関する定義がないという点、実際にはほとんどのエコシステムでSemantic Versioningに従っていないという点が問題であるという内容でした。なのでバージョニングの問題と言うと若干語弊があるのですが、制約についても定義してくれていればこういう事は起きなかったと思うので、少し誇張した感じにはなっていますがこのタイトルとしました。用途の違いから来ているところもあるのですが(Pre-releaseはインストールしたくないとか)、それであれば違うoperatorを使うとかで全く同じ記号で異なる結果になることは避けられたのではないかと思います。「バージョニング?そんなもんsemverに従うだけだから簡単じゃないの?」と思っている人に現実を知ってもらえれば幸いです。