7-Zip ライブラリとしての CubeICE

圧縮・解凍ソフト CubeICE をゼロから改修 に記載した通り、昨年、CubeICE に対して大幅な修正を実施しましたが、この際にライブラリとしても利用できるようにインターフェース(クラスやメソッド)を整理する事をテーマの一つに設定していました。そこで、この記事では CubeICE (正確には Cube.FileSystem.SevenZip.dll)を 7-Zip ライブラリとして利用するための方法について記載します。

事前準備

Cube.FileSystem.SevenZip は NuGet 経由で取得する事ができますが、この中には 7z.dll が含まれないので別途ダウンロードして実行ディレクトリに配置して下さい。7z.dll は www.7-zip.org から取得できる他、オリジナルに対して Unicode ビルドする等いくつかの修正を加えたものを GitHub releases に公開しています(また、修正版のリポジトリcube-soft/7z で公開しています)。尚、C# 等から Unmanaged な dll を利用する場合 x86/x64 の設定が問題になるので、特に Any CPU でビルドする場合、どちらの dll を必要とするか注意して下さい。

圧縮ファイルの解凍・展開

圧縮ファイルを解凍・展開するサンプルコードは下記になります。尚、全てのコードで "using Cube.FileSystem.SeventZip;" が記述されているものとします。

// Set password directly or using Query<string>
var password = new Cube.Query<string>(e =>
{
    e.Result = "password";
    e.Cancel = false;
});

using (var reader = new ArchiveReader(@"path\to\archive", password))
{
    var progress = new Progress<Report>(e => DoSomething(e));
    reader.Filters = new[] { ".DS_Store", "Thumbs.db", "__MACOSX", "desktop.ini" };
    reader.Extract(@"path\to\directory", progress);
}

ArchiveReader クラスは、オブジェクト生成時の第 1 引数に指定されたファイルに対して、解凍・展開するために必要な 7-Zip モジュールを自動的にロードします。また、第 2 引数はパスワードを表し、文字列を直接指定できる他、Cube.IQuery<string> を実装したオブジェクトを指定する事も可能です。このオブジェクトは、「パスワード・ダイアログ」などを通じてユーザにパスワードを尋ねるようなインターフェースを実現する際に利用します。

Filters プロパティは CubeICE のフィルタリング機能をライブラリとして実装したもので、解凍・展開時に除外するファイルまたはフォルダの一覧を指定します。最後に Extract メソッドに解凍・展開後のフォルダのパスを指定して、処理を開始します。この時、第 2 引数に System.IProgress<Report> を実装したオブジェクトを指定すると、このオブジェクトを通じて処理状況が通知されます。

using (var reader = new ArchiveReader(@"path\to\archive", "password"))
{
    // Save as "path\to\directory\{item.FullName}"
    reader.Items[0].Extract(@"path\to\directory");
}

ArchiveReader クラスの Items プロパティでは圧縮ファイルに含まれる各項目の情報 (ArchiveItem) を取得できますが、ArchiveItem に定義されている Extract メソッドを実行する事で個別に解凍・展開する事も可能です。ただし、開発環境で検証した限りですが、解凍・展開するフォーマットによっては処理時間が増大する場合があるようです。そのため、このメソッドは、あくまでも特定ファイルを解凍・展開したい時に使用する程度に留めておいた方が良いかもしれません。

圧縮ファイルの生成

圧縮ファイルを生成するサンプルコードは下記になります。

using (var writer = new ArchiveWriter(Format.Zip))
{
    writer.Add(@"path\to\file");
    writer.Add(@"path\to\directory_including_files");
    writer.Option  = new ZipOption { CompressionLevel = CompressionLevel.Ultra };
    writer.Filters = new[] { ".DS_Store", "Thumbs.db", "__MACOSX", "desktop.ini" };
    
    var progress = new Progress<Report>(e => DoSomething(e));
    writer.Save(@"path\to\save.zip", "password", progress);
}

