株式会社クリアコード > ククログ

ククログ


FirefoxでInternet Explorerのwindow.open()周りの挙動を再現する

日本におけるFirefoxの法人利用の代表的なニーズは「Internet Explorer向けに作られた古いWebベースの社内システムがあるためにIEを捨てられず、社外のWebを見に行くためにFirefoxを使いたい」というものです。しかし、それとは逆のパターンとして、「IE向けに作られたWebベースのシステムをFirefoxで使いたい」という場面もあります。後者の場合にはIEとFirefoxの細かな挙動の違いが問題となることがあり、window.open() でウィンドウを開くときの挙動の違いもその一つです。

名前付きの子ウィンドウ(タブ)の挙動の違い

window.open() はJavaScriptから利用できるブラウザの機能(いわゆるDOM0)で、子ウィンドウ(タブ)を開く物です。第2引数には「子ウィンドウの名前」を指定することができ、この方法を使って名前を付けて開かれた子ウィンドウ(タブ)は、<a target="ウィンドウ名" href="..."> のようにtarget属性を明示したリンクの読み込み先や、window.open() で同じウィンドウ名を指定した場合の読み込み先として再利用されることになります。

Internet Explorerでは、このような場面で以下のような挙動を取ります。

  • すでにその名前の子ウィンドウ(タブ)が開かれていた場合、とにかくそれを読み込み先として使う。
  • 読み込み先の子ウィンドウが最小化状態であれば、通常の状態に復帰させる。他のウィンドウの背後に隠れている場合であれば、最前面に持ってくる。

他方、Firefoxでは同じ場面で以下のような挙動を取ります。

  • 子ウィンドウはそれぞれのタブに対して個別に管理される。2つのタブそれぞれで子ウィンドウを名前を指定して開いた場合、それぞれのタブに対して別々の子ウィンドウが開かれる。その後のウィンドウ名を指定した読み込みも同様に取り扱われる。
    • →IEの挙動を前提に作られたWebアプリだと、無駄にたくさん子ウィンドウが開かれてしまう。
  • 読み込み先の子ウィンドウが最小化状態や他のウィンドウの背後に隠れている場合でも、そのままの状態で読み込みを行う。
    • →IEの挙動を前提に作られたWebアプリだと、「リンクやボタンをクリックしても(最小化された、または背後にあるウィンドウの中に読み込まれる場合があり)結果を画面上で確認できないという事が起こる。

Firefoxアドオンでの解決

Webアプリとしては、このような挙動の違いを吸収したり、Firefoxのような挙動でも問題が起こらないようにしたりと対策を取れればそれに越したことはありません。しかし、様々な事情からそれができない場合もあります。

そのようなお客さまからのご相談を受けて、Firefox上でIEの上記の挙動を再現するためのアドオンを開発しました。以下のリンクからインストールできます。

  • Merge Named Browser Windows:ウィンドウ名を指定して子ウィンドウ(タブ)が開かれた場合に、親ウィンドウ(タブ)を問わずその名前の既存のタブを探して閉じ、特定の名前のタブが1つだけ開かれているという状態を維持する。
  • Unminimize Browser Window On Load:最小化状態や背面にあるウィンドウにコンテンツが読み込まれた場合に、ウィンドウを復帰させフォーカスを与える。

主にセキュリティ向上のために、現代のWebブラウザはウィンドウ(タブ)間での情報の分離を徹底する方向で進化しています。また、迷惑な広告やいわゆるブラクラの類にユーザーが煩わされる事が無いよう、コンテンツ側からの操作によるウィンドウのフォーカス制御も制限されるようになっています。それらの変化は一般的なWebサイトを閲覧するユーザーにとって好ましいものですが、特定のWebアプリの使用頻度が高くなる法人利用では却って困った結果になる事もあります。

そのような特殊な場面での暫定的な対処のための方法として、一般的なWebアプリよりも強力な権限が与えられているアドオン(拡張機能)は有用な選択肢となる場合があります。Firefoxの法人利用で似たような事にお悩みの企業担当者さまがいらっしゃいましたら、お問い合わせフォームより是非ご連絡下さい。

タグ: Mozilla
2019-11-26

フリーソフトウェアの法人向けサポートの一環で行った開発元へのフィードバックの事例紹介

当社の法人向けサポートサービスでは、企業のお客様がフリーソフトウェアを使われていて遭遇されたトラブルの解決や原因究明、文書化されていない技術情報の調査などを有償にて行っています。「特殊な労働力を提供してお客様の役に立ち、その対価としてお金を頂く」ということで、ビジネスモデルとしてはシンプルです。

ところで、当社の理念は「自由と稼ぐの両立」です。前述のビジネスモデルから「稼ぐ」は容易に読み取れますが、これがどのように「自由」に繋がっているかは見えにくいかもしれません。

まず単純に、「ユーザーが増える事が自由なソフトウェアの推進になる」という事が言えます。それは「そのソフトウェアを使うと開発チームに広告収入が入る」といった直接的な還元に限りません。例えば、そのソフトウェアのユーザーが増えると、業界内でのそのソフトウェアの影響力が増すので、プロプライエタリな製品にとってはそのデータ形式やプロトコルを無視しにくくなり、ユーザーが特定の製品に囲い込まれるリスクが低減する、という効果が期待できるでしょう。

また、こちらがこの記事の本題なのですが、このビジネスの中で実際の使用環境で見つかった不具合を報告したり、実際の使用環境でのニーズを汲み取った提案をしたりという形で、そのソフトウェアの改善に寄与できます

ということで、以下、Mozilla製品の法人向けサポート由来のフィードバックの事例を4つご紹介します。

Bug 1540943 - Broken message body on forwarding a mail including invalid character(Mozilla Thunderbird)

これは、メールの本文にエンコーディング上不正な文字が含まれていると転送メールの本文が文字化けしてしまうという問題です。法人利用では様々なメールが頻繁に・大量にやり取りされるため、このような「普通は起こらないエッジケース」が起こる事があり、また、それがビジネス上の支障になってしまう場合もあります。この現象に遭遇されたお客さまの場合、取引先からのメールを転送しようとすると文字化けしてしまうという事でお困りでした。

原因と回避策を調査した結果、本文として転送する場合には発生を避けられない問題である事が分かったため、お客さまには「添付として転送」などの別の操作を使って回避して頂く事をお薦めし、開発元には上記のBugの通り、その時点で判明していた発生条件などの情報を報告しました。

その後、開発者の方の目に留めて頂き調査が進んで、最終的にはソースコード中の1行をピンポイントで修正(呼び出す関数を変更)して問題を解消するパッチが投入されました。変更点が少なかった事から、この修正はThunderbird 60のメンテナンスリリースにも反映される結果となっています。

Bug 1563665 - Replied mails are not marked as "replied" if the folder has a comma (,) in its substance file name(Mozilla Thunderbird)

これは、メールフォルダに対応するローカルディスク上のファイルの名前に , (半角コンマ)が含まれているとそのフォルダ内のメールに返信などの操作をしても「返信済み」のようなマークが付かないという問題です。Thunderbirdではメールフォルダ1つがディスク上の1ファイルとなるmbox形式が初期設定となっており、ファイル名はフォルダ名そのままではなくMUTF-7という特殊なエンコーディング方式で英数字に変換されているのですが、台北 のような特定の文字列で変換結果が &U,BTFw- となり , が含まれる事になって、全く予想もしていなかった所でこの不具合に遭遇してしまう場合があるという状況でした。

お客さまには、MUTF-7でのエンコーディング結果に , が含まれないようにフォルダ名を工夫するという回避策をご案内していますが、どんな文字列がエンコーディング後にこのパターンになるのかについては事前の予想が難しいため、残念ながら万全とは言えない回答となってしまいました。

この問題は原因そのものは判明したものの、古くからある設計上の判断に起因する問題ということで、どのような方向で対処するのが妥当かという技術的判断の落とし所が見つかるまで、修正にはまだしばらく時間がかかりそうです。

Bug 1569089 - The "-version" ("-v") command line option doesn't report version information when using cmd.exe(Mozilla Firefox)

これは、Firefoxの起動オプションの一部が特定の状況下で動作しなくなっているという問題です。Firefoxは実は firefox.exe -v という風に -v オプションを指定して起動すると一般的なコマンドと同様にバージョン情報を文字列として出力するようになっており、これを使って現在インストール済みのFirefoxのバージョンを確認するという運用を取られているお客さまがいらっしゃったのですが、FirefoxをESR68に更新するとバージョン情報が出力されなくなったために運用に支障が生じている、という事でお問い合わせを頂きました。

調査の結果、Bugに記載しているとおりこの問題そのものは回避が不可能という事が分かったため、Firefoxのバージョンを調べるには何かしら別の方法*1を使わなくてはならない旨をお客さまにはお伝えしました。

-v はあまり使われる事の無さそうなオプションで、我々も正直な所、今回の調査を行うまでオプションの存在自体を把握していませんでした。しかし、報告したBugは優先度P2 (クリティカルな問題に対して付けられる P1 の次に高い優先度)と設定されています。本稿執筆時点では未解決ですが、もしかしたら案外早く修正される事になるかもしれません。

Add managed storage support (Duplicate Tabs Closer)

こちらはFirefox本体ではなくアドオンへの改善提案です。「Duplicate Tabs Closer」は同じURLのタブを重複して開く事を制限するアドオンで、重複の検出基準や検出時の挙動について様々な設定ができるようになっています。しかしながら、それらの設定値はローカルストレージ領域を使って保存されており、システム管理者の側では設定を制御できません。ですので、法人利用であらかじめ動作を決めておきたい場合、アドオンのソースコードを編集して組み込みの初期設定を変更する必要があります。

お問い合わせを頂いたお客さまの導入環境向けにそのように改造する事自体は容易ですが、提供開始後にアドオンが更新されたらそれへの追従の必要が生じます。また、その後同様のニーズをお持ちのお客さまから相談を頂く度に改造版を作る必要もあります。

その一方、Firefoxのアドオン向けAPIにはManaged Storageという機能があり、専用のファイルもしくはポリシー設定用の policies.json に書いた設定をアドオン側で読み取る事ができます。もしDuplicate Tabs CloserがManaged Storageに対応していれば、アドオンのソースコードを毎回編集する必要はありません。

以上の事を踏まえて、Managed Storageから設定値を読み込んで反映するという動作を追加する変更を提案した物が、上記のプルリクエストです。こちらは作者の方のご理解を得る事ができ、無事にマージして頂けました。

法人向けサポートからアップストリームへフィードバックする事の意義

アップストリームへのフィードバックは開発の現場に直接関わる事に等しいため、的確なフィードバックには様々な知識が必要になってきます。FirefoxやThunderbirdのように「ソフトウェア開発者でないエンドユーザーも使う物」ではこの点がネックとなりがちなためか、ユーザー自身によってなされた報告にはどうしても、不正確な報告や発生条件の絞り込みが不足した報告が散見されます。また、多くの日本人にとっては英語自体がハードルとなって余計にフィードバックしにくいという問題もあります。当社が法人サポートの中で行っているフィードバックは、これらの点を補ってフリーソフトウェアを推進していく物と言えます。

ビジネス的観点においても、アップストリームへのフィードバックには意義があります。問題点をそのプロダクトの開発元にフィードバックしないで手元での回避だけを行っていると、プロダクトの更新や変更でその回避策が取れなくなる恐れがあり、将来的なリスク要因になります。早め早めにフィードバックしてプロダクト本体側で問題を解決する事によって、その種のリスクが低減され、より安定して運用できるようになる事が期待できます*2。安物買いの銭失いにならなければ、それはサービスの付加価値と言えます。

そうしてプロダクト自体の品質が高まって企業で採用しやすくなると、法人向けサポートの需要の拡大にも期待できます。また、当社内でフィードバック対象のプロダクトへの理解が深まり知見が増えれば、それだけサービスの品質が向上します。

開発プロジェクトにとっても、ユーザーとなるお客さまにとっても、サポートを提供する当社にとっても利益になるという事で、これは「自由と稼ぐの両立」という理念の1つの実践例となっているわけです。

まとめ

以上、当社が法人向けサポートサービスを通じて行ったフィードバックの例を挙げて、会社の理念をどのように実践しているかをご紹介しました。

当社の法人向けサポート事業はいわゆるSI業界の周辺にあり、直接の取引先やその先のお客さまには「お堅い」業界の会社さまも多くあります。そのような業界はオープンさ・自由さと縁遠いという印象を持つ方が多いかもしれませんが、実際にはその内側ではフリーソフトウェアが使われていることは珍しくありません。やり方次第ではその領域のビジネスと自由なソフトウェアの推進を同時に行えるという事例の1つとして、参考にして頂ければ幸いです。

また、業務上でのフリーソフトウェアの使用においてトラブルに遭遇されていて、フリーソフトウェア製品やプロジェクト側の知識の不足でお困りの企業の担当者さまがいらっしゃいましたら、お問い合わせフォームよりお問い合わせ下さい。

*1 Windowsのレジストリの情報を参照する方法が一般的です。

