[Ruby] rubyzipでパスワード付きZIPファイル(良くないけど)を作成する
rubyzip gemを使って、パスワード付きZIPファイルを作成するモジュールを実装するサンプルです。
まずはパスワード付きZIPファイルの使用中止を検討しよう
いきなりですが、そもそもパスワード付きZIPファイルの使用は、以下のような理由から推奨されていません。
-
Windowsの標準機能が対応しているため広く使われる「ZipCrypto」という暗号方式は、強度が弱い
-
セキュリティ対策製品が、ファイルの中身をウイルスチェックできない
-
Emotetなどの攻撃で、ウイルスを送りつける手段としても使われる[1]
また、企業間でよく使われているパスワード付きZIPファイルを利用したメールの添付ファイル送信方法、いわゆるPPAP[2]にも問題があります。
-
宛先間違い、通信傍受に無力
-
圧縮・解凍が手間
-
...
要は、セキュリティを目的にしているのに、セキュリティ的意義が薄い(=防御できていない)、あるいは一部の脆弱性を強めてしまう方法である、ということです。
是非、「パスワード付きZIP 非推奨」「パスワード付きZIP 廃止」などでググったり、参考情報のところのリンク先を確認したりしてみてください。
本記事は、上記の事情を承知した上で、それでもやむを得ない理由で歯ぎしりしながらパスワード付きZIPを生成しなければならなくなった方向けです。
GitHub上のサンプル
デプロイ方法やサンプル動作確認プログラム、APIドキュメントも用意しています。
本記事中の簡略版ではなくマジメバージョンを実際に動かして確認してみたい方は、リンク先を確認してみてください。
バージョン
以下で動作確認しています。
-
Ruby 3.1.2
-
rubyzip 2.3
-
記事執筆時点(2022年8月29日)でRubyGems.orgにある最新バージョン[3]
-
現在開発が進められているrubyzip 3.0では、一部APIの仕様が変わっている[4]ので、注意
-
まとめ
-
通常のZIPファイルを作る場合は
Zip::File.open
メソッド[6]があるが、パスワード付きZIPの作成には対応していない -
パスワード付きZIPファイルを作成するには、
Zip::OutputStream.write_buffer
メソッド[5:1]という少し低いレベルのAPIを使う
サンプル実装
パスワード付きZIPファイルをrubyzip gemで作成する場合の事情は、前項(まとめ)のとおりです。
今回は、ZipGenerator
というモジュールに、以下のようなクラスメソッドを実装します:
-
ZipGenerator.get_zip_buffer(archived_filepaths, password: nil)
:「ZIPファイルに含めたいファイル」のパスのArrayを渡すと、ZIPファイルのデータを持ったStringIO[7:1]を返す、低レベルなメソッド。パスワードを名前付き引数で指定することで、パスワード付きZIPファイルのStringIOを出力できる。主に、他の、より高レベルなメソッドが使うことを想定。
-
ZipGenerator.zip_archive(archived_filepaths, zip_path, password: nil)
:「ZIPファイルに含めたいファイル」のパスのArrayを渡して、指定したパスにZIPファイルを出力するためのメソッド。パスワードを名前付き引数で指定することで、パスワード付きZIPファイルを出力できる。
-
ZipGenerator.get_zip_tempfile(archived_filepaths, password: nil)
:「ZIPファイルに含めたいファイル」のパスのArrayを渡すと、ZIPファイルのデータを格納したTempfile[10]オブジェクトを返すメソッド。パスワードを名前付き引数で指定することで、パスワード付きZIPファイルのTempfileを出力できる。
本記事では、メソッドの細かいエラー実装などの部分は省いて、「実現したい機能を自分で実装する場合に参考にできる」程度のコードのみ残して説明しています。
一応本記事のコードでも(下準備込みで)動きますが、マジメなコードはGitHubリポジトリを参照してください。
下準備と実行方法
下準備(あまり本質的でないので畳みます)
以下のGemfileを書いて、
# frozen_string_literal: true
source "https://rubygems.org"
gem "rubyzip", "~> 2.3"
bundle install
します。
$ ls
Gemfile
$ bundle install
# 出力は省略
以降のコードは、lib/zip_generator.rbファイルに書いていくものとします。
$ mkdir lib
$ touch lib/zip_generator.rb
$ tree -F
.
├── Gemfile
├── Gemfile.lock
└── lib/
└── zip_generator.rb # ここにコードを書く
1 directory, 3 files
実行の際は、lib/zip_generator.rbをrequire_relative
したコードでメソッドを呼ぶプログラムを書いて、そのプログラムについて
$ bundle exec ruby <プログラム>.rb
します。
ZipGenerator.get_zip_buffer
「ZIPファイルに含めたいファイル」のパスのArrayを渡すと、ZIPファイルのデータを持ったStringIO[7:2]を返す、低レベルなメソッドです。パスワードを名前付き引数で指定することで、パスワード付きZIPファイルのStringIOを出力できます。
これ単体を使うというよりは、他の、より高レベルなメソッドが使うことを想定したメソッドです。
require "zip"
module ZipGenerator
FileEntry = Struct.new(:entry_name, :filepath, keyword_init: true)
def self.get_zip_buffer(archived_filepaths, password: nil)
# ファイルパスのうち、ファイル名(basename)部分をZIPファイル中のエントリー、
# つまり、ZIPファイルを解凍した際のファイル名にする。(という実装)
file_entries = archived_filepaths
.map { |path| FileEntry.new(entry_name: File.basename(path), filepath: path) }
enc = password ? Zip::TraditionalEncrypter.new(password) : nil
Zip::OutputStream.write_buffer(StringIO.new(""), enc) do |output|
file_entries.each do |file_entry|
output.put_next_entry(file_entry.entry_name)
file_data = File.read(file_entry.filepath)
output.write(file_data)
end
end #=> メソッドの戻り値は、ZIPファイルのデータを持ったStringIOオブジェクト
end
end
ここでのポイントは、次のとおりです:
-
Zip::OutputStream.write_buffer
メソッド[5:2]を使う-
第1位置引数で渡したStringIO[7:3]オブジェクトにZIPファイルのデータを書き込む
-
その書き込んだStringIOオブジェクトが戻り値になる
- (したがって、このメソッドを戻り値にしている
ZipGenerator.get_zip_buffer
の戻り値もStringIOオブジェクト)
- (したがって、このメソッドを戻り値にしている
-
第2位置引数に
Zip::TraditionalEncrypter
[8:1]インスタンスを渡すと、パスワード付きZIPファイルのStringIOオブジェクトが得られる
Zip::OutputStream.write_buffer(StringIO.new(""), enc) do |output| # ... end
-
-
Zip::OutputStream.write_buffer
のブロック中では各「ZIPファイルに含めようとしているファイル」について、-
Zip::OutputStream#put_next_entry
メソッド[11]で、ZIPエントリー名...つまりはZIPファイルを解凍した際のファイル名を登録し、 -
Zip::OutputStream#write
メソッド[12]で、実際のZIPデータ(String)を書き込む
Zip::OutputStream.write_buffer(StringIO.new(""), enc) do |output| file_entries.each do |file_entry| output.put_next_entry(file_entry.entry_name) file_data = File.read(file_entry.filepath) output.write(file_data) end end
-
-
パスワードの元となる
Zip::TraditionalEncrypter
インスタンスは、Zip::TraditionalEncrypter.new(<パスワードにしたいString>)
[13]で得られるenc = password ? Zip::TraditionalEncrypter.new(password) : nil
ZipGenerator.zip_archive
本命のメソッドです。
「ZIPファイルに含めたいファイル」のパスのArrayを渡して、指定したパスにZIPファイルを出力させます。パスワードを名前付き引数で指定することで、パスワード付きZIPファイルを出力させられます。
require "zip"
require "pathname"
module ZipGenerator
def self.zip_archive(archived_filepaths, zip_path, password: nil)
buffer = get_zip_buffer(archived_filepaths, password: password)
File.open(zip_path, "wb") { |f| f.write(buffer.string) }
end
end
-
StringIO#string
[9:1]で、パスワード付きZIPファイルデータのStringを得る -
それをファイルに書き込む
ZipGenerator.get_zip_tempfile
「ZIPファイルに含めたいファイル」のパスのArrayを渡すと、ZIPファイルのデータを格納したTempfile[10:1]オブジェクトを返すメソッドです。パスワードを名前付き引数で指定することで、パスワード付きZIPファイルのTempfileを出力できます。
主に、以下のような用途を想定しています:
-
ZIPファイルを動的に作成して、Webページ上のその場でダウンロードさせたり、メールの添付ファイルにしたりする(そしてその後はZIPファイルが必要なくなる)ような場面
-
ZIPファイルのStringIOを生成してから使うまでに少し時間がかかったり、大量にZIPファイルを作成したりなど、データをメモリ上に持っておきたくない場面
require "zip"
require "tempfile"
module ZipGenerator
def self.get_zip_tempfile(archived_filepaths, password: nil)
buffer = get_zip_buffer(archived_filepaths, password: password)
Tempfile.open do |fp|
fp.write(buffer.string)
fp
end #=> メソッドの戻り値は、Tempfileオブジェクト
end
end
ここでのポイントは、以下のとおりです:
-
メソッドの戻り値がTempfileオブジェクトになるように、
Tempfile.open
メソッドのブロックの中でfp.write
(Tempfile#write
)ではなく、fp
(Tempfileオブジェクト)を最後にしているTempfile.open do |fp| fp.write(buffer.string) fp # <= ブロックの戻り値 end
-
(Tempfileの使い方の注意)Tempfileを使う際は、
Tempfile#close
などに気をつける[10:2]
参考情報
パスワード付きZIPファイル・PPAP
rubyzip
-
Updating to version 3.0 < Important notes < README.md(rubyzip GitHub) ↩︎
-
Zip::OutputStream.write_buffer(rubyzip 2.3 rubydoc.info) ↩︎ ↩︎ ↩︎
-
Zip::OutputStream#put_next_entry(rubyzip 2.3 rubydoc.info) ↩︎
-
Zip::IOExtras::AbstractOutputStream#wite(rubyzip 2.3 rubydoc.info) ↩︎
-
Zip::TraditionalEncryption#initialize(rubyzip 2.3 rubydoc.info) ↩︎
Discussion