SpiderMonkey の判別とブラウザ判別2009年02月01日 18時30分

1 行でブラウザ判別を行うスクリプト (IE 用の日本語紹介記事Firefox、Safari 用の日本語紹介記事) が注目を集めています。それによると Firefox の判別は次の 15 文字で行えるそうです。

//Firefox detector 2/3 by DoctorDan
FF=/a/[-1]=='a'

いったいなぜこれで Firefox が判別できるのか、少し探ってみました。

  1. 何を判別しているのか
  2. SpiderMonkey のソースコードを探る
  3. より短い SpiderMonkey の判別法
  4. 「ブラウザ判別」としての不備
  5. まとめ
  6. 参考文献

何を判別しているのか

実際に探っていく前にひとつ注意しなければいけないことがあります。それは、上記のコードが Firefox を判別するものではないということです。このコードには Web ブラウザ固有のオブジェクトモデルに関する情報が含まれておらず、正確には Firefox 等で使われている JavaScript エンジン「SpiderMonkey」を判別するためのものであるというべきでしょう。

SpiderMonkey のソースコードを探る

上記コードを見るに、SpiderMonkey では正規表現オブジェクトのインデックス -1 が source プロパティに対応するようです。しかしそうなる原因を探るにはどうすればよいのでしょうか。とりあえず -1 プロパティを取得するという JavaScript コードに対して、SpiderMonkey が生成する中間コードを見てみることにします。中間コードは、単体の SpiderMonkey に付属する JavaScript shell で使える dis 関数で確認できます。なお、都合によりここで使用する SpiderMonkey 及び参照する SpiderMonkey のソースコードは、Firefox 3.0 系列に含まれるものと同等のバージョン (SpiderMonkey 1.8) のものとします。

$ js
js> dis(function () /a/[-1]);
flags: LAMBDA EXPR_CLOSURE INTERPRETED
main:
00000:  regexp null
00003:  int8 -1
00005:  getelem
00006:  return
00007:  stop

Source notes:
  0:     5 [   5] pcbase   offset 5

これを見ると、数値的プロパティの取得には getelem 命令が使われることがわかります。そこで、SpiderMoneky のソースコードから、getelem 命令を処理している部分を見ると次のようになっています (一部改変)。

/* jsinterp.c */
4534: BEGIN_CASE(JSOP_GETELEM)
4535:   /* Open-coded ELEMENT_OP optimized for strings and dense arrays. */
4536:   lval = FETCH_OPND(-2);
4537:   rval = FETCH_OPND(-1);
4538:   if (JSVAL_IS_STRING(lval) && JSVAL_IS_INT(rval)) {
            /* 文字列の数値的プロパティに関する処理 */
4548:   }
4549: 
4550:   VALUE_TO_OBJECT(cx, -2, lval, obj);
4551:   if (JSVAL_IS_INT(rval)) {
4552:       if (OBJ_IS_DENSE_ARRAY(cx, obj)) {
                /* 密な配列の数値的プロパティに関する処理 */
4566:       }
4567:       id = INT_JSVAL_TO_JSID(rval);
4568:   } else {
4569:       if (!js_InternNonIntElementId(cx, obj, rval, &id))
4570:           goto error;
4571:   }
4572: 
4573:   if (!OBJ_GET_PROPERTY(cx, obj, id, &rval))
4574:       goto error;
4575: end_getelem:
4576:   regs.sp--;
4577:   STORE_OPND(-1, rval);
4578: END_CASE(JSOP_GETELEM)

ひとつのブロックが BEGIN で始まって END で終わっているような気がしますが、これは C 言語のソースコードです。今の場合、lval には正規表現オブジェクト (へのポインタ)、rval には整数値 -1 が含まれることになります。lval、rval はともに jsval 型ですが、プロパティの操作に当たっては JSObject へのポインタ型と jsid 型の値を使うので、4550 行目で正規表現オブジェクト (へのポインタ) を JSObject へのポインタ型に、4567 行目で整数値を jsid 型に変換しています。