*2 理論上、そうして問題が解消されていった先には全く仕事が無くなるという未来がいずれ訪れる事になりますが、実際にはプロダクトもユーザー側の要件も変化し続けるため、そのような未来がすぐに訪れるという事は、幸か不幸かまだ無さそうです。

2019-09-09

Firefox ESR60からESR68に移行するには

2019年7月9日に、Firefox ESR (Extended Support Release) の最新バージョンとなるFirefox 68.0esrがリリースされました。

この記事では、最新版への移行を検討されている企業ユーザーの皆様に向けて、ESR68に移行するための主要なスケジュールと、ESR60→ESR68の主な変更点について解説します。

移行スケジュール

ESR68に関連する主だったタイムラインを表として整理します(公式スケジュール表より整理・抜粋)

ESR60ESR68メモ
2019年05月21日 (火)Firefox 60.7 ESR-
2019年07月09日 (火)Firefox 60.8 ESRFirefox 68.0 ESRFirefox 68ESR正式リリース
2019年09月03日 (火)Firefox 60.9 ESRFirefox 60.1 ESR
2019年10月22日 (火)-Firefox 68.2 ESRFirefox 60ESRサポート終了
2019年12月10日 (火)-Firefox 68.3 ESR

この表の最も重要なポイントは 2019年10月22日のESR68.2のリリース です。この日をもって、現行バージョンのESR60のサポートが終了するので、これが実質的なバージョンアップのデッドラインとなります。運用を担当されている方は、この日までに検証を終えて、各端末上でアップデートを実行できるようにする必要があります。

なお、日付はいずれも太平洋標準時基準(PST)です。日本時間では、おおむね当日の深夜帯から翌早朝にかけてのリリースとなります。この時差の問題は、「リリース日に合わせて計画を立てたが、肝心のバージョンが当日の営業時間内にリリースされなかった」というミスにつながるので、段取りを組み立てる際に頭にとめておく必要があります。

ESR68の主なトピック

ここからは、ESR60からESR68で何が変わったのかという本題に移ります。変更箇所そのものは非常に膨大で、数万単位のコミットが加えられています。弊社の標準的なガイダンスでは、そこから要点を絞って約25項目を議論します。この記事では、そこから組織運用上、影響の大きい11項目をトピック別にお伝えします。

Symantec社の証明書がすべて失効します

ESR68からSymantecが発行した証明書は「信頼されない証明書」とみなされるようになります。具体的には、HTTPSでアクセスしようとした場合、警告画面が出現して、先に進めないようになります。

Symantecの証明書警告

対象となる証明書は、Symantecの傘下ブランドのVeriSign・RappidSSL・Thawte・GeoTrustが含まれる点に注意してください。2017年に、Symantecは証明書事業をDigicertに譲渡しているので、この譲渡後に発行された証明書であれば問題ありません。とくに法人環境では社内向けのサイトが古い証明書を使っていないか確認しておく必要があります。

自社のサイトが該当しているか確認したい場合は、Symantec SSL Checkerというツールでチェックできます。

本件の背景を含めた詳細は、Mozilla Security Blogの記事を参照してください。

コンテンツブロック機能の対象が拡張されました

Firefoxにはトラッキングスクリプトをブロックする機能が含まれています。この機能が、ESR68で拡張され、暗号通貨マイニングなどを目的とするスクリプトもブロックできるようになりました。

これによって、設定可能な項目は4項目に増えました。以下に一覧表を示します。

種別説明デフォルト設定
トラッカー ユーザーの行動を追跡するスクリプト
例 - google-analystics.com
プライベートウィンドウのみブロック
トラッキングクッキー トラッカーによってセットされるCookie ブロックしない
暗号通貨マイニング ブラウザで暗号通貨をマイニングするスクリプト
例 - coinhive.com
ブロックしない
フィンガープリント採取ユーザーを一意識別する特殊な追跡スクリプト
例 - simility.com
ブロックしない

ブロック対象のリストはGitHubのレポジトリ mozilla-services/shaver-prod-lists で管理されています。Firefoxは定期的にMozillaのリスト配信サーバーと同期して、リストを取得します。リストの取得状況を確認したい場合は、アドレスバーに「about:url-classifier」と打ち込んでください。

注意が必要な点として、この機能を有効化した場合に、一部のサイトの動作への影響(主に埋め込み動画が再生されなくなる問題)が報告されている事に注意してください。

例) Bugzilla 1557363 コンテンツブロック有効時にibm.comの動画が再生できない

互換性を優先するか、安全性を優先するかに応じて、導入する設定を決める必要があります。

プライベートウィンドウでのアドオンの実行に許可が必要になります

アドオンがプライベートウィンドウでは原則として実行されなくなります。実行にはユーザーの明示的な許可が必要となり、ユーザーは画面から個別にアドオンを許可できるようになります。

プライベートウィンドウのアドオン実行

この変更は、企業運用でアドオンを一括導入している場合に問題になります。例えば、管理用のアドオンを導入している場合に、ユーザーがプライベートウィンドウを開いたときにアドオンが実行されなくなってしまうためです。

従来の挙動(プライベートウィンドウでも原則としてアドオンを実行する)に戻したい場合は、次の設定を適用して下さい。

pref("extensions.allowPrivateBrowsingByDefault", true)
RSSフィード機能が削除されました

FirefoxからRSSフィード機能(ライブブックマーク機能)が削除されました。従来はFirefoxにRSSフィードリーダーが標準搭載されていましたが、このリーダーがESR68からは使えなくなります。

RSSフィードリーダー機能

設定などで従来の挙動に戻すことはできないため、代替としてはウェブベースのリーダーを使うか、RSSフィードに対応したアドオンを導入する必要があります。

なお、ESR68に更新する際に、ライブブックマークのバックアップが自動的に作成されます。具体的には「Firefox feeds backup.opml」というOPML形式のファイルがデスクトップに作成されるので、他のリーダーに移行する場合は、このファイルをインポートしてください。

この削除の背景については、ブログ記事「Firefox removes core product support for RSS/Atom feeds」を参照してください。

Ctrl-Tabのタブ切替が「最終アクセス順」に変更になりました

ESR68ではCTRL-TAB押下時のタブ切り替えの挙動が大きく変わっています。CTRL-TABを押すと次のようなUIが表示されるようになりました(また、タブの切り替えの順番も変わっています)。

CTRL-TAB押下時の表示

この変更が適用されるのは、新規にFirefoxをインストールした端末のみです。ESR60がインストールされている環境でバージョン更新を行った場合には、新しいUIは有効化されません(この挙動は設定画面の『Ctrl+Tabで最近使用した順にタブを切り替える』の設定で管理されています)。運用する端末で使い勝手を統一したい場合は、次の設定を導入してください。

// falseで従来の挙動、trueで新しいUIを適用する
pref("browser.ctrlTab.recentlyUsedOrder", false);
ツールバーにFirefox Accountアイコンが追加されました

Firefox Account (Firefox Sync) とは、Mozillaのサーバーにブックマークや履歴を保存できる機能です。
ESR68では、ツールバーにFirefox Account用のアイコンが追加され、ここから機能にアクセスできるようになりました。

Firefox Accountアイコン

多くの企業では、情報セキュリティの観点からFirefox Account機能自体を無効化している場合が多いと思います。このアイコンを非表示にしたい場合には次の設定を導入します。

pref("identity.fxaccounts.toolbar.enabled", false);
インストールパスごとにユーザープロファイルが新規作成されます

Firefoxのインストールパスごとにプロファイルが新規作成されるようになります。たとえば、次のように2つのFirefoxをインストールしていたとします。

  1. /usr/lib/firefox
  2. /usr/local/lib/firefox

従来、この2つのFirefoxは起動時に同じユーザープロファイルを読み込んでいました。しかし、2つのFirefoxのバージョンが異なる場合に、(Firefoxのプロファイルは前方互換性がないので)バージョンの異なるFirefoxが交互にプロファイルを読み書きする結果、ブックマークの情報などが破損してしまう可能性がありました。

例)Support 1214384 Firefox 53にバージョンを戻すとブックマークが消失する

そこで、Firefox 68 ESRからは(1)と(2)のFirefoxは、起動時に別個のプロファイルを作成するようになりました。これによって、1つの端末に複数のFirefoxをインストールした場合にもプロファイルの破損が起こらなくなりました。

この仕組みの詳細については、Firefox 67以降のユーザープロファイルの仕様の詳細をご参照ください。

Windows 10の再起動時に自動起動するようになりました

Windows 10で、セッション終了時にFirefoxを開いていた場合、次の起動時にFirefoxが自動的に起動するようになっています。
一般的なアプリケーションの挙動と揃えた形になりますが、この機能を無効化したい場合は次の設定を導入します。

pref("toolkit.winRegisterApplicationRestart", false);
データ流出を起こしたサイトが検出されるようになりました

Firefoxに「データ流出を起こしたサイトを検知する機能」(Firefox Monitor機能)が導入されました。過去にデータ流出を引き起こしたサイトにアクセスした場合に、次のようなポップアップが表示されます。

Firefox Monitor機能

警告の表示対象となるサイトの一覧はFirefox Monitorの公式ページで確認できます。本記事の執筆時点で、約300サイトが対象サイトとして登録されています。

この機能を無効化したい場合は、次の設定を導入します。

pref("extensions.fxmonitor.enabled", false);
MSI形式インストーラの提供開始

従来はWindows向けにはexe形式のインストーラのみが提供されていましたが、MSI形式のインストーラが提供されるようになりました。Windows 7以降で利用することができます。

ダウンロード方法などの詳細についてはMozillaの公式ヘルプを参照ください。

自動更新の停止方法が変更になりました

企業環境では更新タイミングをコントロールするため、Firefoxの自動更新機能を無効化している場合が多いと思います。従来は、この無効化はAutoConfigを通じて実現することができましたが、ESR68からは「Policy Engine」という新しい仕組みの導入が必須になっています。

Policy Engineの詳細は、[Mozilla Firefox ESR60でのPolicy Engineによるポリシー設定の方法と設定項目のまとめ][20180512]を参照ください。自動更新の無効化が運用の前提になっている場合は、こちらの記事のガイドに沿ってPolicy Engineを導入してください。

まとめ

上述の通り、Firefox 68.0esrには、企業での運用に影響を与える変更が入っています。移行期限まで、あと2ヶ月を切りましたので、計画的に移行をお進め下さい。

タグ: Mozilla
2019-08-22

Firefoxのトラブルシューティング:Firefox 67以降のバージョンを起動すると意図せず別プロファイルが作られてしまう

2019年8月29日追記:この問題はFirefox 69以降およびFirefox ESR68.1.0以降では修正済みです。本稿はFirefox 67~68.0.x(ESR含む)に特有の現象についての解説となります。

Firefox 67以降のバージョンではFirefoxのインストール先ごとにプロファイルが作られるようになったという事をご紹介しました。しかし、起動の仕方によっては意図せず新しいプロファイルが作られてしまうという場面があります。

例えば、

  • デスクトップ上のショートカットからFirefoxを起動した時
  • コマンドプロンプト(cmd.exe)からパスを明示してFirefoxを起動した時
  • メールクライアントの本文内のURLをクリックして、URLを開く既定のアプリケーションとしてFirefoxを起動した時
  • テキストエディタから登録済みの外部アプリケーションとしてFirefoxを起動した時
  • Google Chrome用拡張機能を使ってリンク先をFirefoxで開いた時

のような場面で、同じ位置にインストールされているFirefoxなのに、それぞれ別々のプロファイルが作られてしまうという事が起こり得ます。これは何故でしょうか、そしてどうすれば回避できるでしょうか。

原因

この問題は、プロファイルを紐付ける対象のパスが正規化されず、大文字小文字が厳密に区別される事が原因で発生します。

通常、Windowsではファイルパスの大文字小文字は区別されません*1。また、ファイルには古いアプリケーション向けに、MS-DOS互換の短い名前が付いている場合があります。これらの理由から、一般的にWindows上では以下のパス表記はすべて等価に扱われます。

  • C:\Program Files\Mozilla Firefox(正式)
  • c:\Program Files\Mozilla Firefox(ドライブレターのみ小文字)
  • c:\program files\mozilla firefox(すべて小文字)
  • C:\PROGRAM FILES\MOZILLA FIREFOX(すべて大文字)
  • C:\PROGRA~1\MOZILL~1(MS-DOS互換の8.3形式)

ところが、Firefoxがインストール先パスとプロファイルとを紐付けるために使う情報は、当該フォルダのNTFSでのオブジェクトIDでもなければ正規化されたパス文字列でもなく、これらを単純な文字列として計算したCity Hash値です。City Hash値*2はパスの表記の仕方ごとに

  • C:\Program Files\Mozilla Firefox308046B0AF4A39CB
  • c:\Program Files\Mozilla Firefox4FD4C3CBEC5FE500
  • c:\program files\mozilla firefox4D589497A5E2C2E9
  • C:\PROGRAM FILES\MOZILLA FIREFOXB86E4C730D96F15B
  • C:\PROGRA~1\MOZILL~1A86124024953D658

