Netscape Communicator 4.5以降のプロファイル情報の解析 - 2011-12-19 - ククログ

ククログ

株式会社クリアコード > ククログ > Netscape Communicator 4.5以降のプロファイル情報の解析

Netscape Communicator 4.5以降のプロファイル情報の解析

クリアコードではMozillaサポート事業を行っていますが、その一環としてNetscape Communicator 4からFirefoxやThunderbirdへの移行のお手伝いもしています。今回は、NC4.5からThunderbirdへの移行の際に特に問題となった、NC4.5のユーザプロファイル一覧の取得方法の調査の解説を通じて、クリアコードでのオープンソース製品のサポート業務の一例をご紹介したいと思います。

NC4.5のユーザプロファイルの困った事情

本題の前に、まずは背景事情を説明しましょう。

Thunderbirdは、設計的にはNC4(Netscape Messenger)の子孫と言えますが、製品としてはNC4の後継ではありません。そのため、ThunderbirdのNC4からの移行支援の対象は非常に限られており、実際に「設定とデータのインポート」機能ではNC4のユーザ情報からはローカルに保存されたメールしかインポートできません。それ以上の情報をインポートしようと思うと、現状ではそのための仕組みを独自に用意する必要があります。

この時に問題になる事の1つとして、NC4のプロファイルの一覧やそれぞれのプロファイルの内容の保存場所をどのように取得すればよいのか?という事が挙げられます。

今のWindowsはマルチユーザ前提の設計になっており、"C:\Users<ユーザ名のフォルダ>\AppData\Roaming" (Windows XP以前では "C:\Documents and Settings<ユーザ名>\Application Data")内にユーザごとの設定を保存するのが当たり前になっています。しかし、昔のWindowsは「1台のPCを使うのは1人だけ」という前提で設計されていたため、複数人で1台のPCを共用しているなどの理由で複数のユーザ設定を使い分けたい場合には、アプリケーションの側で独自にマルチユーザ対応のための仕組みを持つ必要がありました。

NC4もそういった時代に作られた古いアプリケーションの1つで、既定の状態ではプロファイルは "C:\Program Files\Netscape\Users" 以下に保存されるようになっています。

ただ、プロファイルの実体は任意の場所に作成できるため、単純に "C:\Program Files\Netscape\Users" のサブフォルダを辿るだけでは見落としが発生してしまいます。すべてのプロファイルをもれなく把握するためには、そのPC上にあるプロファイルの一覧を取得するための正しい手順を踏まなくてはなりません。実は、Thunderbird 2までのバージョンにはnsreg.datの内容を解釈した結果を取得するためのAPIが含まれていたのですが、Thunderbird 3までの間でそのAPIは削除されてしまったので、今回は代わりの方法を考える必要がありました。

そのプロファイルの一覧の保存先なのですが、NC4の初期のバージョンではWindowsのレジストリに情報が保存されていたのに対し、Netscape 4.5以降では "C:\Windows\nsreg.dat" というバイナリファイルに情報が保存されるように仕様が変更されました。よって、NC4.5以降からThunderbirdへの移行を支援するためには、このバイナリファイルに立ち向かう必要があります。

前置きが長くなりましたが、このバイナリファイル(nsreg.dat)はどのような形式になっていて、どのようにすれば内容を読み取る事ができるのか、というのがこの記事での主題となります。

nsreg.datの内容を読み解くヒントを探す

バイナリファイルの内容自体は、以下のようにしてバイト列として読み込む事ができます。

// nsIFileでnsreg.datを渡すと仮定
function readBinaryFrom(aFile) {
  var fileStream = Cc['@mozilla.org/network/file-input-stream;1']
                     .createInstance(Ci.nsIFileInputStream);
  fileStream.init(aFile, 1, 0, false);
  var binaryStream = Cc['@mozilla.org/binaryinputstream;1']
                       .createInstance(Ci.nsIBinaryInputStream);
  binaryStream.setInputStream(fileStream);
  var bytes = binaryStream.readByteArray(fileStream.available());
  binaryStream.close();
  fileStream.close();
  return bytes;
}

この関数によって読み込まれた内容は、1バイト(8ビット)単位の内容がNumberとして格納されたArrayとして返されます1。ですので、後はこの単純なバイト列の中に埋め込まれた「意味」を読み取ればよいという事になります。

nsreg.datの内容について解説したドキュメントは無いものかと思いまずは簡単に検索してみましたが、断片的な情報はいくつか見つかるものの、ちゃんとした技術情報には辿り着けませんでした。なのでとりあえず、ファイルの中身を見てみる事にしました。