4573 行目のいざプロパティを取得する段では、取得結果を収める変数として rval を再利用しています。正規表現オブジェクトの場合、OBJ_GET_PROPERTY マクロによるプロパティの取得は js_GetProperty 関数 (JS_GetProperty 関数とは別物。大文字の JS から始まる関数は外部 API) を経て js_GetPropertyHelper 関数の呼び出しにつながります。

/* jsobj.c */
3644: JSBool
3645: js_GetPropertyHelper(JSContext *cx, JSObject *obj, jsid id, jsval *vp,
3646:                      JSPropCacheEntry **entryp)
3647: {
3648:     uint32 type;
3649:     int protoIndex;
3650:     JSObject *obj2;
3651:     JSProperty *prop;
3652:     JSScopeProperty *sprop;
3653: 
3654:     /* Convert string indices to integers if appropriate. */
3655:     CHECK_FOR_STRING_INDEX(id);
3656:     JS_COUNT_OPERATION(cx, JSOW_GET_PROPERTY);
3657: 
3658:     type = OBJ_SCOPE(obj)->shape;
3659:     protoIndex = js_LookupPropertyWithFlags(cx, obj, id, 0, &obj2, &prop);
3660:     if (protoIndex < 0)
3661:         return JS_FALSE;
3662:     if (!prop) {
3663:         jsbytecode *pc;
3664: 
3665:         *vp = JSVAL_VOID;
3666: 
3667:         if (!OBJ_GET_CLASS(cx, obj)->getProperty(cx, obj, ID_TO_VALUE(id), vp))
3668:             return JS_FALSE;
3669: 
              /* プロパティキャッシュに関する処理 */
              /* 最終的にプロパティが存在しなかった場合の処理 */
3716:         return JS_TRUE;
3717:     }
3718: 
          /* すでにプロパティが存在する場合の処理 */
          /* プロパティキャッシュに関する処理 */
3733:     return JS_TRUE;
3734: }

3659 行目でプロパティの探索を行いますが、-1 という名前のプロパティは本来正規表現オブジェクトに存在しないので見つからず、変数 prop の値は NULL になります。すると、3667 行目で、正規表現オブジェクトに共通するプロパティ取得時用フック関数 (regexp_getProperty 関数) が、整数 -1 (を表す jsval 型の値) とともに呼び出されるのです。

/* jsregexp.c */
3595: enum regexp_tinyid {
3596:     REGEXP_SOURCE       = -1,
3597:     REGEXP_GLOBAL       = -2,
3598:     REGEXP_IGNORE_CASE  = -3,
3599:     REGEXP_LAST_INDEX   = -4,
3600:     REGEXP_MULTILINE    = -5,
3601:     REGEXP_STICKY       = -6
3602: };