ArchiveWriter クラスは、まず、圧縮形式を指定してオブジェクトを生成します。Format は、圧縮と解凍・展開で共用しているため大量の定義が存在しますが、ArchiveWriter として対応しているものは Zip, SevenZip, Tar, GZip, BZip2, XZ の 6 種類となります。そして、Add メソッドを通じて、生成されたオブジェクトに対してファイルまたはフォルダを追加していきます。尚、フォルダが指定された場合、フォルダに含まれるファイルおよびフォルダが再帰的に追加されていきます。最後に、Save メソッドに保存パスを指定して圧縮処理を開始します。

Option プロパティには圧縮時の各種オプションを指定する事ができます。指定可能な内容については、ArchiveOption.cs を参照下さい。まだ、基本的なオプションにしか対応できていませんが、追々、その他のものにも対応していく予定です。また、Filters プロパティおよび、Save メソッドの System.IProgress<Report> 引数の役割は解凍・展開の時と同様です。

using (var writer = new ArchiveWriter(Format.Tar))
{
    writer.Option = new TarOption
    {
        CompressionMethod = CompressionMethod.BZip2, // GZip, BZip2, XZ or Copy
        CompressionLevel  = CompressionLevel.Ultra,
    };

    writer.Add(@"path\to\file");
    writer.Add(@"path\to\directory_including_files");
    writer.Save(@"path\to\save.tar.gz");
}

*.tar.gz のように Tar 系の圧縮ファイルを生成する場合、ArchiveWriter のコンストラクタには Format.Tar を指定してオブジェクトを生成します。その上で、Option プロパティに対して、TarOption クラスを利用して圧縮方式を指定します。

進捗状況のレポート機能

System.IProgress<Report> を通じて通知される進捗内容は下記になります。

public class Report
{
    public ReportStatus Status { get; }
    public public Information Current { get; }
    public long Count { get; }
    public long TotalCount { get; }
    public long Bytes { get; }
    public long TotalBytes { get; }
    public double Ratio => TotalBytes > 0 ? Bytes / (double)TotalBytes : 0.0;
}

TotalCount は圧縮または解凍・展開対象となるファイルおよびフォルダの総数、Count は Bytes は処理の終了した数を示し、TobalBytes および Bytes はバイト数に関する内容を表します。Current は現在処理中のファイルまたはフォルダの情報を保持し、Status は Current の処理状況 を Begin(開始直前)、End(終了)、Progress(処理中)で示します。

尚、Status および Current に関しては現時点では解凍・展開用と言う性質が強く、圧縮時にはあまり信頼できません(一応 Begin は通知されますが、End の通知は未実装)。これは、圧縮処理は 7-Zip 自体がマルチスレッド対応している事もあり、正確に通知する事が難しいと言う課題が残っているためです。この辺りをどうするかは、今後、検討していきます。

CubePDF シリーズの大改修

先日 CubePDF Utility 0.5.0β のリリースが完了し、数ヶ月にわたる CubePDF シリーズの改修、さらに言えば、去年からスタートさせていた CubeICE を含むキューブ・ソフト初期のソフトウェア大改修プロジェクトがようやくひと段落しました。そこで、この記事では CubePDF シリーズ、特に CubePDF Utility の改修後書を記載していきます。

はじめに

CubePDF シリーズには全部で 6 個のソフトウェアが存在しますが、今回改修した CubePDFCubePDF Utility、それに簡易版 CubePDF Utility として位置付けている CubePDF Page を始めとして、多くのユーザにご利用頂いているようです。特に、CubePDF は単純な累計ダウンロード数であれば 1,000 万を超えるような数字となっており、約 8 年前に最初のバージョンをリリースした時には、ここまで育つとは思いもよらなかったと言うのが正直な感想です。

CubePDF Utility

そんな CubePDF シリーズですが、10 年近い年月が経過する内に、ソフトウェアの保守がどんどん大変になっていくと言う状態が続いていました。また、CubePDF シリーズ内でのソースコードの共有が上手くいっていなかった事や、CubePDF Utility に存在する諸問題を解決するために利用ライブラリを変更したい等、様々な課題を解決するに際して、一度ソースコードを整理しておきたいと思ったのが今回の大改修を実施した理由です。