と変化し、FirefoxはこのCity Hash値に対してプロファイルを作成し紐付けるため、「起動する時のパスの指定の仕方によってプロファイルが変わってしまう」という事が起こる訳です。

Firefoxを起動する手段が関連付けのみに依っているのであれば、この問題には遭遇しにくいです。問題が起こりやすいのは、ユーザーが明示的に文字列としてパスを指定する場合です。上記の例の「テキストエディタの登録済みの外部アプリケーション」や「Google Chrome用拡張機能で起動する外部アプリケーション」としてFirefoxを指定する場面では、正式な表記とは大文字小文字が異なるパスを自分で入力してしまいがちだからです。

対処

この問題はBug 1555319 - The case insensitive file system on Windows makes it possible to run Firefox with seemingly different installation pathsとして既にトラックされており、Firefox 69では修正済みとなっています*3。ただ、Firefox 68向けには修正がバックポートされていない*4ため、Firefox 67から68までのバージョンを使っている場合は手動での対策が必要です。

原因は上記の通りなので、対処としては、Firefoxを起動するためにパスを指定している箇所すべてについて、パス表記を正式な表記に揃えるという事になります。

正式なパス表記は、ファイルのプロパティを開いて「全般」タブの「場所」欄に表示される物です。firefox.exeをデスクトップなどにドラッグしてショートカットを作成し、そのプロパティを開いて「リンク先」欄の内容を参照しても同じパスを得られます。これらの方法で正式なパス表記を調べたら、Firefoxのパスを指定していた箇所を探して地道に設定し直していきましょう。

まとめ

Firefox 67以降のバージョンについて、起動の方法によって違うプロファイルが使われてしまう場合があるという問題の原因と回避策を解説しました。

当社ではFirefoxの法人での使用上で起こる様々な問題について、原因や回避方法の調査を有償にて承っています。社内でのFirefoxの運用でお困りのシステム運用担当者の方がいらっしゃいましたら、ぜひメールフォームよりお問い合わせ下さい。

*1 NTFSであればfsutilコマンドを使って特別な属性を与えることでファイル名の大文字小文字を区別するようにする事もできますが、伝統的なWindowsアプリケーションはファイルパスの大文字小文字が区別されない前提で設計されているため、一般的にはこの機能は使われていません。

*2 ファイル名部分を除外した、フォルダのパスのハッシュ値が使われます。

*3 Firefox側でパスを正規化してからCity Hash値を求めるようになりました。

*4 Firefox 69のブロッカーバグとして修正されたものの、Firefox 68に修正をバックポートするには遅すぎたとの事です。Firefox ESR68については、Firefox ESR68.1.0の時点で修正がバックポートされる見込みです。

タグ: Mozilla
2019-07-05

Firefox 67以降のユーザープロファイルの仕様の詳細

Firefox 67の新機能の1つに、インストール先ごとにプロファイルが分けられるようになったという物があります。

この記事では、Firefoxのユーザープロファイルの仕組みの歴史的な経緯や、プロファイル切り替えの仕組み自体がどのように使われてきたかという点を踏まえながら、Firefox 67での新機能の性質を解説します。

Firefoxの「ユーザープロファイル」

Firefoxのユーザープロファイルの仕組みは、元を辿ると、その前身となったNetscape Communicatorのユーザープロファイルの仕組みに行き着きます。

Netscape Communicatorが現役だった頃に広く使われていたWindows 95やWindows 98は、原則としてユーザーは1人だけであるという前提で設計されていたため、複数人で同じPCを共用する場合にユーザーを使い分けるという事ができませんでした。そのためNetscape Communicator独自の概念として「使用者ごとにユーザープロファイルを作り分けて、設定値やブックマークなどはその中で管理する」という仕組みが用意されました。

(図:Netscape Communicatorのユーザープロファイルをユーザーごとに使い分ける様子)

その一方で、Windows XP以降のバージョン*1ではOS自体が複数ユーザーを想定した設計になっています。アプリケーション側で個別にユーザープロファイルを管理する必要は無く、アプリケーションの設定を使用者ごとに使い分けたい場合、単純にWindowsのユーザーを使い分ければよいという事になっています。

(図:Windowsのプロファイルをユーザーごとに使い分ける様子)

以上のような歴史的な経緯があり、Firefoxのユーザープロファイルは「Windowsのユーザープロファイルごとに、複数のFirefoxのプロファイルが存在しうる」という少々複雑な状況となっています。

(図:Windowsのプロファイル上に複数のFirefoxのプロファイルが存在する様子)

実際には、Windowsのユーザーを使い分ければよい現状においては、使用者ごとにFirefoxのプロファイルを使い分けないといけないという状況はまず発生しません。よって、Firefoxのユーザープロファイルは今では1人のユーザーが、「仕事用」「開発用」「趣味用」といった何らかの用途ごとに使い分けるという用途が主流になっています。

(図:Windowsのプロファイル上で、用途ごとにFirefoxのプロファイルが使い分けられている様子)

「ユーザープロファイルの使い分け」から「コンテキストやコンテナーの使い分け」への変化

ただ、一般的なユーザーが普段使いにおいてFirefoxのユーザープロファイルを使い分けるということは、実際にはあまり無いものと思われます。その最大の理由は、運用の面倒さです。

Firefoxのユーザープロファイルを使い分けるには、

  • Firefoxの起動オプションに-pオプションを指定してプロファイルマネージャを起動し、そこでプロファイルを管理する。
  • about:profilesでプロファイルを管理する。

このいずれかの方法でプロファイルを作成し、さらに、

  • "C:\Program Files\Mozilla Firefox\firefox.exe" -p private(個人的な使用)
  • "C:\Program Files\Mozilla Firefox\firefox.exe" -p work(仕事での使用)
  • "C:\Program Files\Mozilla Firefox\firefox.exe" -p test(検証用)

のように起動オプションを変えたショートカットを複数用意する必要があります。

また、これだけでは「個人用のFirefox」「仕事用のFirefox」を並行して起動することができません。というのも、Firefoxのプロセスが既に起動している場合、別のプロセスで新たにFirefoxを起動しようとすると、既に起動している方のプロセスの方にプロセスが吸収されてしまう(そちらでウィンドウやタブが開かれる)という結果になるからです。これを回避するためには、

  • "C:\Program Files\Mozilla Firefox\firefox.exe" -p private
  • "C:\Program Files\Mozilla Firefox\firefox.exe" -p work -no-remote
  • "C:\Program Files\Mozilla Firefox\firefox.exe" -p test -no-remote

のように、普段使いの(URLなどの関連付けからFirefoxが起動された時に使う)プロファイルを除いて他のショートカットには-no-remoteオプションを指定し、前述の「既存プロセスに吸収される」挙動を無効化する必要があります。

また別の方法として、プロファイルマネージャを使用せずに以下のようにする方法もあります。

  • "C:\Program Files\Mozilla Firefox\firefox.exe" -profile "C:\Users\username\private-profile" -no-remote

-profileオプションで任意のフォルダをフルパスで指定すると、プロファイルマネージャの管理下にないフォルダであっても、それをプロファイルとしてFirefoxを起動できます。実験や検証のためのプロファイルをいくつも併用したり使い捨てにしたりという使い方をしたい場合、プロファイルマネージャを使わずともフォルダを用意するだけで済むため、この方法は非常に便利です。ただしこの場合も、-no-remoteを付けないと「既に起動している方のプロセスの方にプロセスが吸収される」という動作になってしまう点には注意が必要です。

これだけ面倒な手順が必要なわりに、一般的なユーザーが普段使いで得られるユーザー体験はあまり良い物ではありません。というのも、実際の利用局面では「仕事中に見つけた面白そうな記事を個人用にブックマークしておく」「個人的な使用中に見つけた仕事に関係する記事を、仕事中に履歴から探す」といった場面が生じやすいのに対し、履歴やブックマークなどがプロファイルごとに別々に保存されていると、そういった「用途をまたいだ情報の共有」ができないからです。

この問題の解決策として現在のFirefoxには、「1つのユーザープロファイルで、閲覧履歴やブックマークなどは共有しつつ、起動中の1つのプロセスの中でログイン状態やCookieなどの認証情報は分離する」という事を可能にする、以下の機能が備わっています。

  • プライベートウィンドウ(そのウィンドウの中で閲覧したページには、通常モードで閲覧した時のCookieなどが通知されない)
  • コンテナータブ(用途ごとに認証情報を切り替える)

これらの機能は、「諸々の不便を被ってでも厳密にプロファイルを分けたい程ではない」「しかし、ログイン状態やCookieなどの認証情報は用途ごとに切り替えたい」という、実際の使用局面でよく見られるニーズに応えるものとなっています。

(図:コンテナーを使い分けている様子)

ユーザープロファイルの使い分けよりもはるかに手軽に使えるため、これらの機能で充分に用を果たせる場面では、ユーザープロファイルの使い分けのニーズはほぼ無くなったと言ってよいでしょう。

それでも残った「ユーザープロファイルを使い分ける必要がある場面」

以上のような経緯から、現在のFirefoxで「ユーザープロファイルを使い分けなければその目的を達成できない」場面は非常に限られています。その代表的な例が、複数バージョンのFirefoxの並行起動です。

(図:複数バージョンのFirefoxを専用プロファイルで使い分けている様子)

例えば、前述の手順を用いて複数バージョンのFirefoxを並行起動する場合、以下のように起動オプションでプロファイルを指定する事になります。

  • "C:\Program Files\Mozilla Firefox\firefox.exe" -p default(64bit版Firefox)
  • "C:\Program Files (x86)\Mozilla Firefox\firefox.exe" -p 32bit -no-remote(32bit版Firefox)
  • "C:\Program Files\Mozilla Firefox Beta\firefox.exe" -p beta -no-remote(ベータ版)

Firefox 67からの変更は、まさにこの用途のためのものです。言い換えると、Firefox 67以降のバージョンでは、「インストール先が異なるFirefoxの実行ファイルごとに、専用のユーザープロファイルを使用する」使い方が、特にユーザー側での工夫無しに自動的に行われるようになっているという事になります。

Firefoxが自身のインストール先を識別する処理の流れ

では、このような挙動は一体どのようにして実現されているのでしょうか。特に、「特定のインストール先のFirefox」と「特定のユーザープロファイル」とはどのように紐付けられているのでしょうか。鍵になるのは、インストール先のパスに基づくCity Hash値です。

Firefox 67以降のバージョンでは、Firefoxはプロセスの起動時に、自身の実行ファイルが置かれたフォルダの位置からCity Hashのアルゴリズムに基づいてハッシュ値を求めます。例えばFirefoxがC:\Program Files\Mozilla Firefoxにインストールされていた場合、値は308046B0AF4A39CBのようになります。内部的にはこれは「install hash」と名付けられている模様です。

次にFirefoxは、ユーザープロファイルが置かれている%AppData%\Mozilla\Firefox(具体的には C:\Users\ユーザー名\Appdata\Roaming\Mozilla\Firefox)の位置にあるprofiles.iniから[Install(install hash)]という名前のセクションを探します。これは通常、以下のような内容になっています*2

; こちらは従来からあるセクション
[Profile0]
Name=default
IsRelative=1
Path=Profiles/xxxxx.default

; 自動的に追加されたセクション
[Install308046B0AF4A39CB]
Default=Profiles/xxxxx.default
Locked=1

このDefaultの値がprofiles.iniから見た相対パスとなっており、このパスを解決することで、特定のインストール先のFirefoxに対して紐付けられたプロファイルフォルダを特定できるというわけです。この仕組みがあるお陰で、関連付け等からFirefoxが起動された場合などで起動オプションでプロファイルが明示されていなくても、適切なプロファイルが自動的に選択されるようになっています。

ただし、同じインストール先であっても、起動時のパスの指定の仕方によってはプロファイルが意図せず切り替わってしまう場合があります(別記事にて詳細な解説)。Firefoxのパスを明示的に指定する場面では、必ず正式なパス表記を使うように注意して下さい。

なお、この機能について「Firefoxのバージョンごとにプロファイルが別れる」というような説明がなされている場合がありますが、以上の仕組みを見て分かる通り、プロファイルはあくまでFirefoxのインストール先のパスに紐付いています。インストール済みのFirefoxに別のバージョンのFirefoxを上書きインストールしたり、Firefox自身の自動更新機能で新しいバージョンに更新したりした場合であっても、Firefoxのインストール先が変わらなければ、プロファイルは以前の物が引き続き使われます。「Firefoxを更新したらその度に新しいプロファイルが作られる」という事はありません。(逆に言うと、この機能を使ってプロファイルを使い分けるためには、必ずそれぞれのバージョンのFirefoxを別々の位置にインストールする必要があります。)

Windowsのレジストリ上での取り扱い

上記のinstall hashはWindowsのレジストリ上でも使われています。

従来は、Windowsのレジストリ上はFirefoxはあくまで「Firefox」という名前の1つのアプリケーションとして扱われており、複数のバージョンを異なるフォルダにインストールした場合は最後にインストールした物(最後にインストーラがレジストリに書き込みを行ったバージョン)がその代表として扱われるようになっていました。例えばシステム標準のブラウザの選択肢の項目は

  • HKEY_LOCAL_MACHINE\SOFTWARE\Clients\StartMenuInternet\Firefox
  • HKEY_CURRENT_USER\Software\Clients\StartMenuInternet\Firefox