このファイルをバイナリエディタ2で開くと、「ProfileLocation」やプロファイルの位置と思われる文字列がNULL文字(終端文字)を伴う形で、一定の規則に則って埋め込まれている事が見て取れます。そこで、最初はこれらを単純にパターンマッチングで取り出してプロファイルの一覧として列挙するという事を試みました。

しかしながら、実際に使い込まれた環境のnsreg.datで検証すると、この方法では同じ名前のプロファイルが何度も列挙されるといった結果になってしまいました。どうやら、nsreg.datの内容は新しい情報が追記されていく一方で古い情報には削除フラグが立てられるだけという作りになっている模様で、単純なパターンマッチングでは余計な情報が大量にヒットしてしまう事があるようです。

どこが削除フラグなのか、そもそもどこからどこまでが正確に1つの情報の単位なのか、という事をここから探り当てるのは非常に困難です。そこで根本的解決を見るために、Mozilla Seamonkeyのソースツリーの中からnsreg.datのパースに関する処理を探し出す事にしました3

探索にあたって、まず単純にMXRで「nsreg.dat」を文字列検索してみたところ、nsDogbertProfileMigratorというファイルが見つかりました。MozillaプロジェクトではNetscape製品の事は何故かDogbertと呼ばれていたようなのですが、このモジュールはその名の通り、NC4のプロファイルを移行するための諸々の処理を実装しているようです。

この中でさらにプロファイルの一覧を取得しているらしい関数を探して中身を見てみた所、今度はreg.h(ヘッダファイル)とreg.c(実装)というさらに低レベルのAPIに辿り着きました。

nsDogbertProfileMigrator、もしくはさらに低層のAPIだけをコンパイルしてバイナリ形式のコンポーネントにするという手も無くはないのですが、

  • ビルドのための環境を整えるのには手間がかかりすぎる。
  • バイナリコンポーネントをアドオンに含めるのは今(Gecko 2.0以降)では色々と面倒が多い。
  • そして何より、インターフェース的にもあまり使いやすい物では無さそうである。

という事から、今後4のためにも、これに相当する使いやすいモジュールをJavaScriptで実装し直してみる事にしました。

ただ、nsDogbertProfileMigrator.cppは2200行程、reg.cは4100行程あって、全部を読んで把握するというのはちょっと面倒です。なので、実際のnsreg.datとソースコードの両面から、重要そうと思われるポイントに絞って調査を進めていきました。

nsreg.datのレジストリエントリ

先のソースコードにおけるnsreg.datの読み取り処理と、実際のnsreg.datの内容とを比較しながら見ていったところ、ファイルにはdescriptionと呼ばれる単位でレジストリのエントリが保存されている模様である事が分かりました。また、descriptionの中身は以下のようになっているという事も分かりました。

  • description全体の長さ:32バイト
    • 1バイト目〜4バイト目(location):そのdescriptionの先頭の位置(符号無し32ビット整数)
    • 5バイト目〜8バイト目(name):「名前」の先頭の位置(符号無し32ビット整数)
    • 9バイト目〜10バイト目(nameLength):nameの長さ(符号無し16ビット整数)
    • 11バイト目〜12バイト目(type):ノードの種類のフラグ(符号無し16ビット整数)
    • 13バイト目〜16バイト目(left):同じ階層にある次のノード(兄弟ノード)にあたるdescriptionの先頭の位置(符号無し32ビット整数)
    • 17バイト目〜20バイト目(down):最初の子ノードにあたるdescriptionの先頭の位置(符号無し32ビット整数)
    • 21バイト目〜24バイト目(value):「値」の先頭の位置(符号無し32ビット整数)
    • 25バイト目〜28バイト目(valueLength):valueの長さ(符号無し32ビット整数)
    • 29バイト目〜32バイト目(parent):親ノードにあたるdescriptionの先頭の位置(符号無し32ビット整数)

それぞれの値は8ビット毎のリトルエンディアン形式5で格納されており、下位の8ビットから順に並べ替える事で実際の値を得られました。

スクリーンショット:実際のnsreg.datの内容をバイナリエディタで表示した様子

実際にnsreg.datの中をもう一度見てみた所、プロファイルの位置と思われる文字列の近くに32ビットの長さのバイナリ部があり、先頭の4バイトがそのバイナリ部の位置そのものを示している事を確認できました。また、nameやvalueが指している位置にはUTF-8バイト列として文字列が格納されており、終端文字まで含めた長さがnameLengthおよびvalueLengthの長さとして格納されていました6