余談として、本当であれば CubePDF の話を主役に据えたかったのですが、CubePDF に関しては思ったほど書く事がないと言う理由で、今回の主役は CubePDF Utility に譲っています。CubePDF は、私が人生の中で初めてゼロから作成した GUI アプリケーションですが、ソースコードの保守と言う観点で見ると、他のものに比べれば意外と上手くいってる事は嬉しい誤算でした。これは、View 自体はさほど複雑ではない等の理由によるものだろうと思いますが、そう言った点も含めて、いろいろと予想外だったソフトウェアです。

PDFium の採用

CubePDF Utility の改修における大きな変更点の一つとして PDFium の採用が挙げられます。CubePDF Utility は、これまでレンダリングエンジンとして MuPDF .NET Framework 用にラップした PDFLibNet と言うライブラリを使用していましたが、Unicode 版としてビルドされていないためにファイルパスの扱いで問題が発生する事があり、サムネイル画像が表示されない等の原因にもなっていました。

一方 PDFium では、下記のように必要なタイミングで必要なバイトデータを返す関数ポインタを指定するためのインターフェースが公開されています。このインターフェースを利用してファイルの操作自体は C#/.NET 側で行う事により、これまでの懸念事項であったパスの問題を解決する事ができました (PdfiumReader.cs)。

_core = PdfiumApi.FPDF_LoadCustomDocument(
    new FileAccess
    {
        Length    = (uint)_stream.Length,
        GetBlock  = Marshal.GetFunctionPointerForDelegate(_delegate),
        Parameter = IntPtr.Zero,
    },
    password
);

WPF の再入門

CubePDF Utility は、約 6 年前に WPF を用いて作成した初めての GUI アプリケーションでしたが、WPF での代表的な開発パターンである Model-View-ViewModel (MVVM) への理解が足りなかった事が、その後の保守性の悪さに繋がりました(所謂 Fat View、Fat ViewModel 問題)。また、完成したアプリケーションを実際に試してみるとコールドスタートが非常に遅いと言う結果となったため、これ以降の何年もの間、新規プロジェクトにおける WPF の採用を見送る原因にもなりました。キューブ・ソフトで公開している Windows ソフトウェアの中で CubePDF Utility のみスプラッシュ画面を表示しているのも、このためです。

しかし、ここ数年、システムドライブに採用されるストレージが HDD から SSD に変化するのにつれて、コールドスタートの問題は相対的に小さなものとなりました。また、リリース後に何度か実施した調査によると、どうも 利用している Ribbon ライブラリ の影響も大きいと言う結果が出ていたため、今回、利用ライブラリを Fluent.Ribbon に変更する事で改善を試みています*1。こう言った状況の変化もあって WPF 自体を忌避する理由も徐々に薄れているため、2018 年の個人的なテーマの一つとして WPF の再入門を設定し、これまでに CubeRSS Reader の新規作成と CubePDF Utility の改修を行ってきました。

その成果の一つが下図になります。これは CubePDF Utility のメイン画面を表す MainWindow.xaml.cs の改修前後における記述行数の変化を示したものですが、2,120 行 から 0 行 に削減できた事が分かります(Visual Studio による自動生成部分を除く)。もちろん、実際に 1 行も記述せずに実現できている訳ではないのですが、インパクトの大きさを示すのには良い例かなと思って紹介します。

MainWindow.xaml.cs コード行数の変化

今回、個人的にもっとも感動したのは Expression Blend SDK によって提供される Behavior と言う概念でした。WinForms を始めとして、EventHandler ベースの GUI プログラミングはややもすると View がゴチャゴチャする (Fat View) と言う問題点が指摘されますが、この原因の一つに「EventHandler をどこに記述すれば良いのか判断できない」と言うものがあるように思います。自分自身を振り返っても、EventHandler の記述内容を見ると public なプロパティおよびメソッドのみで完結しているので必ずしも MainForm.cs のような場所に記述する必要はないが、それ以外の適当な場所も思いつかないため、結局そこに書いてしまう(そして、どんどんと膨れ上がる)と言う経験が多々ありました。