とその配下に記録された情報に基づいて表示されていました。

Firefox 67以降のバージョンでは、この情報がインストール先ごとに書き込まれるようになっています。

  • HKEY_LOCAL_MACHINE\SOFTWARE\Clients\StartMenuInternet\Firefox-(install hash)
  • HKEY_CURRENT_USER\Software\Clients\StartMenuInternet\Firefox-(install hash)

同様に、関連付け対象のファイルやデータの型も

  • HKEY_CLASSES_ROOT\FirefoxHTML-(install hash)
  • HKEY_CLASSES_ROOT\FirefoxURL-(install hash)

のようにそれぞれインストール先ごとに登録されるようになりました。このように、Windowsのレジストリ上はインストール先ごとのFirefoxはそれぞれ別々のアプリケーションとして認識されています

アプリケーションの一覧上でそれぞれのインストール先を識別できない問題の回避策

以上の通り、Windowsのシステム上は各インストール先のFirefoxは別々の物として認識されているわけですが、ここで1つ問題があります。内部的にはそれぞれ別々のinstall hashを伴って認識されているものの、表示されるアプリケーション名としてはどれも「Firefox」となるため、標準のブラウザや関連付けの対象を選択する場面で同じ名前の項目がいくつも並んでしまうという事が起こってしまうのです。

(複数のバージョンのFirefoxが同じ名前で並んでいる様子)

現在の所、この問題を解決するには手動でのレジストリの編集が必要です。この一覧に表示されるアプリケーション名は HKEY_CLASSES_ROOT\Local Settings\Software\Microsoft\Windows\Shell\MuiCache というキーの値が参照されており、

  • C:\Program Files\Mozilla Firefox\firefox.exe.FriendlyAppName のデータ→Firefox Release
  • C:\Program Files\Mozilla Firefox ESR\firefox.exe.FriendlyAppName のデータ→Firefox ESR

のように実行ファイルのパス.FriendlyAppNameという名前の値のデータをそれぞれ編集しておくと、インストール先ごとの項目が分かりやすい名前で表示されるようになります。

(複数のバージョンのFirefoxにそれぞれ別の名前が付いている様子)

起動オプションの作用はどう変わったか

上記の通り、Firefox 67以降のバージョンは自分自身のインストール先に基づいて適切なプロファイルを自動的に選択するようになったわけですが、では、今まで使われていた「起動オプションでプロファイルを明示する」機能はどのような影響を受けたのでしょうか。

-p プロファイル名

前述した通り、Firefox 67以降のバージョンでは%AppData%\Mozilla\Firefox\profiles.iniに以下のように[Install(install hash)]という名前のセクションが追加されます。

; 従来からあるセクション
[Profile0]
Name=default
IsRelative=1
Path=Profiles/xxxxx.default

; 自動的に追加されたセクション
[Install308046B0AF4A39CB]
Default=Profiles/xxxxx.default
Locked=1

この時、2つのセクションで同じパスが参照されている点に注目して下さい。Firefoxは参照先のパスが一致する2つのセクションを関連付けて認識しています。そのため、以下の2つのコマンド列は同等として扱われます

  • "C:\Program Files\Mozilla Firefox\firefox.exe" (install hashに基づいて自動的にプロファイルを選択)
  • "C:\Program Files\Mozilla Firefox\firefox.exe" -p default-pオプションでプロファイルを明示)

引数無しで起動したFirefoxのプロセスがある状態でプロファイルを明示してFirefoxを起動しても、逆に、プロファイルを明示して起動したプロセスがある状態で引数無しでFirefoxを起動しても、既に起動している方のプロセスに処理が吸収される(既存プロセスの方でウィンドウまたはタブが開かれる)という、従来の-no-remoteオプション無しの動作と似た結果になります。

同じバージョン・同じインストール先のFirefoxを、別プロセス・別プロファイルで起動する

別のプロファイルを指定して別のプロセスを並行動作させたい場合、従来は-p 別のプロファイル名と指定するだけでは不十分で、-p 別のプロファイル名 -no-remoteと指定する必要がありました。Firefox 67以降のバージョンではこの点が改善され、-p 別のプロファイル名と指定するだけで別プロセスのFirefoxを並行動作させられるようになりました

従来は-no-remoteオプションが必須だったため、必然的に、これらのオプションを指定したショートカットからFirefoxを多重起動しようとすると、「既存プロセスに処理が吸収されない」「しかし、新しいプロセスで使おうとしているプロファイルは、別のプロセスによって既に使用中」ということで、「Firefoxは起動していますが応答しません。新しいウィンドウを開くには既存のFirefoxをプロセスを終了させなければなりません。」というメッセージが表示されてしまうという結果になっていました。Firefox 67以降では、この制限に煩わされる事はもうありません。

-profile プロファイルのフルパス

プロファイルをフルパスで指定して起動する場合も同様に、-no-remoteオプション無しでの別プロセス起動が可能となりました。以下のように起動オプションを指定すると、それぞれのプロファイルでFirefoxのプロセスが並行して動作する状態になります。

  • "C:\Program Files\Mozilla Firefox\firefox.exe" -profile "C:\Users\username\profile1"
  • "C:\Program Files\Mozilla Firefox\firefox.exe" -profile "C:\Users\username\profile2"

この場合、同じ起動オプションでFirefoxを多重起動すると、既に起動していてそのプロファイルを使用しているプロセスに処理が吸収されます。

また、条件を整えると、「起動オプションでプロファイル名を明示する場合」と「フルパスで明示する場合」を等価に扱うことや、あるいは、「プロファイル名を明示」「フルパスで明示」「プロファイルを明示しない」の3つを等価に扱うという事も可能になっています。

重要なのは以下の点です。

  • 当該プロファイルがプロファイルマネージャの管理下に入っている(profiles.iniにそのフォルダの情報が記載されている)。
  • 当該プロファイルのプロファイルマネージャ上の名前と、フォルダ名とが一致している。

以下は、profiles.iniを編集して上記の条件を満たすように設定したプロファイルの例です。

[Profile0]
;Name=default
Name=xxxxx.default
IsRelative=1
Path=Profiles/xxxxx.default

Firefoxのプロファイルマネージャはプロファイルを作成する際、表示用の名前の前にランダムな文字列を付与してフォルダを作成します*3この表示名が記載されているName=の行を編集し、表示名がプロファイルのフォルダ名と一致する状態にします。この状態であれば、以下の2つのコマンド列は等価に扱われるようになります。

  • "C:\Program Files\Mozilla Firefox\firefox.exe" -profile "%AppData%\Mozilla\Firefox\Profiles\xxxxx.default"
  • "C:\Program Files\Mozilla Firefox\firefox.exe" -p xxxxx.default

また、install hashとの紐付けが

[Profile0]
Name=xxxxx.default
IsRelative=1
Path=Profiles/xxxxx.default

[Install308046B0AF4A39CB]
Default=Profiles/xxxxx.default
Locked=1

のように行われていれば、以下の3パターンがすべて等価に扱われるようになります。

  • "C:\Program Files\Mozilla Firefox\firefox.exe" -profile "%AppData%\Mozilla\Firefox\Profiles\xxxxx.default"
  • "C:\Program Files\Mozilla Firefox\firefox.exe" -p xxxxx.default
  • "C:\Program Files\Mozilla Firefox\firefox.exe"

ただ、前述した通り、Firefox 67以降での新機能の恩恵を受けるためには、プロファイルをプロファイルマネージャの管理下に置く必要がある、という点に注意が必要です。「フルパスでプロファイルを明示したい場面」と「プロファイル指定無しでプロファイルを自動認識させたい場面」が重なる事はあまりありませんが、この両方のニーズを同時に満たしたいという場合*4には、見落としが生じないようにくれぐれも気をつけて下さい。

まとめ

以上、Firefox 67以降での「Firefoxのインストール先ごとのユーザープロファイルを使用する挙動」の詳細について解説しました。

Google Chromeが覇権を取った現在においては、Firefoxのニーズは一般ユーザーよりも(おそらくは検証用として)Web制作・Webアプリ開発を業としている人の方に偏っている模様です。「複数バージョンを並行して起動して検証する」という場面で特に有用なこの機能が導入されたのも、そのような背景があったからという事ではないかと考えられます。筆者ほど多くのバージョンを使い分ける事はそうそう無いと思われますが、通常リリース版・ベータ版・ESR版の3つを並行起動できるだけでも検証の負担は下がります。皆さんもこれを機に試してみてはいかがでしょうか。

また、今回の調査は現在Firefox ESR60を運用中のお客様にFirefox ESR68をご案内するにあたって、変更点の詳細を把握するために行いました。当社の法人向けFirefoxサポートでは、詳細な技術情報が提供されていない新機能の詳細の調査も有償にて承っております。「SIの業務において、お客さまの承認を得るために詳細なエビデンスの提出が求められているが、どこを対象に調査すればよいのか分からない」といった問題でお困りの方は、メールフォームよりぜひお問い合わせ下さい。

*1 正確には、Windows 2000やそれ以前のWindows NTなども含みます。

*2 Firefox 67以降のバージョンを初めて起動した時に、このような情報が追記されます。

*3 これは、ユーザープロファイルの位置を決め打ちで特定しにくいようにして、悪意の攻撃者の被害を受けにくくするための工夫です。

*4 例えば、web-ext経由でFirefoxを起動して常用しているというような場合。

タグ: Mozilla
2019-06-14

Thunderbirdアドオン「CardBook(連絡先)」でローカルに保存されるデータの暗号化に対応しました

CardBookと企業利用

皆さんはCardDAVという仕様をご存じでしょうか? CardDAVはWebDAVのプロトコルを使ってLDIF形式のアドレス帳をやり取りするという物で、これを用いると「読み書き両方を行えて、内容が複数PC間で同期される」という種類のリモートアドレス帳を汎用の物として実現することができます。CardDAVサーバーとして振る舞える製品にはownCloudやDAViCalなどがあり、読み取り専用に設定したリモートアドレス帳を複数人で共有するという事もできますので、企業利用では重宝する場面がありそうです。

このように便利なCardDAVですが、残念ながらThunderbirdは本体の機能としては対応していません。CardDAVベースのリモートアドレス帳を使うにはアドオンをインストールする必要があります。CardBookは、そのようなCardDAV対応のためのアドオンの一例です。

ところで、企業によっては個人情報の取り扱い方について、「顧客や取引先の個人情報をローカルに保存する際は必ず暗号化する」といったプライバシーポリシーを定めている場合があります。前出のCardBookはリモートアドレス帳のデータをIndexedDBを使用してローカルにキャッシュする設計で、この時のデータは暗号化されないため、そのままでは前述のポリシーに抵触するので採用できないという事になります。

そのような背景から、「CardBookでローカルに保存されるデータを暗号化したい」というご相談を頂き、成果を開発元に還元する前提で先行して作業を進めていたのですが、残念ながら受注には至らず、手元には実現可能性の調査のために行った試験的な実装が残るという結果になりました。しかしせっかく実装した物をそのまま放置しておくのも勿体なかったので、CardBookプロジェクトに還元したところ、標準機能の1つとして取り込まれるに至りました。現在リリース済みのCardBook 33.9以降のバージョンでは、設定画面でチェックボックスをONにすればローカルデータの暗号化が有効になるようになっています。

以下、CardBookのローカルデータベースの暗号化を実現するにあたって行った具体的な内容をご紹介します。

IndexedDBに格納するデータの暗号化と復号

幸い、Thunderbirdの基盤であるGeckoには、暗号化のための汎用APIであるWeb Crypto APIが実装されています。あるのなら使わない理由はありませんので、CardBookでもデータの暗号化はWeb Crypto APIによる共通鍵暗号で行う事にしました。何故公開鍵暗号ではなく共通鍵暗号なのかについては別項で詳しく述べていますので、そちらも併せてご覧下さい。

実装は、まず暗号化・復号を行う専用のモジュールを追加した上で、IndexedDBの読み書きを行うモジュールの書き込み用のデータを用意する箇所に暗号化処理を読み込んだデータを検証する箇所に復号処理を仕掛けることで、他のモジュールに影響を与えず透過的に動作するような組み込み方としました。

この時気をつけなくてはならないポイントとして、暗号化をどのタイミングで行うかという点が挙げられます。以下、暗号化を行っている実際の箇所を抜粋しながら説明します。

元々の設計では、IndexedDBへのデータ書き込みは以下の要領で行われていました。

addCard: unction (aDirPrefName, aCard, aMode) {
  var db = cardbookRepository.cardbookDatabase.db;
  // トランザクション開始
  var transaction = db.transaction(["cards"], "readwrite");
  var store = transaction.objectStore("cards");
  var storedCard = aCard;
  // データの書き込み
  var cursorRequest = store.put(storedCard);

  // 以下、成功時・エラー時の処理
}