前述の「削除フラグ」は、この中のtypeという部分に含まれていました。この部分で特定のビットが立っていると、そのdescriptionは削除済みであるということになるようです。また、nsreg.datの中にはツリーの形で情報が保存されており、left、down、parentの各フィールドからツリー構造を辿っていける模様である事も分かりました。

DOM的にnsreg.datの内容を見るための実装

と、ここまで分かった所で、このdescriptionをDOM風のオブジェクトとして扱うための実装を試験的に作成してみる事にしました。

まず、バイト列をJavaScriptの数値や文字列(UCS2)に変換するユーティリティを定義します。

// バイト列→数値
function bytesToNumber(aBytes) {
  var converted = 0;
  aBytes.forEach(function(aValue, aIndex) {
    // リトルエンディアンの符号無し整数なので、
    // 単純に8桁ずつビットシフトした結果を合計すれば
    // 表現されている数値を得られる。
    converted += (aValue << (aIndex * 8));
  });
  return converted;
}

// バイト列→文字列
function bytesToString(aBytes) {
  var converted = '';
  aBytes.some(function(aValue, aIndex) {
    if (!aValue)
      return true;
    // 数値の配列から、1文字が1バイトの値を表す
    // UTF-8バイト列としての文字列に一旦変換する。
    converted += String.fromCharCode(aValue);
    return false;
  });
  return UTF8toUCS2(converted);
}

function UTF8toUCS2(aUTF8Octets) {
  return decodeURIComponent(escape(aUTF8Octets));
}

次に、これらのユーティリティを使って、「値の内容」「次のノード」などを自ら見つけ出すようなDescriptionクラスを試験的に作成しました。

function Description(aBytes, aOffset) {
  // 他のノードを探すためにはnsreg.dat全体のバイト列を
  // 保持しておかないといけない。
  this.allBytes = aBytes;
  this.bytes = aBytes.slice(aOffset, aOffset + this.DESCRIPTION_SIZE);

  this.location = bytesToNumber(this.bytes.slice(0, 3));
  if (this.location != aOffset)
    throw new Error('invalid description at '+aOffset);

  this.type = bytesToNumber(this.bytes.slice(10, 11));

  var nameOffset = bytesToNumber(this.bytes.slice(4, 7));
  var nameLength = bytesToNumber(this.bytes.slice(8, 9));
  this.name = bytesToString(this.allBytes.slice(nameOffset,
                                                nameOffset + nameLength));

  // 他のノードはlazy getterでその都度インスタンス化するため、
  // ここでは位置の情報だけを保持しておく。
  this._left   = bytesToNumber(this.bytes.slice(12, 15));
  this._down   = bytesToNumber(this.bytes.slice(16, 19));
  this._parent = bytesToNumber(this.bytes.slice(28, 31));

  // valueは他のノードを指している事があるので、これも
  // 位置と長さの情報だけを保持しておく。
  this._valueOffset = bytesToNumber(this.bytes.slice(20, 23));
  this._valueLength = bytesToNumber(this.bytes.slice(24, 27));
}
Description.prototype = {
  DESCRIPTION_SIZE : 32,
  // typeフィールドにおける、削除済のレジストリエントリかどうかを
  // 示すフラグ。
  TYPE_DELETED     : 0x80,

  get deleted() {
    return !!(this.type & this.TYPE_DELETED);
  },

  // valueはノードかもしれないし文字列かもしれないので、
  // 両方の可能性を考慮する。
  get value() {
    return this.nodeValue || this.stringValue;
  },
  get stringValue() {
    if (typeof this._stringValue == 'undefined')
      this._stringValue = bytesToString(
        this.allBytes.slice(this._valueOffset,
                            this._valueOffset + this._valueLength));
    return this._stringValue;
  },
  get nodeValue() {
    if (typeof this._nodeValue == 'undefined') {
      try {
        this._nodeValue = new Description(this.allBytes,
                                          this._valueOffset);
      }
      catch(e) {
        this._nodeValue = null;
      }
    }
    return this._nodeValue;
  },

  // left、down、parentに対応するlazy getter。
  // left/downではなくnext/firstChildなのは、その方が
  // オブジェクト的な表現での実態に即しているから。
  get nextDescription() {
    if (this._left && !this._nextDescription)
      this._nextDescription = new Description(this.allBytes, this._left);
    return this._nextDescription;
  },
  get firstChildDescription() {
    if (this._down && !this._firstChildDescription)
      this._firstChildDescription = new Description(this.allBytes, this._down);
    return this._firstChildDescription;
  },
  get parentDescription() {
    if (this._parent && !this._parentDescription)
      this._parentDescription = new Description(this.allBytes, this._parent);
    return this._parentDescription;
  },

  // deletedなdescriptionは実質的には無い物として扱うバージョン。
  // (実際にdeletedなdescriptionが参照されたままになっている事が
  // あるのかどうかはまだ分からないが、念のため。)
  get next() {
    return this.nextDescription && !this.nextDescription.deleted ?
             this.nextDescription : null ;
  },
  get firstChild() {
    return this.firstChildDescription && !this.firstChildDescription.deleted ?
             this.firstChildDescription : null ;
  },
  get parent() {
    return this.parentDescription && !this.parentDescription.deleted ?
             this.parentDescription : null ;
  },

  // deletedでない子ノードを収集する。
  // (実際にdeletedなdescriptionが参照されたままになっている事が
  // あるのかどうかはまだ分からないが、念のため。)
  get children() {
    if (!this._children) {
      this._children = [];
      let child = this.firstChildDescription;
      let found = {}; // 無限ループに陥ってしまわないよう、念のため。
      while (child) {
        if (!child.deleted && !found.hasOwnProperty(child.location))) {
          this._children.push(child);
          found[child.location] = child;
        }
        child = child.nextDescription;
      }
    }
    return this._children;
  },

  // 子ノードが名前を持っている場合に
  // それを簡単に取得するためのユーティリティ。
  getNamedChild : function(aName) {
    var found = null;
    this.children.some(function(aChild) {
      if (aChild.name == aName)
        found = aChild;
      return found;
    }, this);
    return found;
  },
  getNamedChildren : function(aName) {
    return this.children.filter(function(aChild) {
        return aChild.name == aName;
      });
  }
};