Behavior と言う概念は、この問題に対して「単一、あるいは一纏まりの View の動作に対して名前を付け、クラス化する」と言う解決策を示してくれました。さらに、これによって「View の動作単位での汎用化」と言う可能性も視野に入るようになり、特に MVVM における Messenger (EventAggregator) パターンと併用する事で、かなり柔軟な記述が可能となってきました。例えば、CubePDF Utility では「メッセージとしてサブ画面用の ViewModel オブジェクトを送信すると、View は ViewModel の型に紐づけられた SubView を生成して DataContext にその ViewModel を設定し、ShowDialog メソッドを実行する」と言う「動作」を予め作成しておく事で、View の個別の生成処理をかなり削減するのに成功しています。

<i:Interaction.Behaviors>
    <xb:DialogBehavior />
    <xb:OpenFileDialogBehavior />
    <xb:SaveFileDialogBehavior />
    <xb:UriBehavior />
    <xb:CloseBehavior />
    <xb:ClosingBehavior Command="{Binding Ribbon.Close.Command}" />
    <my:PasswordWindowBehavior />
    <my:PreviewWindowBehavior />
    <my:InsertWindowBehavior />
    <my:RemoveWindowBehavior />
    <my:MetadataWindowBehavior />
    <my:EncryptionWindowBehavior />
    <my:SettingsWindowBehavior />
    <my:DragFileBehavior Command="{Binding Drop}" />
</i:Interaction.Behaviors>

Behavior と言うライブラリ自体は 6 年前から使用していたのですが、今回、改めて見直す事によって View との連携方法を大きく前進できたのは非常に良かったように思います。

正式リリースに向けて

キューブ・ソフトで公開しているソフトウェアの多くは β や RC を名乗っています。「いつまで β なんだよ」と言う指摘を見かける事もありますが、これは私自身が長年、正式リリースを名乗って良いのかどうか判断できないし自信もない、と感じていたためと言う面がありました。

この問題を克服するために、2017 年から「アプリケーション部分を含めたユニットテストの拡充と CI 環境による継続的なテスト」と言う課題に取り組んできました(参考:圧縮・解凍ソフト CubeICE をゼロから改修)。そして、現時点で振り返ってみると、まだ完璧とは言い切れませんが、ここ 1 年のものが実を結びつつあると言う実感がでてきました。もう少し時間がかかりそうですが、順次、正式リリースと言えるように今後も環境を整えていく予定です。

また、Cube.Pdf プロジェクトに関しては、ライブラリ部分が CubePDF シリーズで必要な範囲しか実装できておらず不十分なため、もう少し頑張って実装したいと言う思いがあります。こちらに関しては、どうしても優先度が下がりがちになってしまうのですが、全体のスケジュールを見ながら上手く進めていきたいと思います。

*1:ただし、Fluent.Ribbon .NET Framework 4 以降でないと利用できないため、.NET 3.5 版では従来の Ribbon ライブラリを使用しています。

CubeICE の Zip Slip 脆弱性に関する調査報告

先ほど「Zip Slip」と命名された圧縮・解凍処理に関する脆弱性の情報を目にしました。

この記事では、Zip Slip の概要および CubeICE における関連部分の処理内容について記載します。尚、CubeICE に関しては、少なくとも 0.8.0β 以降であれば、Zip Slip 脆弱性の原因とされる不都合はない 事を Snyk によって公開されている サンプルファイル を利用したテスト等によって確認しています。テストコードは ArchiveReaderTest を参照下さい。また、これに合わせて、CubeICE 0.8.0β 未満をご利用のユーザは、可能な限り最新版のご利用をお願いします(参考:圧縮・解凍ソフト CubeICE をゼロから改修)。

Zip Slip 確認用のサンプルファイルを CubeICE で解凍した結果

Zip Slip 脆弱性の概要

Zip Slip は、圧縮ファイルのファイルリスト(ヘッダ部分)に ".." (double-dot) を含むパスが存在する場合、親ディレクトリに存在するファイルがユーザの意図しない形でコピーまたは上書きされる directory traversal と呼ばれる脆弱性の一種のようです。例えば、Zip Slip の解説サイトでは tmp/evil.sh と言うパスの前に大量の ".." を付記しておく事で、/tmp ディレクトリに悪意のあるファイルを作成する手順が示されています。