ここに暗号化処理を組み込むのですが、Web Crypto APIは暗号化したデータがPromiseで返されるため、値を使うには.then()のコールバック関数で受け取るか、awaitで値の解決を待つ必要があります。コールバック関数を使うスタイルで実装するにはこのメソッドの書き方を大幅に変えなくてはなりませんが、asyncキーワードとawaitを使うと、この同期処理の関数を容易に非同期処理に対応させることができます。

addCard: async function (aDirPrefName, aCard, aMode) { // asyncキーワードを追加
  var db = cardbookRepository.cardbookDatabase.db;
  // トランザクション開始
  var transaction = db.transaction(["cards"], "readwrite");
  var store = transaction.objectStore("cards");
  // 暗号化処理を追加
  var storedCard = cardbookIndexedDB.encryptionEnabled ? (await cardbookEncryptor.encryptCard(aCard)) : aCard;
  // データの書き込み
  var cursorRequest = store.put(storedCard);

  // 以下、成功時・エラー時の処理
}

当初はこの例のように、書き込みを行う直前で暗号化を行うようにしていました。しかし実際に動作させてみると、これではIndexedDBでのデータ書き込みに失敗するという結果になりました。何故でしょうか?

実は、IndexedDBでのデータ書き込みはトランザクション開始から書き込みまでを同期的に(同じイベントループ内で)行う必要があります。この例ではトランザクション開始後にawaitを使ってしまっているせいで、store.put(storedCard)が次のイベントループでの実行となってしまい、そのせいで書き込みに失敗してしまうという訳です。

そのため最終的な実装では、以下の例のようにトランザクション開始前に暗号化を終えておくようにしました。

addCard: async function (aDirPrefName, aCard, aMode) {
  // 暗号化
  var storedCard = cardbookIndexedDB.encryptionEnabled ? (await cardbookEncryptor.encryptCard(aCard)) : aCard;
  var db = cardbookRepository.cardbookDatabase.db;
  // トランザクション開始
  var transaction = db.transaction(["cards"], "readwrite");
  var store = transaction.objectStore("cards");
  // データの書き込み
  var cursorRequest = store.put(storedCard);

  // 以下、成功時・エラー時の処理
}

これなら、トランザクション開始から書き込みまでが同期的に行われるため問題ありません。

復号時には、特にこのような注意は必要ありません。また、元々IndexedDBからのデータ読み取りは結果が非同期で返されるので、CardBookのデータ読み込み処理もその前提で設計されていました。そのため、IndexedDBから返ってきたデータを非同期で復号した上で返却するという処理を挟み込んでも、CardBookのデータ読み込み処理全体としてはインターフェースを変えずに済んだのでした。

パスワード入力を求める方式にしなかった理由

CardBookのローカルデータ暗号化では、暗号化・復号に使う共通鍵は、バックグラウンドで自動生成した物を暗黙的に使い、鍵自体をユーザープロファイル内に保存する形としました。

「暗号化されたデータと鍵を同じ場所に置いておくのでは、暗号化の意味が無いじゃないか」と思うでしょうか? 実際、変更をフィードバックした際にもCardBookプロジェクトの開発者の方からも「パスワード入力を求める方式にした方がいいのではないか?」という質問がありました。Web Crypto APIの機能を使うとユーザーが入力したパスワードから秘密鍵を作る事もできる(Web Crypto APIの解説記事の「パスワードを鍵に変換する」の項をご参照下さい)のに、そうしなかったのは何故でしょうか。

ここで一旦、パスワードの安全な運用という事を考えてみましょう。パスワードを自分で記憶しておきその都度入力するという方式は、一見すると安全なように思えます。しかしながら、運用の仕方によっては却って危険になる場合があります。

  • パスワード入力には、肩越しに入力の様子を覗き見るショルダーハックや、キーの入力を監視するキーロガーなどによってパスワードを盗み取られるリスクがあります。パスワード入力の頻度が多ければ多いほど、このリスクは高まります。
  • 人間の記憶容量には限りがあるため、あまり複雑なパスワードを複数覚えるという事はできません。そのために「同じパスワードを何度も使い回す」「推測が容易なパスワードにする」といった事が行われてしまい、そうなると却って危険な状態となります。
  • 定期的なパスワード変更にも、同様の問題があります

これらの理由から、パスワードの入力は「複雑で憶えにくいパスワードを1つだけ覚える」「それをマスターパスワードとして使い、それ以外はパスワードマネージャに憶えさせる」という運用にするのが比較的安全だというのが現在の定説となっています*1

「企業でThunderbirdを使う」というシチュエーションでは、「PCのログオン」「受信メールサーバーの認証」「送信メールサーバーの認証」などでそれぞれパスワードの入力が発生する可能性があります。という事は、ここにさらに「ローカルデータの復号」のためのパスワードが加わるというのは、さすがに実運用を妨げるレベルの煩わしさでしょう。かといって、他の部分ではパスワードを使用していないのにここでだけパスワードの入力を求める、というアンバランスな運用も考えにくいです。そういった事を考慮した結果として、CardBookのローカルデータ暗号化は現在比較的安全とされている運用を想定し、

  • 秘密鍵は、自動生成した物をJWK形式でエクスポートし、「長いパスワード文字列」の一種としてThunderbirdのパスワードマネージャに記憶させる。
  • 秘密鍵の保護が必要な場合は、使用者が任意でThunderbird本体のマスターパスワード機能を有効化する。

というポリシーを採用する事にしたのでした。

まとめ

Thunderbird用アドオンのCardBookに対して行った、ローカルデータの暗号化対応の概要をご紹介しました。

当社では、一般に公開されているFirefox用アドオン・Thunderbird用アドオンをはじめとした様々なフリーソフトウェア・OSSについて機能追加・改造のご依頼を承っております。また、成果をアップストリームに還元しても差し支えがないケースでは、積極的に還元を行うようにしています。自社でフリーソフトウェア・OSSを採用したいが少しだけ要件に合わない、という事でお悩みの場合には、メールフォームからお問い合わせ下さい。

*1 ただし、これはあくまで現時点での話です。技術の進歩や、この分野での研究が進む事などによって、「最もマシ」なやり方は変わっていく可能性があります。

2019-05-28

「以前のバージョンでは使えていた機能が新バージョンでは使えなくなった」時の原因の調べ方

この記事はQiitaとのクロスポストです

恐らくあまり知られていませんが、Firefoxはインストール先の browser/chrome/icons/default/main-window.ico の位置(Firefoxのインストール先が C:\Program Files\Mozilla Firefox\ であれば、 C:\Program Files\Mozilla Firefox\browser\chrome\icons\default\main-window.ico)にICO形式のアイコン画像を置くと、それがメインウィンドウのアイコンになるという機能がありました*1

(スクリーンショット:実際にアイコンを変えた様子)

この機能について、次のESR版であるESR68でも有効なのかどうかを確かめようとNightly 68で試してみたところ、アイコンが変化しないという結果を得られました。

ということは、Firefox ESR68では機能が削除されたのだな……と決めつけるのは早計です。開発版のNightlyで駄目だったからといって、そのバージョンのFirefoxが正式リリースされた後も駄目だとは限りません*2。たまたまNightlyでは機能していないだけなのか、それともこの方法が使えなくなってしまったのか、きちんと裏付けを取らないとはっきりした事は言えません。

この記事では、フリーソフトウェア・OSSで遭遇したこのような挙動の変化の経緯を実装を辿って調べる方法について、Firefoxでの事例を示します。

調査の取っかかりを探す

この事例では「ウィンドウのアイコン」がポイントになります。Windows上で動くFirefoxはWindowsのアプリケーションなので、ウィンドウのタイトルバーのアイコンを変えるには当然Windowsのアプリケーションの流儀に則った方法を使っているはずです。

Windows window icon win32 といったキーワードで検索してみたところ、Win32アプリケーションでウィンドウのアイコンを変える伝統的なやり方に WM_SETICON という定数で定められたメッセージを使う方法 があるという事が分かりました。

機能が動いているバージョンでどう実装されているかを調べる

確認してみたところ、前述の方法でのアイコン変更はFirefox ESR60やFirefox 66では機能していました。そこで、Firefox ESR60のソースコード中で SM_SETICON という定数が使われている箇所を検索してみたところ、クロスプラットフォームな実装ではなくWindows固有の実装の部分で、この定数が使われている箇所が見つかりました

このメソッドの冒頭で呼ばれているResolveIconName()というメソッドの定義を辿り、さらにその中で呼ばれている ResolveIconNameHelper() というメソッドの定義まで辿ってみたところ、「与えられたファイルハンドラからiconsdefault、とディレクトリを辿り、与えられたファイル名のアイコンファイルに対応するアファイルハンドラを得る」という処理が行われている様子がうかがえました。これはまさしく、前述の「特定の位置に置かれたアイコンファイルを参照する」という処理そのものに見えます。

この箇所は nsWindow クラスの SetIcon() メソッドに含まれていますので、今度はこのメソッドを呼んでいる箇所を探しました。すると、XULのwindow要素のid属性の値を参照してアイコンを設定しているらしい箇所が見つかりました。

  // "id" attribute for icon
  windowElement->GetAttribute(NS_LITERAL_STRING("id"), attr);
  if (attr.IsEmpty()) {
    attr.AssignLiteral("default");
  }
  mWindow->SetIcon(attr);

ということで、先の方法でウィンドウのアイコンを変更するという機能は、この一連の箇所によって実現されている事が分かりました。

機能が動いていないバージョンでどう実装されているかを調べる

機能が動いているバージョンでの実装が分かったので、今度は対応する部分のNightly 68での実装を見てみました。

先ほど見ていった実装のそれぞれについてNightly 68での状況を見ていくと、ほとんどの箇所には変更が見られませんでしたが、nsWindow クラスの SetIcon() メソッドを呼んでいる箇所に違いがありました。こちらでは、XULのwindow要素のicon属性の値を参照して、値があった時にだけアイコンを設定するというコードになっています。

  // "icon" attribute
  windowElement->GetAttribute(NS_LITERAL_STRING("icon"), attr);
  if (!attr.IsEmpty()) {
    mWindow->SetIcon(attr);

    NS_ENSURE_TRUE_VOID(mWindow);
  }

Firefoxのソースコード検索システムでは、そのまま続けてMercurialのblameの結果を見る事ができます。そこから当該箇所の変更が行われたコミットの情報を見ると、バグトラッカー上での対応するbugの番号が記載されており、Bug 1531836 - Each new xul window does a stat call to look for non-existant window specific iconsに辿り着きました。

bugの内容を読むと、どうやら起動処理の高速化の一環として、「まず存在しないファイルを探しに行く」のではなく「明示的にアイコンが指定されている時だけファイルを探しに行く」という方針に改める変更が行われ、その一環として前述の機能が削除されたという事のようです。

まとめ

以上、フリーソフトウェア・OSSで「以前のバージョンでは使えていた機能が新バージョンでは使えなくなった」という事態に遭遇した時の原因の調べ方の、Firefoxでの事例をご紹介しました。

ユーザーの目に触れやすい機能の変更はリリースノートに記載されることが多いですし、また、開発者向けに影響の大きな変更は技術情報が別途まとめられている事もあります。しかし、この例のように影響度の小さい変更は、そのような変更があったという事自体は特に告知されないままになっている事があります。そういった場合でも、フリーソフトウェア・OSSではソースコードやバージョン管理システムの変更履歴を辿って、原因を特定したり回避方法を見つけたりする事ができます。

クリアコードではフリーソフトウェア・OSSに対する技術サポートを有償にて提供しており、お客様からの「今まで使えていた機能が使えなくなった事の理由を調べて欲しい」「回避策があれば、それを教えて欲しい」といったご依頼を受けてこのような調査も行っています。
業務上でのフリーソフトウェア・OSSの利用でお困りの方は、是非メールフォームからご相談下さい。

*1 正確には、`(id文字列).ico` という名前でファイルを置いておくと、ウィンドウの内部的なID(XULの`window`要素の`id`属性の値)が一致するファイルが自動的に使われるという事になっています。

*2 その逆に、Nightlyでは使えていた機能が同じバージョンのリリース版Firefoxで使えなくなる場合もあります。例えば `xpinstall.signatures.required` という設定はビルド時のオプションによって機能するかどうかが切り替わるようになっており、NightlyとDeveloper Editionでは使えますが、ベータ版および正式リリース版のFirefoxでは機能しないようになっています。

タグ: Mozilla
2019-05-14

2019年5月4日前後から発生しているFirefoxのアドオン無効化問題の詳細な解説

「Firefox」でインストール済みアドオンが利用不能になる問題が発生中 - 窓の杜Firefox 66.0.4 リリース、拡張機能全滅問題を修正 | スラド ITなどで既報の通り、2019年5月4日前後から一般向けのリリース版Firefoxでアドオンを一切使用できない状態が発生していました。緊急の修正版としてリリースされたFirefox 66.0.4およびFirefox ESR60.6.2で現象は既に解消されており、また、既にインストール済みのFirefoxに対してもホットフィックスが順次展開されていますが、本記事では今回の問題の技術的な詳細を解説します。

ユーザー側で取れる対応

