[Rust #01] WinRAR置き換えのアーカイバ「Tote」を作り、7z・ISO・パスワード付き書庫まで

[Rust #01] WinRAR置き換えのアーカイバ「Tote」を作り、7z・ISO・パスワード付き書庫まで

はじめに

WinRARを長く使ってきましたが、自分が使う機能だけを持った軽いものがほしくなりました。そこで、WinRARの代わりに使う自作のアーカイバを作りました。名前は Tote です。

  • 右クリックから、形式・圧縮レベル・パスワードを選んで書庫を作る(ZIP / 7z / tar.gz / tar)
  • 書庫を開くと、展開する前に中身を一覧できる(ZIP / 7z / RAR / tar.gz / CAB / LZH / ISO)
  • 項目をエクスプローラーへドラッグすると、その項目だけ展開される
  • ZIPには、ドロップでファイルを追加できる
  • ZIP / 7z のパスワード付き書庫を、作れて、開ける

作る言語は Rust です。画面だけ Tauri v2 と素のJavaScriptで、書庫の処理はすべてRustです。

スクリーンショット

[Rust #01] Tote 書庫を作成ダイアログ

リポジトリ:

前回の記事(Tauriで作ったアプリの話):

1. 構成:Rustが本体、画面は「必要なときだけ」開く

core/   tote-core(ライブラリ)。書庫の作成・一覧・展開。画面もOSも知らない
app/    tote(exe)。引数の処理、右クリックの登録、Tauriの窓(ビューア / 設定 / 作成)

core は、ファイルとバイト列だけを扱う純粋なライブラリにしました。そのおかげで、Linuxのサンドボックスでも、画面なしで cargo test が回せます。Windowsでしか確かめられないのは、右クリックの登録と、画面の見た目くらいです。

もう1つ大事にしたのが、右クリックの圧縮では、窓もWebViewも作らないことです。右クリックから起動したとき、圧縮だけならプロセスが立ち上がってすぐ処理して終わります。窓を作るのは、ビューア・設定・作成ダイアログのときだけです。

形式ごとの読み出しは、1つのトレイトにそろえました。

pub(crate) trait Backend {
    fn list(&self, path: &Path, pw: Option<&str>) -> Result<Listing, Error>;
    fn walk(&self, path: &Path, entries: &[Entry], pw: Option<&str>, sink: &mut dyn Sink) -> Result<(), Error>;
    fn check_password(&self, path: &Path, entries: &[Entry], wanted: &[usize], pw: &str) -> Result<(), Error> { Ok(()) }
}

list は一覧、walk は欲しい項目を順に Sink(書き出し側)へ流す役です。新しい形式を足すときは、この2つを書くだけです。形式の判定は、拡張子ではなく先頭のバイト列(マジックナンバー)で行い、だめなときだけ拡張子を見ます。

使ったライブラリは、次のとおりです。

  • ZIP:zip(AES暗号とdeflate64つき)
  • 7z:sevenz-rust2
  • RAR:unrar(展開のみ)
  • tar / gz:tar、flate2
  • CAB:cab、LZH:delharc
  • ISOは、ライブラリを使わず自前です(後述)

2. 右クリックで複数選択すると、プロセスが選択数ぶん起動する

Windowsの右クリックメニューの「動詞」は、ファイルを10個選ぶと、プロセスが10回起動されます。1つの書庫にまとめたいのに、各プロセスは自分の1ファイルしか知りません。

そこで、次の形にしました。

  1. 起動した各プロセスは、自分の引数を、共有のスプール用フォルダへ1ファイルとして書く
  2. 先にロックを取れた1つだけが「リーダー」になり、少し待って(デバウンス)、集まった引数を全部読む
  3. リーダーが、まとめて1つの書庫を作る。ほかのプロセスは、書いたらすぐ終わる

作成ダイアログを開くときは、ひと工夫が要りました。リーダーが集め終わってから、ロックを手放してから窓を開きます。窓を開いている間もロックを持っていると、次の右クリックが待たされるからです。

なお、エクスプローラーは、15個を超える選択では、右クリックの動詞を呼んでくれません。その場合は「送る」メニューのほうが確実で、「送る」なら、全部の引数が1プロセスに届きます。設定画面で、両方を登録できるようにしました。

3. 7zの罠:「データのない項目」が混ざると、後ろのファイルを取りこぼす

7zは、複数のファイルを1つの塊(ブロック)にまとめて圧縮します。使っているライブラリのブロック復号は、「ブロックに入っている項目は、ヘッダーの中で連続して並んでいる」という前提で書かれています。

ところが、実際の書庫では、フォルダや空ファイル(データを持たない項目)が、データを持つファイルの間に混ざっていることがよくあります。そうすると、混ざった位置より後ろのファイルを、取りこぼしました。

対策は、ブロックごとに「データを持つ項目を先頭に寄せる」ように、項目の並びを入れ替えることです。

fn group_stream_files(archive: &mut Archive) -> Vec<usize> {
    let n = archive.files.len();
    let mut order: Vec<usize> = (0..n).collect();
    for b in 0..archive.blocks.len() {
        let first = archive.stream_map.block_first_file_index[b];
        let members: Vec<usize> =
            (first..n).take_while(|&i| archive.stream_map.file_block_index[i] == Some(b)).collect();
        let (streams, empties): (Vec<usize>, Vec<usize>) =
            members.iter().partition(|&&i| archive.files[i].has_stream);
        for (k, &orig) in streams.iter().chain(empties.iter()).enumerate() {
            order[first + k] = orig;
        }
    }
    // files を order の順に並べ替え、「並べ替え後の位置 → 元の位置」の対応表を返す
    ...
}

戻り値の対応表で、画面に出している項目の番号と突き合わせます。再現用に、わざと項目を混ぜた7zを、テストの材料として置いています。

読み出しも、必要な項目があるブロックだけを復号します。固体圧縮でない書庫なら、1ファイルを取り出すときに、ほかのブロックは触りません。

4. ISOを自前で読む:ISO 9660 / Joliet / Rock Ridge

ISOの中身を見られるようにするため、ライブラリを使わずに、ISO 9660の読み出しを自前で書きました。

  • ボリューム記述子は、セクタ16(2048バイト×16)から並んでいる。CD001 の印で見分ける
  • Joliet(Windowsが作る長いファイル名の拡張)があれば、そちらを優先する。名前は UCS-2のビッグエンディアン
  • Jolietがなければ、基本の記述子を読み、Rock Ridgeの NM(長い名前)、SL(シンボリックリンク)、PX(権限)で名前を補う
  • 基本のISO 9660の名前の末尾にある ;1(バージョン)は、取り除く
  • 大きなファイルは複数の領域(エクステント)に分かれるので、順に読んでつなぐ
  • 壊れたISOで無限ループや深すぎる階層に落ちないよう、循環と深さにガードをかける

検証には、xorriso で3種類のISO(Joliet / Rock Ridge / どちらもなし)を作って、テストの材料にしました。その途中で気付いた点が1つあります。

Jolietは UCS-2 なので、絵文字のような基本多言語面の外の文字を入れられません。xorrisoで絵文字入りの名前を入れたら、_ に置き換わっていました。絵文字の名前は、Rock Ridgeだけのイメージでテストしています。

実機では、昔のレッツノートのリカバリディスクのISO(7.8GB)を開いてみました。ISO 9660の構造を読むだけなので、中身の一覧は出せます。ただ、そこから大きなファイルをドラッグで取り出すと、いったん一時フォルダへ全部展開してから渡すので、遅くなります。ここは今後の課題です。

なお、UDF(大きいDVDやBlu-rayのISO)は未対応です。そういうディスクは、専用のソフトを使うほうがよいと判断しました。

5. ZIPへ「追記」する:安全優先と高速優先

ビューアで開いているZIPへ、ファイルをドロップして追加できます。ZIPは、末尾にある「中央ディレクトリ」に目次を持つので、追記の方法が2つあります。

  • 安全優先:一時ファイルを作り、元の項目を再圧縮せずにそのままコピー(raw_copy_file)して、新しい項目を足し、最後に差し替える。途中で止まっても、元の書庫は無事。大きな書庫ではコピーに時間がかかる
  • 高速優先:書庫の末尾へ、その場で追記する(ZipWriter::new_append)。速いが、途中で止まると書庫が壊れることがある

どちらを使うかは、設定画面で選べます。ただし、同名の項目を置き換えるときは、末尾への追記では古い項目を消せないので、高速優先を選んでいても、安全優先に切り替わります。切り替わったときは、画面に通知を出します。

6. 書庫を作る:途中で止めても、書きかけを残さない

作成時の方針は、次のとおりです。

  • 書き込みは 名前.tote-part に行い、最後まで成功したら名前を付け替える。失敗しても、書きかけのファイルは残らない
  • 入力が1つのフォルダのときは、フォルダの中身を書庫のルートに置く(展開したとき、フォルダが二重にならない)
  • ZIPのパスワードは AES-256。ただし、Windows標準のエクスプローラーでは開けない。7-Zip・WinRAR・Toteでは開ける
  • 7zも AES-256。ファイル名の暗号化は、7-Zipと同じく既定でオフにした(オンにすると、開くときにもパスワードが要る)
  • 7zは、既定で固体圧縮。全体をまとめて圧縮するので小さくなるが、一部だけ取り出すときは少し遅くなる。「最高」のレベルは、メモリを食いすぎないよう、7相当に抑えた

作ったものは、自作の読み出しで読み戻して確かめたうえで、別の道具でも確かめました。7z / ZIP(AES)は 7-Zip の 7z t、7zは py7zr、tar.gzは GNU tar で、それぞれ読めています。自分のコードで作って自分で読むだけだと、同じ思い違いを両方に入れてしまうことがあるからです。

右クリックの項目は、「ダイアログで選ぶ」に寄せた

最初は、右クリックの項目が「ToteでZIPに圧縮」(ダイアログなしで直接圧縮)でした。ところが、使ってみると、WinRARのときから、いつも形式と圧縮率をダイアログで選んで作る習慣がありました。そもそも、ZIPを作ることがほとんどありません。

そこで、主な項目を「Toteで書庫を作成…」(ダイアログ)に置き換えて、ダイアログなしの直圧縮は、設定で選べるようにして、既定はオフにしました。右クリックのメニューが多すぎるのが、前のソフトをやめた理由でもあるので、増やさない方向に倒しています。

7. パスワード付きの展開:7zには「検査値」がない

パスワード付き書庫を作るのはまだ簡単ですが、開くのは、パスワードが違ったときの扱いが難しいです。

ZIP

ZIPの暗号化には、鍵が合っているかを調べる検査値が入っています。ライブラリの by_index_decrypt に、パスワードを渡して開けば、違えば InvalidPassword が返ります。1つ開くだけで合否が分かります。

7z

7zには、その検査値がありません。違うパスワードで復号しても、それらしいゴミのバイト列が出てくるだけです。分かるのは、LZMAの復号がエラーになるか、最後にCRC32が合わないか、のどちらかです。

そこで、展開を始める前に、暗号化されている項目のうち、一番サイズの小さいものを実際に復号して、CRC32を調べるようにしました。

fn check_password(&self, path: &Path, entries: &[Entry], wanted: &[usize], pw: &str) -> Result<(), Error> {
    // 暗号化された項目のうち一番小さいものを復号して、CRC32 で合否を見る
    let Some(&probe) = wanted.iter()
        .filter(|&&i| entries[i].size > 0)
        .min_by_key(|&&i| entries[i].size) else { return Ok(()) };
    ...
    if p.bad || !p.done { Err(Error::WrongPassword) } else { Ok(()) }
}

この確認は、1ファイルも書き出す前に行います。展開の途中でパスワードの間違いに気付くと、先に書いた分が残ってしまい、入れ直して再実行したときに name (1) のような重複ができるためです。違っていたときは、展開先のフォルダも作りません。

名前まで暗号化された7z

7zで名前の暗号化をオンにすると、ヘッダーそのものが暗号化されます。この場合は、一覧を出す時点でパスワードが要ります。ビューアは、開いた瞬間にパスワードを聞いて、違えば聞き直します。

パスワードの扱い

  • 画面で入力したパスワードは、その窓を閉じるまで、メモリの上だけで持つ。保存はしない
  • 最初にパスワード付きの項目を開く・展開するときに、入力を求める。一度入れれば、聞き直さない
  • ドラッグで取り出すときは、先に入力が済んでいる必要がある。マウスを押している最中には、ダイアログを出せないため

動作確認は、自分で作った書庫だけでなく、7-Zipで作った書庫でも行っています。ZIPのAES、従来のZipCrypto、7z、名前を暗号化した7zの4種類で、読み出せます。

8. 日本語のファイル名

ZIPのファイル名は、昔の日本語環境では Shift_JIS で入っているものが多くあります。フラグが立っていなければUTF-8ではないので、encoding_rs でShift_JISとして読み直す処理を入れています。LZH(Shift_JISの名前)も、文字化けせずに出ました。

一方、CABの日本語名は、文字化けすることがあります。ここは未対応です。

9. 配布:WindowsのCIだけが落ちた

配布は、[Tauri #07] のDISC.TOCと同じく、タグをpushすると、GitHub Actionsが動く構成です。DISC.TOCのワークフローをもとに、次のように作りました。

  • Windows:cargo build --release した tote.exe を、READMEと一緒にzipにする。インストーラーは作らない(右クリックの登録は、exeの場所を指すため)
  • Linux:.deb / .rpm / .AppImage。app/tauri.linux.conf.json を置くと、Linuxでビルドしたときだけ設定が自動で重なって、パッケージの作成が有効になる
  • 最後に、両方の成果物を集めて、下書きのリリースに置く

Linuxの .desktop には、Exec=tote --open %f と、書庫のMIMEタイプ(zip / 7z / rar / tar / cab / lzh / iso など)を書きました。メニューからファイルなしで起動したときは、--open に引数がないので、設定画面を開くようにしてあります。右クリックの登録は、Windowsのみです。

初回、Windowsだけがリンクエラーで落ちた

タグをpushして、すぐWindowsのジョブが落ちました。ログの末尾は、こうです。

error LNK2019: unresolved external symbol __imp_OpenProcessToken
error LNK2019: unresolved external symbol __imp_CryptAcquireContextW
error LNK2019: unresolved external symbol __imp_CryptGenRandom
  ...tote-core... (test "append") ... fatal error LNK1120: 4 unresolved externals

原因は、RAR展開用の unrar(C++のコード)が、Windowsのトークンと乱数のAPIを使うのに、tote-core 単体のテストexeには、それを持つ advapi32 をリンクする指定がなかったことです。画面つきの tote.exe は、Tauriやレジストリ用のクレートが advapi32 を引いてくるので、問題なくリンクできていました。手元のビルドでは、この抜けに気付けませんでした。CIで cargo test --workspace を流したことで、見つかりました。

対策は、core/build.rs に、リンクの指定を1行足すだけです。

fn main() {
    if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
        println!("cargo:rustc-link-lib=advapi32");
    }
    println!("cargo:rerun-if-changed=build.rs");
}

手元でビルドが通ることと、テストが通ることは別でした。次からは、リリースの前に、手元でも cargo test --workspace を流します。

まとめ:書庫の形式ごとに、癖を調べて、別の道具でも確かめる

  • 右クリックの複数選択は、プロセスが選択数ぶん起動する。スプール用のフォルダとリーダー選出で、1つにまとめる
  • 7zは、データのない項目が混ざると、ライブラリの復号が取りこぼす。ブロックごとに並べ替えて対処する
  • ISO 9660は、セクタ16から。Jolietを優先し、なければRock Ridgeで名前を補う。Jolietでは絵文字が使えない
  • ZIPへの追記は、安全(一時ファイル)と高速(末尾へ追記)の2通り。同名の置き換えは、安全のほうへ寄せる
  • 7zにはパスワードの検査値がない。一番小さい暗号化項目を復号して、CRC32で確かめる。書き出す前に
  • 作った書庫は、自分のコードだけでなく、7-Zipなどの別の道具でも読めることを確かめる
  • ビルドが通ることと、テストが通ることは別。WindowsのCIで、リンクの抜けが見つかった

動作確認は、手元のWindows 11が中心です。Linuxは、パッケージの作成と、窓が開くところまでで、実際の使い心地はまだ見ていません。動かない書庫があれば、Issuesで教えてください。今後は、RARのパスワード、右クリックメニューの整理(項目を1つの親メニューにまとめる)、巨大なISOからのドラッグ出しの高速化を考えています。