In the example below, we can see the contents of a zip file. It has two files, a good.sh file which would be extracted into the target directory and an evil.sh file which is trying to traverse up the directory tree to hit the root and then add a file into the tmp directory. When you attempt to cd .. in the root directory, you still find yourself in the root directory, so a malicious path could contain many levels of ../ to stand a better chance of reaching the root directory, before trying to traverse to sensitive files.

5 Tue Jun 5 11:04:29 BST 2018 good.sh
20 Tue Jun 5 11:04:42 BST 2018 ../../../../../../../../tmp/evil.sh

Zip Slip Vulnerability

圧縮ファイルのファイルリストに含まれる ".." を処理する際には注意が必要である、と言う問題自体は古くから認識されています。例えば、先日 iOSAndroid 上の多数のアプリに「ZipperDown」と命名された脆弱性が存在すると報道されましたが、これも directory traversal に起因するものだったようです。

しかし、今回の脆弱性に関しても、ファイルリストに含まれるパスをそのまま扱っている事例が思いの外多かったのか Zip Slip の影響を受けるライブラリ もかなりの数に上っています。Snyk では、例えば下記のように、ライブラリから取得出来た名前(相対パス)をそのまま結合するようなコードが含まれている場合、特に注意を要する旨が記載されています。

Enumeration<ZipEntry> entries = zip.getEntries();
while (entries.hasMoreElements()) {
    ZipEntry e = entries.nextElement();
    File f = new File(destinationDir, e.getName());
    InputStream input = zip.getInputStream(e);
    IOUtils.copy(input, write(f));
}

CubeICE における圧縮ファイル中のパスの扱い

CubeICE では、圧縮ファイルのヘッダ情報から取得したパスに対して PathFilter と言う自作クラスを介してアクセスしています。PathFilter は、指定されたパスをディレクトリ単位で分割し、それぞれのファイル名またはディレクトリ名に対して下記のようなチェックを実行します。また、CubeICE では 圧縮・解凍時に特定のファイルまたはディレクトリをフィルタリングする機能を提供していますので、その判定も PathFilter が担っています。

  • カレントディレクトリを表す "." (single-dot) が含まれているかどうか
  • ディレクトリを表す ".." (double-dot) が含まれているかどうか
  • ドライブ文字 (drive-letter) が含まれているかどうか
  • UNC パスを表す接頭辞 "\\" が含まれているかどうか
  • サービス機能の不活性化を表す接頭辞 "\\?\" が含まれているかどうか
  • ファイル名またはディレクトリ名の一部に使用不可能な記号が含まれているかどうか
  • ファイル名またはディレクトリ名に Windows で予約済みの名前が存在するかどうか

この PathFilter を実際に利用しているコードが下記 (ArchiveItemController) になります。ただし、これは圧縮ファイルの 1 項目を表す ArchiveItem クラスの内部実装なので、普段はその存在を意識する事はありません。

_filter = new PathFilter(RawName)
{
    AllowParentDirectory  = false,
    AllowDriveLetter      = false,
    AllowCurrentDirectory = false,
    AllowInactivation     = false,
    AllowUnc              = false,
};

src.FullName = _filter.EscapedPath;

上記の各種 AllowXxx プロパティでは、対応するシンボル(single-dot, double-dot, drive-letter, ...) の存在を許容するかどうかを設定しています。PathFilter は許容しない場合には該当のシンボルを除去するため、結果として CubeICE ではこれらの特殊なシンボルを全て無視します。例えば、脆弱性の解説において例示されていた "../../../../../../../../tmp/evil.sh" と言うパスは、単に "tmp/evil.sh" として扱います。この処理は、基本となるライブラリ部分で強制的に実行しているため、Readme に記載している下記のサンプルコードにおいても、今回の脆弱性の影響を受ける事はありません。*1

// Set password directly or using Query<string>
var password = new Cube.Query<string>(e => e.Result = "password");
using (var reader = new ArchiveReader(@"path\to\archive", password))
{
    reader.Extract(@"path\to\directory");
}

*1:もっとも、現時点では、System.FileSystem.SevenZip ライブラリ自体はまだ NuGet に登録していないため、この情報が参考になる人がいるのかは不明ですが……