まず最初に、ユーザー側で取れる対応をまとめておきます。

  • Firefox 66.0.4以降またはFirefox ESR60.6.2以降に更新する。
  • 「Firefox調査」を通じて hotfix-update-xpi-signing-intermediate-bug-1548973 が自動的に反映されるのを待つ。
  • 有効期限が更新された中間証明書のファイルをダウンロードし、「オプション」→「プライバシーとセキュリティ」→「証明書」→「証明書を表示」で表示される証明書マネージャにおいて、「認証局証明書」の一覧の「インポート」ボタンから認証局証明書としてインポートする。
  • about:config で隠し設定の xpinstall.signatures.requiredfalse に設定する。(Firefox ESR、Developer Edition、Nightlyのみ有効で、Firefox 48以降の通常のリリース版Firefoxではこの方法は無効です)

Mozillaによるサポートが終了したバージョンのFirefoxでは「Firefox調査」を通じての修正の反映が行われないため、Firefoxの更新、証明書の手動でのインポート操作、または about:config での隠し設定の変更が必要になります。

修正が反映される前のバージョンのFirefoxを起動してアドオンが無効化されてしまった場合、証明書のインポートや about:config での暫定的対応を行った後でも「アドオンが依然として無効で、且つ、ユーザー操作で再度有効化できないまま」となる場合があります。これは、アドオンが署名の期限切れで強制的に無効化されたという情報がユーザープロファイル内に残留しているために発生する現象です。この状況に陥った場合、Firefoxを終了した状態でユーザープロファイル内の extensions.json を削除して状態をリセットすると、アドオンが自動的に有効化されます(あるいは、手動操作で有効化できる状態でアドオンマネージャに表示されるようになります)。

アドオンの電子署名の基本のおさらい

Firefoxでは、Firefox 43以降のバージョンからアドオンのインストールパッケージの電子署名が必須となっています

Firefoxのアドオンはその特性上、認証局証明書の差し替えや通信の読み取りなど、Firefox上でありとあらゆる事ができる物でした*1。これではユーザーを困らせたりユーザーの機密情報を狙ったりする事を企む攻撃者にとって格好の標的となり得るため、その種の攻撃を防ぐために、Firefox 43で以下のような仕組みが導入されました。

  • すべてのアドオンは、Mozilla Add-onsでダウンロード可能な形で公開する物も、それ以外も、審査のため一旦Mozilla Add-onsのサービスにファイルをアップロードしなくてはならない。
  • アップロードされたファイルは、エディター権限を持つ人の目視による審査を受け、受理された場合にのみMozillaによって電子署名が施される。電子署名により、エディターが審査した時のファイルと同じ内容のファイル(=安全が確認されたファイル)である事が保証される。
  • ユーザー環境のFirefoxは、入手したアドオンのパッケージの電子署名を検証し、妥当な署名である事を確認できた(=エディターが審査した通りの内容のファイルであると確認できた=安全が確認された)段階で初めてそのアドオンを使用可能にする。

ただ、これではアドオンの開発中や、社内用として機密情報を含めたアドオンを使いたい場合など、審査用にファイルを供出できないケースで支障があります。そのため、以下のような救済措置が存在しています。

  • この電子署名の検証は xpinstall.signatures.required という隠し設定を false にして無効化できる。
    • ただし、Firefox 48以降のバージョンでは、法人向けのFirefox ESRと開発者向けのDeveloper Edition、Nightlyでのみ有効で、通常リリース版のFirefoxではこの設定は機能しません。
  • about:debugging から一時的に読み込まれたアドオンに対しては、電子署名の検証は行われない。

アドオンの電子署名の詳細

ここでさらっと「電子署名が施される」と述べましたが、具体的には何が行われているのでしょうか。IE View WEの場合を例に取って解説します。

「インストール」ボタンからダウンロードできるインストールパッケージ ie_view_we-1.3.2-fx.xpi の実体はZIP形式のアーカイブですが、これを展開して取り出された中にある META-INF というフォルダとその内容がまさに電子署名の実体です。それぞれの内容は以下の通りです。

  • META-INF/manifest.mf: パッケージ内に含まれる各ファイルのハッシュ値の一覧
  • META-INF/mozilla.mf: パッケージ全体のハッシュ値
  • META-INF/mozilla.rsa: DER形式の電子署名データ

このうちの mozilla.rsa には、以下の情報が含まれています。

  • 各ファイルやパッケージ全体の情報と、Mozillaが持つ秘密鍵を組み合わせて導出した署名データ
  • 署名データの導出に使われた秘密鍵に対応する公開鍵、の情報を含む証明書

ユーザー環境のFirefoxは、実際のアドオンのパッケージに含まれるファイルの内容と公開鍵を使って署名データを導出し、それが mozilla.rsa に含まれる物(秘密鍵を使って導出された物)と一致するかどうかを以て、パッケージの内容が改竄されていないことを確認することができます。

この mozilla.rsa に含まれる証明書は、openssl コマンドを使うと以下のようにして内容を確認できます。

$ openssl pkcs7 -inform DER -text -print_certs < ie_view_we-1.3.2-fx/META-INF/mozilla.rsa
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number: 1551780906376321839 (0x158908833dcb5b2f)
    Signature Algorithm: sha384WithRSAEncryption
        Issuer: C=US, O=Mozilla Corporation, OU=Mozilla AMO Production Signing Service, CN=signingca1.addons.mozilla.org/emailAddress=foxsec@mozilla.com
        Validity
            Not Before: Mar  5 10:15:06 2019 GMT
            Not After : Mar  4 10:15:06 2020 GMT
        Subject: C=US, ST=CA, L=Mountain View, O=Addons, OU=Production, CN=ieview-we@clear-code.com
        Subject Public Key Info:
            (省略)
        X509v3 extensions:
            (省略)
    Signature Algorithm: sha384WithRSAEncryption
         (省略)
-----BEGIN CERTIFICATE-----
(省略)
-----END CERTIFICATE-----

Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number: 1048580 (0x100004)
    Signature Algorithm: sha384WithRSAEncryption
        Issuer: C=US, O=Mozilla Corporation, OU=Mozilla AMO Production Signing Service, CN=root-ca-production-amo
        Validity
            Not Before: May  4 00:09:46 2017 GMT
            Not After : May  4 00:09:46 2019 GMT
        Subject: C=US, O=Mozilla Corporation, OU=Mozilla AMO Production Signing Service, CN=signingca1.addons.mozilla.org/emailAddress=foxsec@mozilla.com
        Subject Public Key Info:
            (省略)
        X509v3 extensions:
            (省略)
    Signature Algorithm: sha384WithRSAEncryption
         (省略)
-----BEGIN CERTIFICATE-----
(省略)
-----END CERTIFICATE-----

ここでは2つの証明書が出力されています。

  • CN=ieview-we@clear-code.com である証明書(CN=signingca1.addons.mozilla.org によって発行された):そのアドオン固有の物として発行されたコード署名証明書で、パッケージの電子署名に使われた物
  • CN=signingca1.addons.mozilla.org である証明書(CN=root-ca-production-amo によって発行された):中間証明書

CN=root-ca-production-amo の証明書は addons-public.crt というファイル名でFirefoxのリポジトリに含まれている自己署名証明書で、ビルド時にソースコード内に組み込まれ組み込みの証明書として認識される、ビルトインのルート証明書の1つです*2

ここで注目するべきなのは、mozilla.rsa に含まれる証明書のうち2番目、中間証明書の方の有効期限です。当該箇所を以下に抜粋します。

Not Before: May  4 00:09:46 2017 GMT
Not After : May  4 00:09:46 2019 GMT

これを見ると、有効期限が2017年5月4日0時9分46秒(UTC)から2019年5月4日0時9分46秒(UTC)となっています。日本標準時はUTCより9時間進んでいるので、日本では2019年5月4日の9時過ぎに有効期限を迎えた事になります。この瞬間以降、この中間証明書を含むアドオンのパッケージはすべて「期限切れの中間証明書に基づく証明書で署名されたファイル」と見なされ、「署名を検証できない」=「エディターが審査した通りの内容のファイルであると確認できない」=「安全が確認されていない」と判断されるため、新規インストールは拒絶され、インストール済みの物は無効化されるという結果になっていたわけです。

Mozillaが取った対応

上記の事を踏まえ、Mozillaではいくつかの対応が検討されたようですFirefoxのリリースブランチESR60用ブランチに実際に投入された変更を追うと、以下の3通りの方法が試された模様です。

  1. コード署名証明書の検証時にだけ現在時刻を2019年4月27日に巻き戻す
  2. 新しい中間証明書をハードコードし、それを証明書データベースに追加する*3
  3. 新しい中間証明書をハードコードし、CN=root-ca-production-amo の証明書に基づく証明書ツリーを検証する時に新しい中間証明書を代わりに使う

緊急のリリースとなったFirefox 66.0.4とFirefox ESR60.6.2には、この2番目の対応が反映されています。また、動的な反映が可能である事から、統計情報の収集・ユーザーのFirefox使用状況の収集を行うための仕組みを使って、この2番目の方法と同じ手順でインストール済みのFirefoxに暫定的対策を行う(有効な中間証明書を自動インポートする)という対応が展開されています。Firefox 66.0.3以前やFirefox ESR60.6.1以前を使用中の環境では、この対応が反映された事によってアドオンが再び使用可能になっているはずです。「オプション」→「プライバシーとセキュリティ」→「証明書」→「証明書を表示」で認証局証明書の一覧を表示し、Mozilla Corporation 配下に signingca1.addons.mozilla.org が表示されていれば、この対応が既に反映された状態であるという事を確認できます。

(スクリーンショット:認証局証明書の一覧に中間証明書が現れている状態)

一方、Firefox 66.0.5とFirefox ESR60.6.3には3番目の対応が採用されており、2番目の対応は不要になったとして取り除かれています*4

中間証明書の更新では解消されない問題

現時点では以上の通りの暫定的な対応がなされていますが、今回発生したすべての問題が解決されたわけではなく、一部のアドオンではさらに手動での対応が必要です。その中でも、アナウンスされている未解決の問題の1つである、一部のアドオンが中間証明書の更新後も有効にならない件は、ここまでで解説した電子署名の仕組みが大きく関係しています。

前述の説明の通り、アドオンは中間証明書によって発行されたそのアドオン固有の証明書によって電子署名を施された状態になっています。この証明書はアドオンごとの固有の識別子(ID)に基づいて発行されていますが、EPUBReaderなどの一部のアドオンでは、そもそもこの固有の識別子が開発元によって指定されていません。そのため、Mozilla Add-onsのシステムが自動的に一意なIDを割り当てているのですが、アドオンのファイルの最終更新日が変わったなどの契機で自動割り当てされたIDが代わってしまうと、中間証明書の更新後であっても、そもそも電子署名が不正と見なされてしまうという状況が発生します。そのため、アドオンを手動で再インストールして、電子署名に記録されたIDとアドオンの一時的なIDが一致する状態を作り直す必要があるという訳です。

他にも、コンテナータブに固有の情報が失われる、検索エンジンや起動時のホームページなどアドオンによって変更されたはずのFirefox側の設定が初期状態に戻ったままになる、といった問題が残されているようです。これらの問題が解決された場合、それらもまたホットフィックスとして展開されたり、もしくは緊急の修正版としてリリースされたりする可能性がありますので、引き続き注視が必要です。

暫定的な対応を取った後の注意点

冒頭に記載したとおり、Firefox ESR、Developer EditionおよびNightlyにおいては、 xpinstall.signatures.required による電子署名の検証の無効化によって今回の問題を回避できます。しかしながら、それは同時に、この設定と同時に導入された安全確認の仕組みを無効化する事をも意味します。企業での運用でユーザーによるアドオンのインストールを禁止していて、導入されるアプリケーションの影響範囲が厳密に制御されている状況であればよいのですが、個人でESR版を使用している場合、悪意の攻撃者によるアドオンが知らず知らずのうちに読み込まれるというリスクがあります。

この方法を取った場合は、Firefoxが更新されたり、ホットフィックスが反映されたりした事を確認できたら、早急に証明書の検証を有効化する事を強くお勧めします。

まとめ

以上、2019年5月4日以降で発生しているFirefoxのアドオン無効化問題の技術的な詳細を解説しました。

当社ではFirefoxの法人向けテクニカルサポートを有償で提供していますが、実際にお客様の環境でも影響が出ている例があります。

今回の問題に見られるような証明書の期限切れに起因する問題は、度々世間を騒がせています。記憶に新しい所では、2018年から2019年にかけても以下のニュースがありました。

証明書のように有効期限が存在するデータに依存するシステムを運用する場合、有効期限の到来には充分な余裕を持って対応できるような仕組みを作っておく必要がある、という事を思い知らされますね。

*1 これは従来型のアドオン(いわゆるXULアドオン)での話です。Firefox 57以降のバージョンで使用できるアドオン(WebExtensionsベースのアドオン)では、できる事が技術的に制限されているため、危険性は大幅に減じています。

