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

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 からも使う判定。

Returns:

  • (Boolean)


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.message}"
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.message}"
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.message)
rescue ArchiveError => e
  # 壊れている/未対応の内部構造など。UI 向けにメッセージを返す (500 にはしない)。
  edit_json(res, 422, "error" => e.message)
rescue Chocomint::Error => e
  edit_json(res, 500, "error" => e.message)
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.message}"
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 } を返す。

Raises:



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