これを先のreadBinaryFrom()と組み合わせれば、description単位での調査はずっと容易になります。

nsreg.datの全容

ここまでの調査の過程で、nsreg.datの中には全てのユーザ情報のルートとなるエントリが存在しており、nsDogbertProfileMigratorのGetSourceProfiles()というメソッドの中でそれを起点としてその子ノードを走査しているらしいという事が分かっていました。という事は、その「ユーザ情報のルート」から芋蔓的にプロファイルの一覧を見つけられる(しかも削除済みのレジストリエントリを除外した形で)と考えられます。

残る問題は、そのレジストリエントリがどこにあるのかという事です。

ファイルの先頭から32バイトが全体のルートになるdescriptionなのでは?とも一瞬考えましたが、実際のファイルの先頭には先頭のアドレス(0000)とは似ても似つかない値が入っていたので、この可能性は無いようです。この調子で2バイト目から順番に見ていってもきりが無いですし、そもそもそれで見つかったとしても常にその位置に最初のdescriptionがあるとは限らないので、ここはやはり既存の実装を見るのが一番確実そうです。

という事でソースコードを見ていた所、reg.hの中に「must equal MAGIC_NUMBER7」や「major version number」といったコメントが付いた、REGHDRといういかにも怪しい名前の構造体の定義がある事に気がつきました8MAGIC_NUMBERという定数の定義を見ると、nsreg.datの先頭4バイトがまさにこのMAGIC_NUMBERと一致しています。また、REGHDRの中でrootというフィールドにあたる位置(先頭から13バイト目~16バイト目)に書き込まれていた値をdescriptionの位置として参照してみた所、全体のルートとなるdescriptionらしき内容に行き当たりました(このdescriptionの子ノードの中には先の「ユーザ情報のルート」となるdescriptionが含まれている事も確認できました)。さらに駄目押しで既存の実装から「REGHDR」を参照している箇所を探してみた所、レジストリファイルをオープンする処理の中で固定のオフセットとしてファイルの先頭からREGHDR分の長さだけ内容を読み込んでくるという処理も見つかりました。やはりこれがルートと見て間違いなさそうです。

残りの実装

これでようやく、nsreg.datの中を機械的に辿ってユーザプロファイルの一覧を取得する目処が立ちました。後は不足している部分を実装するだけです。

まず、nsreg.datの内容全体をバイト列として渡すとルートにあたるレジストリエントリを返す関数を定義します。

function getRootDescription(aBytes) {
  const ROOT_LOCATION        = 0xC;
  const ROOT_LOCATION_LENGTH = 4;
  var root = aBytes.slice(ROOT_LOCATION,
                          ROOT_LOCATION + ROOT_LOCATION_LENGTH - 1);
  return new Description(aBytes, bytesToNumber(root));
}