*2 編集履歴によると、このルート証明書は2015年に電子署名の仕組みが導入された時に追加されて以降特に変更された様子はありません。ルート証明書自体は2025年3月15日まで有効なので当面は問題無く利用できますが、このファイルがもし今後更新されないままであれば、2025年に再び同様のトラブルが起こる事になるでしょう。その場合、署名の検証を無効化できなくなっている現在のリリース版Firefoxをサポート切れのまま使い続けていると、アドオンを再度有効化する手立てが全く無くなるという事にもなりかねません。これらの観点からも、サポート切れのバージョンのFirefoxを使い続ける事にはリスクがあり、原則としてサポートが継続しているバージョンを使い続ける事が望ましいという事が言えます。

*3 各アドオンの署名データに含まれる中間証明書と同じSubjectの中間証明書がFirefoxの証明書データベースにも含まれるようになり、以後はそちらが優先的に認識されるために中間証明書の期限切れ問題が解消する、という理屈です。

*4 一度証明書データベース登録された中間証明書は、明示的に削除しない限りはFirefox 66.0.5への更新後もそのまま残ります。

タグ: Mozilla
2019-05-07

回帰テストの対象は適切に設定しよう

先日、Bug 1541748 - New tab and restored tab notified via tabs.onCreated can have invalid (too large) index という報告をFirefoxのバグトラッキングシステムに行った結果、提出したパッチがFirefox 68に反映される事になりました。

とはいえ、実はこのパッチで追加されるコードは皆さんのお手元にインストールされるFirefoxには反映されません。何故なら、これは自動テストを追加するだけのパッチだからです。

パッチが投入されるまでの経緯

この報告は、「Firefoxのアドオン開発に使用するWebExtensionsのいくつかのAPIにおいて、タブの位置を示すindexというプロパティが不正な値になる場合がある」という物でした。ただ、これは実はWebExtensions APIの実装の問題ではなく、Firefoxのタブが内部的に持っている対応する情報自体が壊れていたというのが真相でした。

(図:壊れた内部状態が露出した事が原因で、APIの情報が壊れている様子)

「根本的な原因」の方は Bug 1504775 - Index is wrong for restored tabs という別のBugで取り扱われていました。今回の報告と前後してそちらのBugの作業が進行していたため、そちらの方で修正が行われた事の影響として、報告からあまり間を置かずにWebExtensions APIの不具合も自然解消したという状況になっていました。

一般的に、このような「ある報告の原因が別の報告の修正で解消された」というようなケースでは、その旨を記して修正済み扱いとしたり、もしくは重複する報告であるとして処理する事が多いです。

(図:2つのBugに因果関係がある時、両方が閉じられる様子)

このような場合、そちら側に提出されたパッチはBugのクローズに伴って破棄されがちです。しかし今回はそうではなく、Bugはクローズされずに留め置かれ、新たに自動テストを加えるだけのパッチを追加投入する事が承認されました。

これは、根本原因の方のBugがFirefox内部の問題のみを取り扱う物であったのに対し、こちらで報告したBugはアドオンという外部アプリケーションに対する互換性の問題を取り扱っていたからです。

テストは目的ごとに必要

根本原因が修正されたパッチでは、Firefox内部のAPIに対するテストケースは追加されていますが、WebExtensionsのテストケースには特に変更は加えられていませんでした。

今回はFirefoxがたまたま「タブの内部的なindexがそのままWebExtensions APIを通じてアドオンに露出する」という実装を取っていたために、WebExtensions API側の問題も偶然解消されました。しかし、それはあくまで現時点の実装がそうであったからというだけで、将来に渡ってそうであるという事の保証はどこにもありません

(図:内部向けの保証はあってもアドオン相手の保証は無いという様子)

よって、今後Firefox内部の実装やWebExtensions APIの実装が変更された場合に、人知れず再び同じ問題が起こるようになるという可能性は依然としてあります。

そのため、今回のパッチでWebExtensions APIのレイヤにおいて後退バグ*1が発生していないかを検出するための回帰テスト*2を追加することで、WebExtensions APIに意図せず影響を与えてしまう(そのような変更が気付かれずに投入されてしまう)という事故の発生を未然に防ぎ、APIの安定性を保証しやすくしたという事になります。

(図:保証する相手ごとにテストがある様子)

気をつけて欲しいのは、これは「単体テストと結合テストではレイヤが違うので、単体テストで検証済みの事もすべて結合テストで再検証しよう」という話ではない、という事です。同じ事を保証するテストがそれぞれの実装レイヤに含まれるという事はあり得ますが、それはあくまで結果的にそうなっているだけに過ぎません。テストは誰に対して何を保証するか、という観点で整備される事が重要です。

今回は「Firefox内部向け」と「アドオンという外部アプリケーション向け」のそれぞれで修正・互換性を保証する必要があったためにこうなったわけです。

まとめ

Firefoxに投入された自動テストのみのパッチの背景説明を通じて、テストの目的とテストを追加するかどうかの判断基準の一例をご紹介しました。

自動テストの追加は、ドキュメントの修正に次いで「外部のコントリビューターとして開発に参加する最初のステップ」として取り組みやすい作業です。自動テストが全く存在しないプロダクトや、自動テストがあっても特定の機能についてテストが不足しているというケースに遭遇したら、皆さんもぜひテストの追加に取り組んでみて下さい。独りでやりきるには不安があるという方は、スケジュールが合うOSS Gateのワークショップに参加してサポーターの人に相談してみても良いかもしれません。

*1 今まで期待通りに動いていた物が、別の変更の影響で期待通りに動かなくなってしまう事。

*2 ある問題を修正したときに、問題が発生しなくなっている事を検証するテストのこと。

2019-04-23

FirefoxやChromiumのアドオン(拡張機能)で効率よくタブを並べ替えるには

この記事はQiitaとのクロスポストです。

Firefox 57以降のバージョンや、Google Chromeをはじめ各種のChromiumベースのブラウザでは、アドオン開発に共通のAPIセットを使用します。タブバー上のタブを任意の位置に移動するAPIとしてはtabs.move()という機能があり、これを使うと、タブを何らかのルールに基づいて並べ替えるという事もできます。実際に、以下のような実装例もあります。

このようなタブの並べ替え機能を実現する時に気をつけたいポイントとして、 いかにAPIの呼び出し回数を減らし、効率よく、且つ副作用が少なくなるようにするか? という点があります。

ソートアルゴリズムの効率とは別の話

「並べ替え」「効率」というキーワードでソートアルゴリズムの事を思い浮かべる人もいるのではないでしょうか。確かにそれと似ているのですが、実際にはこの話のポイントはそこにはありません。

確かに、ソート自体は前述の2つのアドオンでも行っており、

  • バラバラの順番のタブをコンテナごとに整理する
  • URLなどの条件でタブの順番を整理する

という処理はまさにソートそのものです。しかし、現代のJavaScript実行エンジンは充分に高速なため、余程まずいアルゴリズムでもない限りJavaScriptで実装されたソートの速度自体は実用上の問題となりにくいです。

それよりも、その後に行う 「実際のタブを移動して、算出された通りの順番に並べ替える」 という処理の方が影響度としては深刻です。「タブ」というUI要素を画面上で移動するのは、純粋なデータのソートに比べると遙かにコストが大きいため、数百・数千個といったオーダーのタブをすべて並べ替えるとなると相応の時間を要します。また、タブが移動されたという事はイベントとして他のアドオンにも通知され、その情報に基づいて様々な処理が行われ得ます*1ので、そのコストも馬鹿になりません*2

タブの並べ替えには手間がかかる

実際の並べ替えの様子を図で見てみましょう。[i, h, c, d, e, f, g, b, a, j] という順番のタブを、[a, b, c, d, e, f, g, h, i, j] に並べ替えるとします。

(図:並べ替え前後)

ぱっと見で気付くと思いますが、これは a, b, h, i の4つを左右で入れ換えただけの物です。しかし、人間にとっては一目瞭然の事でも、プログラムにとっては自明な事ではありません(それを検出するのがまさにこの記事の本題なのですが、詳しくは後述します)。

最もナイーブな*3実装としては、元の並び順からタブを1つづつ取り出していくやり方が考えられます。その場合、行われる操作は以下のようになります。

(図:ナイーブな並べ替えの流れ。計8回の移動が生じている様子。)

というわけで、この場合だと10個のタブの並べ替えに8回の移動が必要となりました。元のタブの並び順次第では、移動は最大で9回必要になります。

ちなみに、tabs.move()は第一引数にタブのidの配列を受け取ることができ、その場合、第一引数での並び順の通りにタブを並べ替えた上で指定の位置に移動するという挙動になります。そのため、

browser.tabs.move([a, b, c, d, e, f, g, h, i, j], { index: 0 });

とすれば、APIの呼び出し回数としては確かに1回だけで済ませる事もできます。ただ、これは内部的には

browser.tabs.move(a, { index: 0 });
browser.tabs.move(b, { index: 0 + 1 });
browser.tabs.move(c, { index: 0 + 2 });
browser.tabs.move(d, { index: 0 + 3 });
browser.tabs.move(e, { index: 0 + 4 });
browser.tabs.move(f, { index: 0 + 5 });
browser.tabs.move(g, { index: 0 + 6 });
browser.tabs.move(h, { index: 0 + 7 });
browser.tabs.move(i, { index: 0 + 8 });
browser.tabs.move(j, { index: 0 + 9 });

といった移動操作と同等に扱われるため、タブが実際に移動されるコストは変わらず、タブが移動された事を通知するイベントもその都度通知されます。

ソートと同時にタブを移動するのではどうか?

ここまではArray.prototype.sort()でソートした結果に従って、後からまとめてタブを並べ替えるという前提で説明していました。では、Array.prototype.sort()を使わずに独自にソートを実装し、その並べ替えの過程で同時にタブも移動する、という形にするのはどうでしょうか?

例えばこちらのクイックソートの実装の過程にtabs.move()を呼ぶ処理を追加すると、以下のようになります。

// 再帰によるクイックソートの実装
function quickSort(tabIds, startIndex, endIndex) {
  const pivot = tabIds[Math.floor((startIndex + endIndex) / 2)];
  let left = startIndex;
  let right = endIndex;
  while (true) {
    while (tabIds[left] < pivot) {
      left++;
    }
    while (pivot < tabIds[right]) {
      right--;
    }
    if (right <= left)
      break;
    // 要素の移動時に、同じ移動操作をタブに対しても行う
    browser.tabs.move(tabIds[left], { index: right });
    browser.tabs.move(tabIds[right], { index: left });
    const tmp = tabIds[left];
    tabIds[left] = tabIds[right];
    tabIds[right] = tmp;
    left++;
    right--;
  }
  if (startIndex < left - 1) {
    quickSort(tabIds, startIndex, left - 1);
  }
  if (right + 1 < endIndex ){
    quickSort(tabIds, right + 1, endIndex);
  }
}
const tabs = await browser.tabs.query({windowId: 1 });
quickSort(tabs.map(tab => tab.id), 0, tabIds.length - 1);

先の [i, h, c, d, e, f, g, b, a, j] を [a, b, c, d, e, f, g, h, i, j] に並べ替えるという例で見てみると、

(図:クイックソートでの並べ替えの流れ。計4回の移動が生じている様子。)

このように、今度は4回の移動だけで並べ替えが完了しました。先の場合に比べると2倍の高速化なので、これは有望そうに見えます。

……が、そう考えるのは早計です。この例で並べ替えが4回で終わったのは、部分配列の左右端から要素を見ていくというアルゴリズムが、左右の端に近い要素が入れ替わる形になっていた [i, h, c, d, e, f, g, b, a, j] という元の並び順に対して非常にうまく作用したからに過ぎません。例えば入れ替わった要素の位置が左右の端から遠い位置にある [c, d, i, h, e, b, a, f, g, j] のようなケースでは、

(図:クイックソートでの並べ替えの流れ。計12回の移動が生じている様子。)

と、今度は12回も移動が発生してしまいます。クイックソートは場合によっては同じ要素を何度も移動する事になるため、タブの移動のコストが大きい事が問題となっている今回のような場面では、デメリットの方が大きくなり得るのです。

また、クイックソートには「ソート結果が安定でない」という性質もあります。各要素のソートに使う値がすべて異なっていれば並べ替え結果は安定しますが、Firefox Multi-Account Containersでの「タブのコンテナごとに整理する」というような場面では、同じグループの要素の順番がソートの度に入れ替わってしまうという事になります。このような場合は要素間の比較の仕方を工夫するか、別のソートアルゴリズムを使う必要があります。

どうやら、このアプローチでは一般的に効率よくタブを並べ替えるという事は難しいようです。一体どうすれば「人が目視でするように、可能な限り少ない移動回数で、タブを目的の順番に並べ替える」という事ができるのでしょうか。

diffのアルゴリズムを応用する

このような問題を解決するには、diff編集距離といった考え方が鍵となります。

Unix系の環境に古くからあるdiffというコマンドを使うと、2つの入力の差を表示する事ができます。Gitなどのバージョン管理システムにおいてもgit diffのような形で間接的に機能を利用できるようになっているため、各コミットで行った変更点を見るために使った事がある人は多いでしょう。例えば、A = [a, b, c, d] と B = [a, c, d, b] という2つの配列があったとして、AとBの差分はdiffでは以下のように表されます。

  a
