Module: Chocomint::EditArchiveHandlers
- Included in:
- Server
- Defined in:
- lib/chocomint/edit_archive_handlers.rb
Overview
/edit AI エディタのアーカイブプレビュー用ハンドラ (Server に include して使う)。
提供機能:
GET /edit/archive?path=foo.zip アーカイブの第一階層エントリ + メタデータ (JSON)
対応形式: .zip / .tar / .tar.gz (.tgz) / .gz / .tar.bz2 (.tbz2) / .bz2 いずれも展開はメモリ上で行い、ディスクには書き出さない。tar/zip はエントリを ストリーム走査し、第一階層 (トップレベル) だけに畳んで返す。
対応形式は拡張子でしか判定しない (中身の magic は見ない)。ファイルアクセスは すべて @path_guard 経由で workspace 内に制限する。
Defined Under Namespace
Classes: ArchiveError
Constant Summary collapse
- MAX_ARCHIVE_ENTRIES =
一覧をブラウザで扱える範囲に抑えるためのエントリ数上限 (走査自体の安全弁)。 tar は全走査するため、巨大アーカイブでも到達したら打ち切って capped を立てる。
20_000- MAX_BZIP2_INPUT_BYTES =
bzip2 展開に許す入力サイズ上限 (純Ruby 実装は重いので極端に巨大な .bz2 は弾く)。
64 * 1024 * 1024
Instance Method Summary collapse
-
#accumulate_top_level(rel, is_dir, size, dirs, files) ⇒ Object
正規化済みエントリを「第一階層」に畳んで dirs / files に足し込む。 "src/main.rb" → トップレベル dir "src" を登録し配下件数 +1 "README.md" → トップレベル直下 file "README.md" "src/" → dir "src" を登録するだけ (ディレクトリ自身なので件数は増やさない) "emptydir/" → dir "emptydir" を件数 0 で登録 (空ディレクトリ).
-
#archive?(abs) ⇒ Boolean
フロントがメディア/アーカイブを区別できるよう、handle_edit_file からも使う判定。.
-
#archive_kind(abs) ⇒ Object
拡張子からアーカイブ形式を判定して種別文字列を返す (非対応は nil)。 種別: "zip" / "tar" / "tar.gz" / "tar.bz2" / "gz" / "bz2" 二重拡張子 (.tar.gz 等) を単一拡張子 (.gz) より優先する。.
-
#build_result(dirs, files, total, total_size, capped) ⇒ Object
dirs / files から JSON 用の結果ハッシュを組み立てる。 entries はディレクトリ→ファイルの順、各名前順 (一覧の並びに揃える)。.
-
#bunzip2(abs) ⇒ Object
bzip2 を純Ruby (rbzip2) で展開する。ネイティブ libbz2 は不要。.
-
#find_eocd(raw) ⇒ Object
EOCD (End Of Central Directory, シグネチャ PK\x05\x06) を末尾から探す。 ZIP コメントは最大 65535 バイトなので、末尾 (22 + 65535) だけ後方走査すれば十分。.
-
#gunzip(bytes) ⇒ Object
---- 展開ヘルパ ---------------------------------------------------------.
-
#gzip_uncompressed_size(abs) ⇒ Object
gzip 末尾 4 バイトの ISIZE (展開後サイズ mod 2^32)。4GB 未満の実用ケース向け。.
- #handle_edit_archive(req, res) ⇒ Object
-
#inspect_archive(abs, kind) ⇒ Object
---- 検査 (種別ごとにディスパッチ) --------------------------------------.
-
#inspect_single_compressed(abs, algorithm, strip_ext:) ⇒ Object
---- 単体圧縮ファイル (.gz / .bz2、tar でない) --------------------------.
-
#inspect_tar(tar_bytes) ⇒ Object
---- tar の走査 ---------------------------------------------------------.
-
#inspect_zip(abs) ⇒ Object
---- zip の走査 (中央ディレクトリを自前パース) --------------------------.
-
#normalize_entry_name(name) ⇒ Object
エントリ名を正規化する ("./" 前置や重複スラッシュを除く。先頭は相対に統一)。.
-
#zip_central_directory(raw) ⇒ Object
ZIP の中央ディレクトリを読み、各エントリの { name, size, dir } を返す。.
-
#zip_decode_name(name) ⇒ Object
zip のエントリ名を文字列に整える。UTF-8 として妥当ならそれ、駄目なら置換して壊さない。.
Instance Method Details
#accumulate_top_level(rel, is_dir, size, dirs, files) ⇒ Object
正規化済みエントリを「第一階層」に畳んで dirs / files に足し込む。 "src/main.rb" → トップレベル dir "src" を登録し配下件数 +1 "README.md" → トップレベル直下 file "README.md" "src/" → dir "src" を登録するだけ (ディレクトリ自身なので件数は増やさない) "emptydir/" → dir "emptydir" を件数 0 で登録 (空ディレクトリ)
281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 |
# File 'lib/chocomint/edit_archive_handlers.rb', line 281 def accumulate_top_level(rel, is_dir, size, dirs, files) # 末尾スラッシュはディレクトリ自身のエントリ。区切りとしての "/" と区別する。 trailing_dir = rel.end_with?("/") trimmed = trailing_dir ? rel.chomp("/") : rel slash = trimmed.index("/") if slash.nil? # トップレベル直下のエントリ (ファイル or ディレクトリ自身)。 if is_dir || trailing_dir dirs[trimmed] ||= 0 # 件数は増やさない (自身の登録だけ)。 else files << { name: trimmed, size: size } end else # 配下エントリ → トップレベルのディレクトリに畳んで件数を +1。 top = trimmed[0...slash] dirs[top] ||= 0 dirs[top] += 1 end end |
#archive?(abs) ⇒ Boolean
フロントがメディア/アーカイブを区別できるよう、handle_edit_file からも使う判定。
74 75 76 |
# File 'lib/chocomint/edit_archive_handlers.rb', line 74 def archive?(abs) !archive_kind(abs).nil? end |
#archive_kind(abs) ⇒ Object
拡張子からアーカイブ形式を判定して種別文字列を返す (非対応は nil)。 種別: "zip" / "tar" / "tar.gz" / "tar.bz2" / "gz" / "bz2" 二重拡張子 (.tar.gz 等) を単一拡張子 (.gz) より優先する。
59 60 61 62 63 64 65 66 67 68 69 70 71 |
# File 'lib/chocomint/edit_archive_handlers.rb', line 59 def archive_kind(abs) lower = File.basename(abs).downcase return "tar.gz" if lower.end_with?(".tar.gz") return "tar.gz" if lower.end_with?(".tgz") return "tar.bz2" if lower.end_with?(".tar.bz2") return "tar.bz2" if lower.end_with?(".tbz2") || lower.end_with?(".tbz") return "zip" if lower.end_with?(".zip") return "tar" if lower.end_with?(".tar") return "gz" if lower.end_with?(".gz") return "bz2" if lower.end_with?(".bz2") nil end |
#build_result(dirs, files, total, total_size, capped) ⇒ Object
dirs / files から JSON 用の結果ハッシュを組み立てる。 entries はディレクトリ→ファイルの順、各名前順 (一覧の並びに揃える)。
304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 |
# File 'lib/chocomint/edit_archive_handlers.rb', line 304 def build_result(dirs, files, total, total_size, capped) dir_entries = dirs.keys.sort.map do |name| { "name" => name, "dir" => true, "child_count" => dirs[name] } end # 同名ファイルの重複は稀だが、名前順で安定させる。 file_entries = files.uniq { |f| f[:name] } .sort_by { |f| f[:name] } .map { |f| { "name" => f[:name], "dir" => false, "size" => f[:size] } } { "archive" => "tar_or_zip", "single" => false, "entries" => dir_entries + file_entries, "total_entries" => total, "total_size" => total_size, "capped" => capped } end |
#bunzip2(abs) ⇒ Object
bzip2 を純Ruby (rbzip2) で展開する。ネイティブ libbz2 は不要。
250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 |
# File 'lib/chocomint/edit_archive_handlers.rb', line 250 def bunzip2(abs) require "rbzip2" raw = File.binread(abs) if raw.bytesize > MAX_BZIP2_INPUT_BYTES raise ArchiveError, "bzip2 ファイルが大きすぎます (上限 #{MAX_BZIP2_INPUT_BYTES / (1024 * 1024)}MB)" end RBzip2.default_adapter::Decompressor.new(StringIO.new(raw)).read rescue LoadError raise ArchiveError, "bzip2 の展開には rbzip2 gem が必要です" rescue ArchiveError raise rescue StandardError => e raise ArchiveError, "bzip2 を展開できませんでした: #{e.}" end |
#find_eocd(raw) ⇒ Object
EOCD (End Of Central Directory, シグネチャ PK\x05\x06) を末尾から探す。 ZIP コメントは最大 65535 バイトなので、末尾 (22 + 65535) だけ後方走査すれば十分。
189 190 191 192 193 194 195 196 197 198 199 |
# File 'lib/chocomint/edit_archive_handlers.rb', line 189 def find_eocd(raw) sig = "PK\x05\x06".b min = [raw.bytesize - (22 + 0xFFFF), 0].max i = raw.bytesize - 22 while i >= min return i if raw[i, 4] == sig i -= 1 end nil end |
#gunzip(bytes) ⇒ Object
---- 展開ヘルパ ---------------------------------------------------------
235 236 237 238 239 |
# File 'lib/chocomint/edit_archive_handlers.rb', line 235 def gunzip(bytes) Zlib::GzipReader.new(StringIO.new(bytes)).read rescue Zlib::GzipFile::Error => e raise ArchiveError, "gzip を展開できませんでした: #{e.}" end |
#gzip_uncompressed_size(abs) ⇒ Object
gzip 末尾 4 バイトの ISIZE (展開後サイズ mod 2^32)。4GB 未満の実用ケース向け。
242 243 244 245 246 247 |
# File 'lib/chocomint/edit_archive_handlers.rb', line 242 def gzip_uncompressed_size(abs) tail = File.open(abs, "rb") { |f| f.seek(-4, IO::SEEK_END); f.read(4) } tail ? tail.unpack1("V") : nil rescue SystemCallError nil end |
#handle_edit_archive(req, res) ⇒ Object
28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
# File 'lib/chocomint/edit_archive_handlers.rb', line 28 def handle_edit_archive(req, res) return edit_method_guard(res) unless req.request_method == "GET" path = req.query["path"].to_s return edit_json(res, 400, "error" => "path required") if path.empty? abs = @path_guard.resolve(path) return edit_json(res, 404, "error" => "no such file") unless File.file?(abs) kind = archive_kind(abs) return edit_json(res, 415, "error" => "unsupported archive format") unless kind info = inspect_archive(abs, kind) edit_json(res, 200, { "path" => path }.merge(info)) rescue Chocomint::PathAccessError => e edit_json(res, 403, "error" => e.) rescue ArchiveError => e # 壊れている/未対応の内部構造など。UI 向けにメッセージを返す (500 にはしない)。 edit_json(res, 422, "error" => e.) rescue Chocomint::Error => e edit_json(res, 500, "error" => e.) end |
#inspect_archive(abs, kind) ⇒ Object
---- 検査 (種別ごとにディスパッチ) --------------------------------------
80 81 82 83 84 85 86 87 88 89 90 91 |
# File 'lib/chocomint/edit_archive_handlers.rb', line 80 def inspect_archive(abs, kind) case kind when "zip" then inspect_zip(abs) when "tar" then inspect_tar(File.binread(abs)) when "tar.gz" then inspect_tar(gunzip(File.binread(abs))) when "tar.bz2" then inspect_tar(bunzip2(abs)) when "gz" then inspect_single_compressed(abs, "gzip", strip_ext: ".gz") when "bz2" then inspect_single_compressed(abs, "bzip2", strip_ext: ".bz2") else raise ArchiveError, "unsupported archive format" end end |
#inspect_single_compressed(abs, algorithm, strip_ext:) ⇒ Object
---- 単体圧縮ファイル (.gz / .bz2、tar でない) --------------------------
tar を含まない単なる圧縮ファイルは、展開後の 1 ファイルとして扱う。 中身の全展開は避け、メタデータ (圧縮/展開サイズ・推定名) だけを返す。
211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 |
# File 'lib/chocomint/edit_archive_handlers.rb', line 211 def inspect_single_compressed(abs, algorithm, strip_ext:) compressed_size = File.size(abs) inner_name = File.basename(abs) inner_name = inner_name[0...-strip_ext.length] if inner_name.downcase.end_with?(strip_ext) uncompressed_size = case algorithm when "gzip" then gzip_uncompressed_size(abs) when "bzip2" then bunzip2(abs).bytesize end { "archive" => algorithm == "gzip" ? "gz" : "bz2", "single" => true, "entries" => [{ "name" => inner_name, "dir" => false, "size" => uncompressed_size }], "total_entries" => 1, "total_size" => uncompressed_size, "compressed_size" => compressed_size, "capped" => false } end |
#inspect_tar(tar_bytes) ⇒ Object
---- tar の走査 ---------------------------------------------------------
展開済み tar バイト列を走査し、第一階層に畳んだ entries とメタデータを返す。
96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 |
# File 'lib/chocomint/edit_archive_handlers.rb', line 96 def inspect_tar(tar_bytes) dirs = {} # トップレベルのディレクトリ名 => 配下エントリ数 files = [] # トップレベルの直下ファイル { name, size } total = 0 total_size = 0 capped = false Gem::Package::TarReader.new(StringIO.new(tar_bytes)) do |reader| reader.each do |entry| name = entry.full_name.to_s next if name.empty? || name == "./" total += 1 if total > MAX_ARCHIVE_ENTRIES capped = true break end rel = normalize_entry_name(name) next if rel.empty? size = entry.header.size.to_i total_size += size unless entry.directory? accumulate_top_level(rel, entry.directory?, size, dirs, files) end end build_result(dirs, files, total, total_size, capped) rescue Gem::Package::TarInvalidError, Zlib::GzipFile::Error => e raise ArchiveError, "tar を読み取れませんでした: #{e.}" end |
#inspect_zip(abs) ⇒ Object
---- zip の走査 (中央ディレクトリを自前パース) --------------------------
標準ライブラリだけで済ませるため rubyzip には依存せず、ZIP の End Of Central Directory (EOCD) → Central Directory を辿ってエントリ名とサイズを読む。 実データ (ローカルファイルヘッダ) は読まないので大きな zip でも軽い。
133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 |
# File 'lib/chocomint/edit_archive_handlers.rb', line 133 def inspect_zip(abs) raw = File.binread(abs) cd = zip_central_directory(raw) dirs = {} files = [] total = 0 total_size = 0 capped = false cd.each do |ent| total += 1 if total > MAX_ARCHIVE_ENTRIES capped = true break end rel = normalize_entry_name(ent[:name]) next if rel.empty? total_size += ent[:size] unless ent[:dir] accumulate_top_level(rel, ent[:dir], ent[:size], dirs, files) end build_result(dirs, files, total, total_size, capped) end |
#normalize_entry_name(name) ⇒ Object
エントリ名を正規化する ("./" 前置や重複スラッシュを除く。先頭は相対に統一)。
269 270 271 272 273 274 |
# File 'lib/chocomint/edit_archive_handlers.rb', line 269 def normalize_entry_name(name) s = name.tr("\\", "/") s = s.sub(%r{\A\./}, "") s = s.sub(%r{\A/+}, "") s.gsub(%r{/+}, "/") end |
#zip_central_directory(raw) ⇒ Object
ZIP の中央ディレクトリを読み、各エントリの { name, size, dir } を返す。
160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 |
# File 'lib/chocomint/edit_archive_handlers.rb', line 160 def zip_central_directory(raw) eocd = find_eocd(raw) raise ArchiveError, "zip の終端レコードが見つかりません" unless eocd count = raw[eocd + 10, 2].unpack1("v") offset = raw[eocd + 16, 4].unpack1("V") entries = [] pos = offset count.times do break if pos + 46 > raw.bytesize sig = raw[pos, 4] break unless sig == "PK\x01\x02".b # Central Directory File Header usize = raw[pos + 24, 4].unpack1("V") # uncompressed size name_len = raw[pos + 28, 2].unpack1("v") extra_len = raw[pos + 30, 2].unpack1("v") comment_len = raw[pos + 32, 2].unpack1("v") name = raw[pos + 46, name_len].to_s name = zip_decode_name(name) entries << { name: name, size: usize, dir: name.end_with?("/") } pos += 46 + name_len + extra_len + comment_len end entries end |
#zip_decode_name(name) ⇒ Object
zip のエントリ名を文字列に整える。UTF-8 として妥当ならそれ、駄目なら置換して壊さない。
202 203 204 205 |
# File 'lib/chocomint/edit_archive_handlers.rb', line 202 def zip_decode_name(name) s = name.dup.force_encoding("UTF-8") s.valid_encoding? ? s : s.scrub("?") end |