次に、それを使ってユーザのプロファイル一覧(名前とパスの組)を取得して返す関数を定義します。

function getProfilesFromBinary(aBytes) {
  var root = getRootDescription(aBytes);
  var users = root.getNamedChild('Users').children;
  var profiles = users.map(function(aUserNode) {
      return {
        name : aUserNode.name,
        path : aUserNode.nodeValue.stringValue
      };
    });
  profiles.sort(function(aA, aB) {
    return aA.name > aB.name;
  });
  return profiles;
}

最後に、システムのnsreg.datを自動検出してgetProfilesFromBinary()に渡し、その結果を返す関数を定義します9

function getProfiles() {
  try {
    const DirectoryService = Cc['@mozilla.org/file/directory_service;1']
                               .getService(Ci.nsIProperties);
    let file = DirectoryService.get('WinD', Ci.nsIFile);
    file.append('nsreg.dat');
    if (file.exists()) {
      let bytes = readBinaryFrom(file);
      return getProfilesFromBinary(bytes);
    }
  }
  catch(e) {
  }
  return [];
}

以上で必要な実装は揃いました。後はこれらを1つのファイルにまとめてJavaScriptコードモジュールにしておけば、今後またNC4からの移行案件があっても安心して対応できますね。

まとめ

NC4.5以降の古いNetscape製品ではバイナリ形式の独自のレジストリファイルが使われているという事、そのレジストリファイルに関する情報とパーサの実装はMozilla Seamonkeyのソースツリーから見つけられる事、そしてDOM的なアプローチでのパーサの再実装の流れを紹介しました。これ自体は非常に限定的な局面でしか役に立たない情報ですが、クリアコードのMozillaサポート事業やオープンソースソフトウェアのサポート事業では実際にどのような事をやっているのか?という事例紹介10も兼ねて記事にしてみた次第です。

古い製品であってもソースコードさえあれば、後の時代からでもこのように目的を達成できる可能性が高くなります。ソースが無いと、ここまですんなりとはいかなかったでしょう(本格的なリバースエンジニアリングをしないといけないとなると、どれだけの手間がかかるか考えたくもないですね)。「クローズドソースのパッケージ製品を重宝していたにも関わらず、提供元が倒産等で存在しなくなってしまい、もはやサポートを受けられなくなってしまった」という状況に陥ってしまう将来的なリスクを非常に重く見る場合、オープンソースな製品を導入するというのも現実的な選択肢の1つなのではないでしょうか。

  1. 今ならTyped Array を使った方が良いかも知れませんね。

  2. Ubuntu上で「Hexエディタ」を使いました。

  3. NC4の後継ソフトウェアであるNetscape 6〜Netscape 7は、Mozilla Application Suiteというソフトウェアにいくつかの機能を足した物でしたが、差異を最小限にする(Netscape側でないとできないという作業を最小化する)ためにほとんどの機能はApplication Suiteに含める方針だったようで、nsreg.datのパース処理もそういった機能の1つとしてApplication Suiteに含まれていました。そのMozilla Application Suiteは、Netscape亡き後も有志の手によりMozilla Seamonkeyとして開発が継続されています。実はThunderbird 2にもこの処理は含まれていたので、Thunderbird 2のソースコードを調査してもよかったのですが、今回探索を行った時にはその事を失念していました……

  4. あるんでしょうか?

  5. 8ビットを超える長さのデータを表現する際の表現形式で、8ビットを1桁と考えた時に上の桁から順に格納する方式をビッグエンディアン、下の桁から順に格納する方式をリトルエンディアンと言います。ビッグエンディアンは人間にとって分かりやすく、リトルエンディアンはコンピュータにとって分かりやすいという利点がそれぞれあります。

  6. ただし、valueLengthは0になっている場合があり、その場合はvalueの位置から32バイトの内容がまた別のdescriptionになっている、という構造になっていました。

  7. 定数値の中でも、それ単体を見ても意味が全く分からない上に、他に何の説明も無い物。一意な識別子として使われる事がある。

  8. 「hdr」はThunderbirdのソース中では「header」または「handler」の意味で使われている事が多いです。

  9. ソースコードを見た限りではWindows版だけでなくMac OS版やUNIX版のNC4.5もnsreg.datを使用している様子でしたので、この箇所だけ各プラットフォーム向けに実装を分ければWindows/Mac OS/UNIXの3プラットフォームに対応できそうです。

  10. 純粋に調査のみの依頼の場合もあれば、今回のように移行案件の中で必要に迫られて行う場合もあります。