- b
  c
  d
+ b

行頭に-が付いている行は削除(AにはあってBには無い)、+が付いている行は追加(Aには無くてBにはある)を意味していて、これを見れば、「Aのbを2番目から4番目に移動する」というたった1回の操作だけでAをBにできるという事が見て取れます。このような「最小の変更手順」を求めるのが、いわゆる「diffのアルゴリズム」です。

つまり、これを「タブの並べ替え」に当てはめて、 「現時点でのタブの並び順」と「期待される並び順」とを比較し、算出された差分の情報に則ってタブを実際に移動するようにすれば、最小の手間でタブを並べ替えられる というわけです。

JavaScriptでのdiffの実装

diffの実装というとdiffコマンドが真っ先に思い浮かぶ所ですが、diffをライブラリとして実装した物は各言語に存在しており、もちろんJavaScriptの実装もあります。以下はその一例です。

diffのアルゴリズムをタブの並べ替えに使うには、こういったライブラリをプロダクトに組み込むのがよいでしょう。ただ、その際には以下の点に気をつける必要があります。

  • ライブラリのライセンスが他の部分のライセンスと競合しない事。
  • ライブラリの内部表現を機能として利用できる事。
  • 入力として任意の要素の配列を受け受ける事。

ライセンスの事は当然として、内部表現が利用できるとはどういう事でしょうか。

前述の例の「行頭に-+が付く」という表現は、git diffなどで目にする事の多い「Unified Diff」形式に見られる表記ですが、これはあくまで最終出力です。ライブラリ内部では、相当する情報を構造化された形式で持っている事が多いです。npmのdiffであるjsdiffの場合は以下の要領です。

[
  { count: 1, value: [a] },                // 共通の箇所
  { count: 1, value: [b], removed: true }, // 削除された箇所
  { count: 2, value: [c, d] },             // 共通の箇所
  { count: 1, value: [b], added: true }    // 追加された箇所
]

先のテキストでの表現をパースしてこのような表現を組み立てる事もできますが、ライブラリの内部表現→出力結果のテキスト表現→内部表現風の表現 という無駄な変換をするよりは、素直に内部表現をそのまま使えた方が話が早いです。

また、ライブラリの入力形式には任意の配列を受け付ける物である方が望ましいです。文字列のみを入力として受け付けるライブラリでも使えなくはないのですが、内部ではどの実装も基本的に配列を使っています。こちらも、比較したい配列→ライブラリに与えるための文字列→内部表現の配列 という無駄な変換が必要の無い物を使いたい所です。

npmのdiffパッケージであるjsdiffは、これらの点で使い勝手が良いのでおすすめの実装と言えます。

diffのアルゴリズムを使ったタブの並べ替え

diffを使ってタブを並べ替えるには、「ソート前の配列」「ソート後の配列」に「作業中の状態を保持する配列」を加えた3つの配列を使います。

const tabs = await borwser.tabs.query({ currentWindow: true });

const beforeIds = tabs.map(tab => tab.id); // ソート前のタブのidの配列
const afterIds  = tabs.sort(comparator)
                      .map(tab => tab.id); // ソート後のタブのidの配列
let   currentIds = beforeIds.slice(0);     // 現在の状態を保持する配列(ソート前の状態で初期化)

次に、ソート前後のタブのidの配列同士の差分を取り、その結果に従ってタブを移動していきます。

const differences = diff.diffArrays(beforeIds, afterIds);
for (const difference of differences) {
  if (!difference.added) // 削除または共通の部分は無視する
    continue;
  // ここにタブの移動処理を書いていく
}

タブの移動による並べ替えという文脈では、「削除」の差分で操作されるタブは対応する「追加」の操作によっても操作されます。また、「なくなるタブ(=処理中に閉じなければいけないタブ)」は原則として存在しません。よって、差分情報のうち「共通」と「削除」は無視して、「追加」にあたる物のみを使用します。

「追加」の差分情報を検出したら、まずタブの移動先位置を計算します。tabs.move()はタブの位置をindexで指定する必要がありますが、この時のindexは以下の要領で求められます。

  // 以下、forループ内の処理

  // 移動するタブ(1個以上)
  const movingIds = difference.value;
  // 移動するタブ群の中の右端のタブを得る
  const lastMovingId = movingIds[movingIds.length - 1];
  // ソート後の配列の中で、移動するタブ群の右に来るタブの位置を得る
  const nearestFollowingIndex = afterIds.indexOf(lastMovingId) + 1;
  // 移動するタブ群がソート後の配列の最後に来るのでないなら、
  // 移動するタブ群の右に来るタブのindexを、タブ群の最初のタブの移動先位置とする
  let newIndex = nearestFollowingIndex < afterIds.length ? currentIds.indexOf(afterIds[nearestFollowingIndex]) : -1;
  if (newIndex < 0)
    newIndex = beforeIds.length;

  // 移動するタブ群の左端のタブの現在のindexを調べる
  const oldIndex = currentIds.indexOf(movingIds[0]);
  // 現在の位置よりも右に移動する場合、移動先の位置を補正する
  if (oldIndex < newIndex)
    newIndex--;

タブ群の移動先の位置を求めたら、いよいよtabs.move()でタブを移動します。このAPIは第1引数にタブのidの配列を渡すと複数のタブを一度に指定位置に移動できるので、APIの呼び出し回数を減らすためにもそのようにします。

  // 実際にタブを移動する
  browser.tabs.move(movingIds, {
    index: newIndex
  });
  // 現在の状態を保持する配列を、タブの移動後として期待される状態に合わせる
  currentIds = currentIds.filter(id => !movingIds.includes(id));
  currentIds.splice(newIndex, 0, ...movingIds);

タブの移動はAPIを介して非同期に行われますが、タブの移動指示そのものはまとめて一度にやってしまって構いません。むしろ、途中でtabs.move()の完了をawaitで待ったりすると、タブの並べ替えの最中に外部要因でタブが移動されてしまうリスクが増えるので、ここはバッチ的に同期処理で一気にやってしまうのが得策です。

ただしその際は、2回目以降のtabs.move()に指定するタブの移動後の位置として、前のtabs.move()の呼び出しで並べ替えが完了した後の位置を指定しなくてはならないという点に注意が必要です。そのため、ソート前後の配列とは別に「並べ替えが実施された後の状態」を保持する配列を別途用意しておき、それだけを更新しています。前述のコード中で「タブの現在の位置」を求める際に、tabs.Tabindexを参照せずにこの配列内での要素の位置を使用していたのは、これが理由なのでした。

実際の並べ替えの様子を、最初と同じ例を使って見てみましょう。[i, h, c, d, e, f, g, b, a, j] を [a, b, c, d, e, f, g, h, i, j] に並べ替えるとします。初期状態は以下のようになります。

  • beforeIds: [i, h, c, d, e, f, g, b, a, j]
  • afterIds: [a, b, c, d, e, f, g, h, i, j]
  • currentIds: [i, h, c, d, e, f, g, b, a, j]
  • 実際のタブ: [i, h, c, d, e, f, g, b, a, j]

この時、beforeIdsafterIdsを比較して得られる差分情報は以下の通りです。

// 実際にはvalueはタブのidの配列だが、分かりやすさのためにここではアルファベットで示す
[
  { count: 2, value: [i, h], removed: true },
  { count: 2, value: [a, b], added: true },
  { count: 5, value: [c, d, e, f, g] },
  { count: 2, value: [b, a], removed: true },
  { count: 2, value: [h, i], added: true },
  { count: 1, value: [j] }
]

前述した通り、ここで実際に使われる差分情報は「追加」にあたる以下の2つだけです。

  1. { count: 2, value: [a, b], added: true }
  2. { count: 2, value: [h, i], added: true }

まず1つ目の差分情報に基づき、aとbを移動します。移動先の位置は、bの右隣に来る事になるタブ(=c)のcurrentIds内での位置ということで、2になります。

(図:diffを使った並べ替えの流れ。まず2つのタブを移動する。)

この時、b, aだった並び順も併せてa, bに並べ替えています。タブの移動完了後は、それに合わせる形でcurrentIdsも [i, h, a, b, c, d, e, f, g, j] へと更新します。

次は2つ目の差分情報に基づき、hとiを移動します。移動先の位置は、iの右隣に来る事になるタブ(=j)のcurrentIds内での位置から求めます。jの位置は9ですが、これはhの現在位置より右なので、そこから1減らした8が最終的な移動先位置になります。

(図:diffを使った並べ替えの流れ。次も2つのタブを移動する。)

この時も、i, hだった並び順をh, iに並べ替えています。タブの移動完了後は、それに合わせる形でcurrentIdsも [a, b, c, d, e, f, g, h, i, j] へと更新します。

ということで、APIの呼び出しとしては2回、タブの移動回数としては4回で並べ替えが完了しました。冒頭の例の8回に比べるとタブの移動回数は半分で済んでいます。

クイックソートでは却って移動回数が増えてしまった、[c, d, i, h, e, b, a, f, g, j] からの並べ替えではどうでしょうか。この場合、比較結果は以下のようになります。

[
  { count: 2, value: [a, b], added: true },
  { count: 2, value: [c, d] },
  { count: 2, value: [i, h], removed: true },
  { count: 2, value: [e] },
  { count: 2, value: [b, a], removed: true },
  { count: 2, value: [f, g] },
  { count: 2, value: [h, i], added: true },
  { count: 1, value: [j] }
]

前述の通り、このうち実際に使われる差分情報は以下の2つだけです。

  1. { count: 2, value: [a, b], added: true }
  2. { count: 2, value: [h, i], added: true }

これは先の例と全く同じなので、APIの呼び出し回数は2回、実際のタブの移動は4回で済みます。

(図:diffを使った並べ替えの流れ。タブを4回移動している様子。)

これらの例から、diffのアルゴリズムを用いると安定して少ない移動回数でタブを並べ替えられるという事が分かります。diffのアルゴリズムを用いた差分の計算自体はタブの移動に比べれば一瞬で済みますので、タブの移動回数を少なく抑えられれば充分に元が取れると言えるでしょう。

まとめ

以上、diffのアルゴリズムを用いてブラウザのタブを効率よく並べる方法について解説しました。

冒頭で例として挙げたタブの並べ替え機能を持つアドオンに対しては、以上の処理を実際に組み込むプルリクエストを既に作成済みです。

これらの変更(およびこの記事)は、「ツリー型タブ」での内部的なタブの並び順に合わせてFirefoxのタブを並べ替える処理が元になっています。ツリー型タブではnpmのdiffとは別のdiff実装を使用していますが、diffの結果として得られる内部表現の形式はよく似ており、タブの並べ替え処理そのものは同じロジックである事が見て取れるはずです。

このように「最小の変更内容を計算して適用する」という考え方は、Reactで広まった仮想DOMという仕組みのベースとなっています。コンピューターが充分に高性能となり、富豪的プログラミングが一般化した現代においては、多少の事は力業で解決してしまって問題無い事が多いです。しかし、ネットワーク越しの処理や、携帯端末などデスクトップPCよりも性能が制限されがちな環境などで、何千・何万回といった単位で実行されるような処理にボトルネックがあると致命的な性能劣化に繋がりかねず、そのような場合には「コストの高い処理の実行頻度を減らしてトータルの性能を向上する」という正攻法での性能改善が依然として有効です。

なお、2者間で「変更箇所を検出する」というのは「共通部分を検出する」という事と表裏一体ですが、そのような計算は最長共通部分列問題と呼ばれ、diffのアルゴリズムの基礎となっています。どのような理屈で共通箇所を検出しているのか知りたい人は解説や論文を参照してみると良いでしょう。筆者は理解を諦めましたが……

*1 例えば、タブをツリー状の外観で管理できるようにするアドオンであるツリー型タブでは、親のタブが移動された場合は子孫のタブもそれに追従して移動されますし、既存のツリーの中にタブが出現した場合や既存のツリーの一部だけが別の場所に移動された場合などにも、ツリー構造に矛盾が生じなくなるようにタブの親子関係を自動的に調整するようになっています。

*2 ブラウザ自体のタブの並べ替えにかかる時間の事よりも、むしろ、こちらの副作用の方が問題だと言っていいかもしれません。

*3 愚直な。

2019-04-19

タグ:
年・日ごとに見る
2008|05|06|07|08|09|10|11|12|
2009|01|02|03|04|05|06|07|08|09|10|11|12|
2010|01|02|03|04|05|06|07|08|09|10|11|12|
2011|01|02|03|04|05|06|07|08|09|10|11|12|
2012|01|02|03|04|05|06|07|08|09|10|11|12|
2013|01|02|03|04|05|06|07|08|09|10|11|12|
2014|01|02|03|04|05|06|07|08|09|10|11|12|
2015|01|02|03|04|05|06|07|08|09|10|11|12|
2016|01|02|03|04|05|06|07|08|09|10|11|12|
2017|01|02|03|04|05|06|07|08|09|10|11|12|
2018|01|02|03|04|05|06|07|08|09|10|11|12|
2019|01|02|03|04|05|06|07|08|09|10|11|