3607: static JSPropertySpec regexp_props[] = {
3608:     {"source",     REGEXP_SOURCE,      RO_REGEXP_PROP_ATTRS,0,0},
3609:     {"global",     REGEXP_GLOBAL,      RO_REGEXP_PROP_ATTRS,0,0},
3610:     {"ignoreCase", REGEXP_IGNORE_CASE, RO_REGEXP_PROP_ATTRS,0,0},
3611:     {"lastIndex",  REGEXP_LAST_INDEX,  REGEXP_PROP_ATTRS,0,0},
3612:     {"multiline",  REGEXP_MULTILINE,   RO_REGEXP_PROP_ATTRS,0,0},
3613:     {"sticky",     REGEXP_STICKY,      RO_REGEXP_PROP_ATTRS,0,0},
3614:     {0,0,0,0,0}
3615: };
3616: 
3617: static JSBool
3618: regexp_getProperty(JSContext *cx, JSObject *obj, jsval id, jsval *vp)
3619: {
3620:     jsint slot;
3621:     JSRegExp *re;
3622: 
          /* 前処理 */
3630:     slot = JSVAL_TO_INT(id);
3631:     if (slot == REGEXP_LAST_INDEX)
3632:         return JS_GetReservedSlot(cx, obj, 0, vp);
3633: 
3634:     JS_LOCK_OBJ(cx, obj);
3635:     re = (JSRegExp *) JS_GetPrivate(cx, obj);
3636:     if (re) {
3637:         switch (slot) {
3638:           case REGEXP_SOURCE:
3639:             *vp = STRING_TO_JSVAL(re->source);
3640:             break;
3641:           case REGEXP_GLOBAL:
3642:             *vp = BOOLEAN_TO_JSVAL((re->flags & JSREG_GLOB) != 0);
3643:             break;
3644:           case REGEXP_IGNORE_CASE:
3645:             *vp = BOOLEAN_TO_JSVAL((re->flags & JSREG_FOLD) != 0);
3646:             break;
3647:           case REGEXP_MULTILINE:
3648:             *vp = BOOLEAN_TO_JSVAL((re->flags & JSREG_MULTILINE) != 0);
3649:             break;
3650:           case REGEXP_STICKY:
3651:             *vp = BOOLEAN_TO_JSVAL((re->flags & JSREG_STICKY) != 0);
3652:             break;
3653:         }
3654:     }
3655:     JS_UNLOCK_OBJ(cx, obj);
3656:     return JS_TRUE;
3657: }

regexp_getProperty 関数内では、3630 行目で変数 slot に id の値である -1 が収められます。3638 行目でそれが定数 REGEXP_SOURCE と一致することで、-1 というプロパティ名に対し source プロパティと同じ値が返ってくるのです。

source に対する -1 のような、プロパティを表す整数を tiny id と呼びます。SpiderMonkey では、プロパティごとにゲッタ / セッタ用フック関数を設定するのではなく、特定のオブジェクトの全プロパティに共通するゲッタ / セッタ用フック関数を使うと、JavaScript コードからの tiny id によるアクセスがプロパティ名によるアクセスと同一視されてしまうそうです。

より短い SpiderMonkey の判別法

外部から tiny id を使ってプロパティにアクセスできるのは正規表現オブジェクトに限りません。文字列の場合は length プロパティに対する tiny id として -1 が割り当てられ、それにより外部から参照できます。これを使えば、結果が真偽値となるような SpiderMonkey の判別は次の 12 文字で可能です。

SM=""[-1]==0

「ブラウザ判別」としての不備

最初に紹介したページに掲載されている判別法は、いずれもブラウザの JavaScript エンジンの差異を用いたものです。これらは JavaScript エンジンの内部実装に大きく依存するものであり、エンジンのちょっとした変更で期待した動作をしなくなる可能性も否定できません。実際、そこで Firefox 3 の判別用と紹介されている以下のコードの結果は、Firefox 3.1 では false となります。SpiderMonkey は現在、関数オブジェクトでは古びて非効率的な方法を捨て、プロパティごとにゲッタ用フック関数を指定するようにしたからです。

//Firefox 3 by me:-
FF3=(function x(){})[-5]=='x'

また、一般にブラウザによってスクリプトの動作が違うといったとき、その原因がスクリプトエンジンであることはあまりありません。多くの場合はレイアウトエンジン (ここでは DOM 等オブジェクトモデルの提供もレイアウトエンジンの役割に含めます) の動作の違いによるものです。Safari と Google Chrome はともにレイアウトエンジンに WebCore を用いていますが、スクリプトエンジンはそれぞれ JavaScriptCore と V8 と異なっています。WebCore に起因する Safari の問題を回避するのに JavaScriptCore の判別を使っていれば、Google Chrome が同じ問題を持っていたときに対処できません。

特定ブラウザでの問題に対処するときは、プロパティやメソッドの存在確認や動作確認により処理を切り分けるべきです。それで判定できないときはブラウザ判別を行うことになりますが、そこで調べるのはブラウザではなくブラウザのレイアウトエンジンです。navigator.userAgent プロパティから調べるのなら、"Firefox" や "Safari" ではなく "Gecko/" や "WebKit" といった文字列の有無で判断しましょう。いずれにしても、問題がレイアウトエンジンにあるのなら、上記ページで紹介されているようなスクリプトエンジンに基づくブラウザ判別を使ってはいけません

まとめ

  • SpiderMonkey では、プロパティを表す tiny id によりプロパティにアクセスできることがあります。
  • 問題解決のためのブラウザ判別にはプロパティ / メソッドの存在確認や動作確認を用いるか、それが無理ならレイアウトエンジンを調べましょう。
  • スクリプトエンジンの判別はレイアウトエンジンの判別になりません。

参考文献

セキュリティ & プログラミングキャンプ・キャラバン 2008 京都2009年02月11日 16時54分

セキュリティ & プログラミングキャンプ・キャラバン 2008 京都に行ってきました。セキュリティ & プログラミングキャンプとは、IT 分野において優秀な若者の発掘・育成を目的として、22 歳以下の学生を対象に毎年夏に行われている合宿 (参加費無料) です。中学生の参加者もおり、普段はなかなかできないコンピュータの話題で盛り上がるそうです。

キャラバンはキャンプの紹介のためのイベントで、プログラミングとセキュリティに関する講義や昨年のキャンプの様子の公開などがありました。詳しい内容については「セキュリティ&プログラミングキャンプキャラバン2008 レポート - y_tsuda's blog - s21g」やそこからとだれるページ、はてなブックマークで「caravan2008」タグをつけられたページなどを参照してください。

個人的に印象に残ったのは、「セキュリティ & プログラミングキャンプの選考に受かるコツは?」という質問に対する回答です。絶対的なコツはないという前置きの下で、自分から情報発信をすること、活動が見えないと評価ができないという話でした。キャンプの選考に限らず、IT 分野において情報の発信は非常に重要だと思います。

私の場合、WEB+DB PRESS に連載を持たせていただいたり、はてなインターンに参加させていただいたりということがありました。こうした機会を得られたのは、ちょうど Ajax ブームが起きて JavaScript に注目が集まったという「タイミングがよかった」面もありますが、ひとつ確実にいえるのは、このブログを書いていなければそのような機会はなかったということです。

現在個人が情報発信する場として代表的なのはブログですが、ブログでは何かすごいことを書かないといけないというようなことはまったくありません。わからないなら何がわからないのか書けばいいのです。勉強会に行こうという話もありましたが、勉強会に行って何が楽しかったかブログに書くだけでもずいぶん違うと思います。私自身は筆不精でついついブログの更新が滞りがちなのですが (^^;)、そうしたブログを読んでいくのは楽しいし、新たな気づきが得られることもたくさんあります。

そのほかキャラバンでは、セキュリティ & プログラミングキャンプは 22 歳以下だけど、大学院生も参加できる IT Keys という取り組みもあるという話、脆弱性を報告したが製造者から「仕様です」と言われたときに、引き下がって愚痴るのではなく製造者やコミュニティに働きかけて悪い仕様を良い仕様に変えていこうという話がありました。

全体を通して主催者側のキャンプにかける意気込みが伝わってきて、若年層にはぜひキャンプに参加してほしいと感じました。同じ志を持った同年代の人たちと知り合えるというだけでも刺激的な体験になると思います。また、このような取り組みが今後も継続していけるよう、自分にできることがあれば積極的に協力していきたいです。

最後に、勉強会に行こうという話があったといいましたが、現在私が参加している主な勉強会に、関西の JavaScript 勉強会 Kanasan.JSSICP 読書会 (こちらは最近タイミングが合わずご無沙汰していますが) があります。Kanasan.JS の方はこの 2 月 22 日 (日曜日) に Greasemonkey チュートリアル読書会が開かれるので、お近くにお住まいの方はぜひ参加してみませんか。午前中には同会場で Haskell 勉強会 実践編も開かれます。