chocomint 1.0.3 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 43fbe75bcf172909a002314dbbd673417c25e2ab667f3e401eacb026988743e4
4
- data.tar.gz: 68e6a6bf6ecd91251f5311a6c03742353e17ac4689c51b64cd35fe1d602e80f2
3
+ metadata.gz: cf5b5e18513023f06550376aa901ada01d10efd621f53fc01c4f1671b94e3f1d
4
+ data.tar.gz: 8fa2badc83ba90c08fd8bcbc5a2a2632a4342993befafb3721457e10c39ee168
5
5
  SHA512:
6
- metadata.gz: ec1a8f844e984a0387bd6d2a22d66af477b99c6d99320904cc870a2bc115b7b9a3dd21135cab14a966920634f957f9b65657b1abe376d05c28b6e28a113408da
7
- data.tar.gz: 9b8726ba1954c4eded4eaf811ed58c6c6db2ecb239286cb85b98be6263aefe11cdbea98e017a90b8a843495834ae385690b7cc0d3e0ccd00299c2f56b4e2846d
6
+ metadata.gz: 6be492f0a271ab2d6978da7c22c6740522983f933f167b96fcdc7800482b341aadc48156d4cb062612abadda090dcc34220a4f0e21745e2b835c51e0a658f8fb
7
+ data.tar.gz: b5408738a6734a31b1d311fa455c4f2374f176c375cd6fddb7136011353c8fe7dddd54461851baf2c7ed625c6091f7a3d36e90e30ea71a6d918eda31a66ebd0d
data/README.md CHANGED
@@ -27,7 +27,14 @@ bundle install
27
27
 
28
28
  ## 設定
29
29
 
30
- `config/config.yml` を編集する(DESIGN §11)。実機確認済みの構成:
30
+ 設定ファイルは XDG Base Directory 準拠のユーザーパスに置かれる:
31
+
32
+ - `~/.config/chocomint/config.yaml`(`$XDG_CONFIG_HOME` があればそちらの `chocomint/config.yaml`)
33
+
34
+ 初回起動時、このファイルが無ければ gem 同梱の既定 `config/config.yml` が上記へ
35
+ コピーされる。以後はユーザーパス側を編集する(`/edit` のモデル切り替えもここへ保存される)。
36
+
37
+ 実機確認済みの構成:
31
38
 
32
39
  ```yaml
33
40
  llm:
@@ -191,11 +198,37 @@ ruby bin/chocomint-server
191
198
  |----------------|------|
192
199
  | `GET /edit` | エディタ画面 |
193
200
  | `GET /edit/files` | `workspace/` のファイル一覧 (JSON) |
194
- | `GET /edit/file?path=` | ファイル内容 (JSON) |
201
+ | `GET /edit/file?path=` | ファイル内容 (JSON)。画像/PDF/動画/音声は `media` 種別だけ返す |
202
+ | `GET /edit/raw?path=` | 生ファイル配信(メディアプレビュー用。`Range` 対応で動画のシークも可) |
203
+ | `GET /edit/archive?path=` | アーカイブの第一階層エントリ + メタデータ(zip / tar / tar.gz / bz2 等) |
195
204
  | `POST /edit/save` | 人手編集の保存(`PathGuard` で `workspace/` 内に限定) |
196
205
  | `POST /edit/chat` | 自然言語指示で AI 編集(`Planner#run`。`expectations` に指示を渡すため意味的検証が効く) |
197
206
  | `GET /edit/assets/*` | Monaco / xterm.js のローカル同梱アセット |
198
207
 
208
+ ### メディアプレビュー
209
+
210
+ テキストファイルは Monaco で編集し、以下のメディアはタブ内でプレビューする
211
+ (`workspace/` 配下のみ・`PathGuard` で保護):
212
+
213
+ - **画像**: PNG / JPEG / GIF / WebP / BMP / ICO / AVIF / TIFF / SVG(ベクター)
214
+ - **文書**: PDF(ブラウザ内蔵ビューアで表示)
215
+ - **動画**: MP4 / WebM / MKV / MOV / AVI / OGV(`<video>`。ブラウザが対応する形式のみ再生可)
216
+ - **音声**: MP3 / WAV / OGG / M4A / AAC / FLAC / Opus(`<audio>`)
217
+
218
+ 動画・音声は `Range` リクエストに対応し、シークやストリーミング再生ができる。
219
+ ブラウザがコーデック非対応の形式(MKV の一部など)は再生できずメッセージを表示する。
220
+
221
+ ### アーカイブプレビュー
222
+
223
+ zip / tar 系アーカイブをクリックすると、**第一階層(トップレベル)のエントリと
224
+ メタデータ**(エントリ数・展開後サイズ・圧縮サイズ)をタブ内に表示する。展開は
225
+ サーバー側でメモリ上に行い、ディスクには書き出さない。
226
+
227
+ - **対応**: `.zip` / `.tar` / `.tar.gz`(`.tgz`)/ `.gz` / `.tar.bz2`(`.tbz2`)/ `.bz2`
228
+ - 配下のエントリは親ディレクトリに畳み、その配下件数を添えて表示する。
229
+ - `.zip` は中央ディレクトリを自前パースするため rubyzip 非依存。`.bz2` は純Ruby
230
+ 実装の `rbzip2` で展開する(ネイティブ `libbz2` 不要)。
231
+
199
232
  ### 追加依存
200
233
 
201
234
  - `ffi` — Windows の擬似コンソール (ConPTY) を FFI で叩く(プリビルド gem、DevKit 不要)。
@@ -41,5 +41,6 @@ AI エディタ (`/edit`) をオフラインで動作させるため、以下を
41
41
  | webrick | Ruby / BSD-2-Clause | https://github.com/ruby/webrick |
42
42
  | ffi | BSD-3-Clause | https://github.com/ffi/ffi |
43
43
  | websocket-driver | Apache-2.0 | https://github.com/faye/websocket-driver-ruby |
44
+ | rbzip2 | BSD-3-Clause | https://github.com/koraktor/rbzip2 |
44
45
 
45
46
  上記はいずれも再配布・商用利用を許容する寛容なライセンスである。
data/bin/chocomint-server CHANGED
@@ -39,5 +39,6 @@ Chocomint::Server.new(
39
39
  max_file_bytes: config.max_file_bytes,
40
40
  ws_port: ws_port,
41
41
  ws_token: ws_token,
42
- chat_client: chat_client
42
+ chat_client: chat_client,
43
+ config: config
43
44
  ).start
data/config/config.yml CHANGED
@@ -1,37 +1,39 @@
1
- # chocomint 設定 (DESIGN.md §11)
2
- llm:
3
- base_url: http://localhost:11434/v1
4
- model: gemma4:e4b
5
- api_key_env: OLLAMA_API_KEY # 参照する環境変数名 (無ければ空)
6
- max_tokens: 4096 # thinking モデルは思考だけで使い切ることがあるため余裕を持たせる
7
-
8
- verifier:
9
- enabled: true
10
- # モデルは指定しない。VRAM 節約のため llm.model を共有する (factory.rb 参照)。
11
- max_tokens: 1024 # thinking モデルは思考だけで使い切ることがあるため余裕を持たせる
12
-
13
- execution:
14
- max_retry: 10
15
- max_steps: 20 # マルチステップ要求で実行を許すステップ数の上限 (暴走防止)
16
- timeout_sec: 30
17
- max_output_bytes: 1048576 # stdout/stderr サイズ制限 (1 MiB)
18
-
19
- security:
20
- allowed_dirs:
21
- - . # サーバーを起動した場所 (Dir.pwd) を作業ディレクトリとして許可
22
- max_file_bytes: 10485760 # ファイルサイズ制限 (10 MiB)
23
- allowed_commands: # run_command で実行を許可するコマンド (whitelist)
24
- - git
25
- - ruby
26
- - node
27
- - uv # Python 環境構築 (uv init / uv add / venv)
28
- - ping # ネットワーク疎通確認
29
- allow_all_commands: true # true にすると run_command allowed_commands を無視し任意コマンドを実行する
30
- # (⚠️ whitelist を無効化する。信頼できるローカル環境でのみ true にすること)
31
-
32
- logging:
33
- sqlite_path: ./logs/execution.db
34
-
35
- server:
36
- host: 127.0.0.1
37
- port: 9210
1
+ # chocomint 設定 (DESIGN.md §11)
2
+ llm:
3
+ base_url: http://localhost:11434/v1
4
+ model: gemma4:e4b
5
+ api_key_env: OLLAMA_API_KEY # 参照する環境変数名 (無ければ空)
6
+ max_tokens: 4096 # thinking モデルは思考だけで使い切ることがあるため余裕を持たせる
7
+ timeout_sec: 300 # LLM 応答待ちのタイムアウト (秒)。ローカル LLM は遅いため長めに
8
+ # (ツール実行の execution.timeout_sec とは別物)
9
+
10
+ verifier:
11
+ enabled: true
12
+ # モデルは指定しない。VRAM 節約のため llm.model を共有する (factory.rb 参照)。
13
+ max_tokens: 1024 # thinking モデルは思考だけで使い切ることがあるため余裕を持たせる
14
+
15
+ execution:
16
+ max_retry: 10
17
+ max_steps: 20 # マルチステップ要求で実行を許すステップ数の上限 (暴走防止)
18
+ timeout_sec: 30
19
+ max_output_bytes: 1048576 # stdout/stderr サイズ制限 (1 MiB)
20
+
21
+ security:
22
+ allowed_dirs:
23
+ - . # サーバーを起動した場所 (Dir.pwd) を作業ディレクトリとして許可
24
+ max_file_bytes: 10485760 # ファイルサイズ制限 (10 MiB)
25
+ allowed_commands: # run_command で実行を許可するコマンド (whitelist)
26
+ - git
27
+ - ruby
28
+ - node
29
+ - uv # Python 環境構築 (uv init / uv add / venv)
30
+ - ping # ネットワーク疎通確認
31
+ allow_all_commands: true # true にすると run_command が allowed_commands を無視し任意コマンドを実行する
32
+ # (⚠️ whitelist を無効化する。信頼できるローカル環境でのみ true にすること)
33
+
34
+ logging:
35
+ sqlite_path: ./logs/execution.db
36
+
37
+ server:
38
+ host: 127.0.0.1
39
+ port: 9210
@@ -1,21 +1,62 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "yaml"
4
+ require "fileutils"
5
+ require "uri"
4
6
 
5
7
  module Chocomint
6
- # config/config.yml を読み込み、環境変数オーバーライドを適用する。
8
+ # 設定ファイルを読み込み、環境変数オーバーライドを適用する。
9
+ #
10
+ # 設定は XDG Base Directory 準拠のユーザーパス
11
+ # (~/.config/chocomint/config.yaml。$XDG_CONFIG_HOME があればそちらを優先) に置く。
12
+ # ユーザー設定がまだ無い場合は、gem 同梱の既定 config をそこへコピーして雛形にする
13
+ # (初回起動時)。読み込み・保存 (モデル切り替えの persist) はいずれもこのユーザーパスを使う。
7
14
  class Config
8
- DEFAULT_PATH = File.expand_path("../../config/config.yml", __dir__)
15
+ # gem 同梱の既定 config (初回のコピー元。読み取り専用として扱う)
16
+ BUNDLED_PATH = File.expand_path("../../config/config.yml", __dir__)
9
17
 
10
18
  attr_reader :data
11
19
 
12
- def self.load(path = DEFAULT_PATH)
20
+ # XDG 準拠のユーザー設定ファイルパス (~/.config/chocomint/config.yaml)
21
+ def self.user_config_path
22
+ base = ENV["XDG_CONFIG_HOME"]
23
+ base = File.join(Dir.home, ".config") if base.nil? || base.empty?
24
+ File.join(base, "chocomint", "config.yaml")
25
+ end
26
+
27
+ # 設定を読み込む。明示的な path が無ければ user_config_path を使い、そこに
28
+ # まだファイルが無ければ gem 同梱の既定 config をコピーして作成する。
29
+ # コピーできない (書き込み不可など) 場合は同梱 config をそのまま読み、
30
+ # 保存先だけユーザーパスに向ける (persist 時に改めて作成を試みる)。
31
+ def self.load(path = nil)
32
+ path ||= ensure_user_config
13
33
  raw = File.exist?(path) ? YAML.safe_load(File.read(path)) : {}
14
- new(raw || {})
34
+ new(raw || {}, path)
35
+ end
36
+
37
+ # user_config_path を用意する。無ければ同梱 config をコピーして作る。
38
+ # 戻り値: 実際に読み込むべきパス (コピー成功時はユーザーパス、失敗時は同梱パス)。
39
+ def self.ensure_user_config
40
+ dest = user_config_path
41
+ return dest if File.exist?(dest)
42
+
43
+ begin
44
+ FileUtils.mkdir_p(File.dirname(dest))
45
+ FileUtils.cp(BUNDLED_PATH, dest) if File.exist?(BUNDLED_PATH)
46
+ File.exist?(dest) ? dest : BUNDLED_PATH
47
+ rescue SystemCallError
48
+ # 書き込み不可などでコピーできないときは同梱 config を読む。
49
+ BUNDLED_PATH
50
+ end
15
51
  end
16
52
 
17
- def initialize(data)
53
+ # 読み込み元パス。llm モデル設定を書き戻す (persist_llm!) のにも使う。
54
+ # path は第 2 引数 (キーワードにすると Config.new("k" => v) が keyword 誤認で壊れるため)。
55
+ attr_reader :path
56
+
57
+ def initialize(data, path = self.class.user_config_path)
18
58
  @data = deep_stringify(data)
59
+ @path = path
19
60
  apply_env_overrides!
20
61
  end
21
62
 
@@ -37,6 +78,10 @@ module Chocomint
37
78
  # マルチステップ実行のステップ数上限。未設定なら max_retry を流用する (暴走防止)。
38
79
  def max_steps = execution.fetch("max_steps", max_retry)
39
80
  def timeout_sec = execution.fetch("timeout_sec", 30)
81
+ # LLM への HTTP リクエストのタイムアウト (秒)。ツール実行の timeout_sec とは分離する:
82
+ # ローカル LLM は 1 応答に数分かかることがあり、暴走コマンド用の短い timeout_sec を
83
+ # 流用すると Net::ReadTimeout で失敗するため。未設定なら余裕を持たせた既定にする。
84
+ def llm_timeout_sec = llm.fetch("timeout_sec", 300)
40
85
  def max_output_bytes = execution.fetch("max_output_bytes", 1_048_576)
41
86
 
42
87
  def allowed_dirs = security.fetch("allowed_dirs", ["."])
@@ -49,8 +94,95 @@ module Chocomint
49
94
 
50
95
  def verifier_enabled? = verifier.fetch("enabled", false)
51
96
 
97
+ # ---- LLM プロバイダ / モデル設定 (AI エディタのモデル切り替え用) ----------
98
+ #
99
+ # provider は base_url から推定する: openrouter.ai を含めば "openrouter"、
100
+ # それ以外 (localhost:11434 等) は "ollama" とみなす。UI での表示・選択に使う。
101
+ OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
102
+ OLLAMA_BASE_URL = "http://localhost:11434/v1"
103
+
104
+ def llm_base_url = llm.fetch("base_url", OLLAMA_BASE_URL)
105
+ def llm_model = llm.fetch("model", nil)
106
+
107
+ def llm_provider
108
+ llm_base_url.to_s.include?("openrouter.ai") ? "openrouter" : "ollama"
109
+ end
110
+
111
+ # Ollama の API ベース URL (末尾に /v1 を含まない)。インストール済みモデル一覧
112
+ # (/api/tags) の取得に使う。provider が OpenRouter でも Ollama タブのモデルは
113
+ # ここから取るため、現在の provider に依存させない。
114
+ #
115
+ # 優先順位: (1) OLLAMA_HOST 環境変数 (Ollama 標準の設定) →
116
+ # (2) 現在の base_url が Ollama を指していればその親 →
117
+ # (3) 既定 (http://localhost:11434)。
118
+ def ollama_api_base
119
+ if (host = ENV["OLLAMA_HOST"]) && !host.to_s.empty?
120
+ return normalize_ollama_host(host)
121
+ end
122
+ base = llm_provider == "ollama" ? llm_base_url : OLLAMA_BASE_URL
123
+ base.to_s.sub(%r{/v1/?\z}, "").chomp("/")
124
+ end
125
+
126
+ # 実行時にモデル (と provider) を差し替える。Factory がここを参照して
127
+ # Planner / chat_client を再構築する。api_key_env は provider ごとに固定する
128
+ # (Ollama: OLLAMA_API_KEY / OpenRouter: OPENROUTER_API_KEY)。
129
+ def set_llm!(provider:, model:)
130
+ llm_cfg = (@data["llm"] ||= {})
131
+ llm_cfg["model"] = model
132
+ case provider.to_s
133
+ when "openrouter"
134
+ llm_cfg["base_url"] = OPENROUTER_BASE_URL
135
+ llm_cfg["api_key_env"] = "OPENROUTER_API_KEY"
136
+ else
137
+ llm_cfg["base_url"] = OLLAMA_BASE_URL
138
+ llm_cfg["api_key_env"] = "OLLAMA_API_KEY"
139
+ end
140
+ self
141
+ end
142
+
143
+ # 現在の llm 設定 (provider / model / base_url / api_key_env) をユーザー設定へ
144
+ # 書き戻す。llm セクションだけを更新し、他セクションは元のファイル内容を保つ。
145
+ #
146
+ # 保存先は常にユーザーパス (~/.config/chocomint/config.yaml)。読み込み元が
147
+ # gem 同梱 config だった場合 (初回コピーに失敗したケース) でも、gem ディレクトリ
148
+ # には書かず、ベースは @path (同梱) の内容を引き継いでユーザーパスへ書き出す。
149
+ def persist_llm!
150
+ dest = self.class.user_config_path
151
+ raw = begin
152
+ (File.exist?(@path) && YAML.safe_load(File.read(@path))) || {}
153
+ rescue Psych::SyntaxError
154
+ {}
155
+ end
156
+ raw = deep_stringify(raw)
157
+ raw["llm"] = @data["llm"]
158
+ FileUtils.mkdir_p(File.dirname(dest))
159
+ File.write(dest, YAML.dump(raw))
160
+ @path = dest
161
+ end
162
+
52
163
  private
53
164
 
165
+ # Ollama の既定ポート。OLLAMA_HOST にポートが無いとき補う。
166
+ OLLAMA_DEFAULT_PORT = 11434
167
+
168
+ # OLLAMA_HOST の値をクライアントが接続できる URL に正規化する。
169
+ # OLLAMA_HOST は Ollama サーバーの「待ち受け設定」なので、そのまま接続先には使えない:
170
+ # - scheme が無ければ http:// を補う
171
+ # - ポートが無ければ既定の 11434 を補う
172
+ # - 0.0.0.0 / :: (全 IF で listen する指定) は接続不可なので 127.0.0.1 に読み替える
173
+ def normalize_ollama_host(host)
174
+ host = host.to_s.strip
175
+ host = "http://#{host}" unless host.match?(%r{\Ahttps?://}i)
176
+ uri = URI.parse(host)
177
+ uri.host = "127.0.0.1" if ["0.0.0.0", "::", "[::]"].include?(uri.host)
178
+ # URI::HTTP は scheme 既定ポート (80) を .port に持つため、元文字列に ":ポート" が
179
+ # 明示されていたかで判定する。無ければ Ollama の既定ポートを補う。
180
+ uri.port = OLLAMA_DEFAULT_PORT unless host.match?(/:\d+(?:\/|\z)/)
181
+ uri.to_s.chomp("/")
182
+ rescue URI::InvalidURIError
183
+ OLLAMA_BASE_URL.sub(%r{/v1/?\z}, "")
184
+ end
185
+
54
186
  def apply_env_overrides!
55
187
  if (url = ENV["CHOCOMINT_LLM_BASE_URL"])
56
188
  (@data["llm"] ||= {})["base_url"] = url
@@ -0,0 +1,323 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "zlib"
5
+ require "stringio"
6
+ require "rubygems/package"
7
+
8
+ module Chocomint
9
+ # /edit AI エディタのアーカイブプレビュー用ハンドラ (Server に include して使う)。
10
+ #
11
+ # 提供機能:
12
+ # GET /edit/archive?path=foo.zip アーカイブの第一階層エントリ + メタデータ (JSON)
13
+ #
14
+ # 対応形式: .zip / .tar / .tar.gz (.tgz) / .gz / .tar.bz2 (.tbz2) / .bz2
15
+ # いずれも展開はメモリ上で行い、ディスクには書き出さない。tar/zip はエントリを
16
+ # ストリーム走査し、第一階層 (トップレベル) だけに畳んで返す。
17
+ #
18
+ # 対応形式は拡張子でしか判定しない (中身の magic は見ない)。ファイルアクセスは
19
+ # すべて @path_guard 経由で workspace 内に制限する。
20
+ module EditArchiveHandlers
21
+ # 一覧をブラウザで扱える範囲に抑えるためのエントリ数上限 (走査自体の安全弁)。
22
+ # tar は全走査するため、巨大アーカイブでも到達したら打ち切って capped を立てる。
23
+ MAX_ARCHIVE_ENTRIES = 20_000
24
+
25
+ # bzip2 展開に許す入力サイズ上限 (純Ruby 実装は重いので極端に巨大な .bz2 は弾く)。
26
+ MAX_BZIP2_INPUT_BYTES = 64 * 1024 * 1024
27
+
28
+ def handle_edit_archive(req, res)
29
+ return edit_method_guard(res) unless req.request_method == "GET"
30
+
31
+ path = req.query["path"].to_s
32
+ return edit_json(res, 400, "error" => "path required") if path.empty?
33
+
34
+ abs = @path_guard.resolve(path)
35
+ return edit_json(res, 404, "error" => "no such file") unless File.file?(abs)
36
+
37
+ kind = archive_kind(abs)
38
+ return edit_json(res, 415, "error" => "unsupported archive format") unless kind
39
+
40
+ info = inspect_archive(abs, kind)
41
+ edit_json(res, 200, { "path" => path }.merge(info))
42
+ rescue Chocomint::PathAccessError => e
43
+ edit_json(res, 403, "error" => e.message)
44
+ rescue ArchiveError => e
45
+ # 壊れている/未対応の内部構造など。UI 向けにメッセージを返す (500 にはしない)。
46
+ edit_json(res, 422, "error" => e.message)
47
+ rescue Chocomint::Error => e
48
+ edit_json(res, 500, "error" => e.message)
49
+ end
50
+
51
+ # アーカイブ処理中の想定内エラー (壊れたデータ・展開失敗など)。
52
+ class ArchiveError < StandardError; end
53
+
54
+ # ---- 形式判定 -----------------------------------------------------------
55
+
56
+ # 拡張子からアーカイブ形式を判定して種別文字列を返す (非対応は nil)。
57
+ # 種別: "zip" / "tar" / "tar.gz" / "tar.bz2" / "gz" / "bz2"
58
+ # 二重拡張子 (.tar.gz 等) を単一拡張子 (.gz) より優先する。
59
+ def archive_kind(abs)
60
+ lower = File.basename(abs).downcase
61
+ return "tar.gz" if lower.end_with?(".tar.gz")
62
+ return "tar.gz" if lower.end_with?(".tgz")
63
+ return "tar.bz2" if lower.end_with?(".tar.bz2")
64
+ return "tar.bz2" if lower.end_with?(".tbz2") || lower.end_with?(".tbz")
65
+ return "zip" if lower.end_with?(".zip")
66
+ return "tar" if lower.end_with?(".tar")
67
+ return "gz" if lower.end_with?(".gz")
68
+ return "bz2" if lower.end_with?(".bz2")
69
+
70
+ nil
71
+ end
72
+
73
+ # フロントがメディア/アーカイブを区別できるよう、handle_edit_file からも使う判定。
74
+ def archive?(abs)
75
+ !archive_kind(abs).nil?
76
+ end
77
+
78
+ # ---- 検査 (種別ごとにディスパッチ) --------------------------------------
79
+
80
+ def inspect_archive(abs, kind)
81
+ case kind
82
+ when "zip" then inspect_zip(abs)
83
+ when "tar" then inspect_tar(File.binread(abs))
84
+ when "tar.gz" then inspect_tar(gunzip(File.binread(abs)))
85
+ when "tar.bz2" then inspect_tar(bunzip2(abs))
86
+ when "gz" then inspect_single_compressed(abs, "gzip", strip_ext: ".gz")
87
+ when "bz2" then inspect_single_compressed(abs, "bzip2", strip_ext: ".bz2")
88
+ else
89
+ raise ArchiveError, "unsupported archive format"
90
+ end
91
+ end
92
+
93
+ # ---- tar の走査 ---------------------------------------------------------
94
+ #
95
+ # 展開済み tar バイト列を走査し、第一階層に畳んだ entries とメタデータを返す。
96
+ def inspect_tar(tar_bytes)
97
+ dirs = {} # トップレベルのディレクトリ名 => 配下エントリ数
98
+ files = [] # トップレベルの直下ファイル { name, size }
99
+ total = 0
100
+ total_size = 0
101
+ capped = false
102
+
103
+ Gem::Package::TarReader.new(StringIO.new(tar_bytes)) do |reader|
104
+ reader.each do |entry|
105
+ name = entry.full_name.to_s
106
+ next if name.empty? || name == "./"
107
+
108
+ total += 1
109
+ if total > MAX_ARCHIVE_ENTRIES
110
+ capped = true
111
+ break
112
+ end
113
+
114
+ rel = normalize_entry_name(name)
115
+ next if rel.empty?
116
+
117
+ size = entry.header.size.to_i
118
+ total_size += size unless entry.directory?
119
+ accumulate_top_level(rel, entry.directory?, size, dirs, files)
120
+ end
121
+ end
122
+
123
+ build_result(dirs, files, total, total_size, capped)
124
+ rescue Gem::Package::TarInvalidError, Zlib::GzipFile::Error => e
125
+ raise ArchiveError, "tar を読み取れませんでした: #{e.message}"
126
+ end
127
+
128
+ # ---- zip の走査 (中央ディレクトリを自前パース) --------------------------
129
+ #
130
+ # 標準ライブラリだけで済ませるため rubyzip には依存せず、ZIP の End Of Central
131
+ # Directory (EOCD) → Central Directory を辿ってエントリ名とサイズを読む。
132
+ # 実データ (ローカルファイルヘッダ) は読まないので大きな zip でも軽い。
133
+ def inspect_zip(abs)
134
+ raw = File.binread(abs)
135
+ cd = zip_central_directory(raw)
136
+
137
+ dirs = {}
138
+ files = []
139
+ total = 0
140
+ total_size = 0
141
+ capped = false
142
+
143
+ cd.each do |ent|
144
+ total += 1
145
+ if total > MAX_ARCHIVE_ENTRIES
146
+ capped = true
147
+ break
148
+ end
149
+ rel = normalize_entry_name(ent[:name])
150
+ next if rel.empty?
151
+
152
+ total_size += ent[:size] unless ent[:dir]
153
+ accumulate_top_level(rel, ent[:dir], ent[:size], dirs, files)
154
+ end
155
+
156
+ build_result(dirs, files, total, total_size, capped)
157
+ end
158
+
159
+ # ZIP の中央ディレクトリを読み、各エントリの { name, size, dir } を返す。
160
+ def zip_central_directory(raw)
161
+ eocd = find_eocd(raw)
162
+ raise ArchiveError, "zip の終端レコードが見つかりません" unless eocd
163
+
164
+ count = raw[eocd + 10, 2].unpack1("v")
165
+ offset = raw[eocd + 16, 4].unpack1("V")
166
+ entries = []
167
+ pos = offset
168
+
169
+ count.times do
170
+ break if pos + 46 > raw.bytesize
171
+ sig = raw[pos, 4]
172
+ break unless sig == "PK\x01\x02".b # Central Directory File Header
173
+
174
+ usize = raw[pos + 24, 4].unpack1("V") # uncompressed size
175
+ name_len = raw[pos + 28, 2].unpack1("v")
176
+ extra_len = raw[pos + 30, 2].unpack1("v")
177
+ comment_len = raw[pos + 32, 2].unpack1("v")
178
+ name = raw[pos + 46, name_len].to_s
179
+ name = zip_decode_name(name)
180
+ entries << { name: name, size: usize, dir: name.end_with?("/") }
181
+ pos += 46 + name_len + extra_len + comment_len
182
+ end
183
+
184
+ entries
185
+ end
186
+
187
+ # EOCD (End Of Central Directory, シグネチャ PK\x05\x06) を末尾から探す。
188
+ # ZIP コメントは最大 65535 バイトなので、末尾 (22 + 65535) だけ後方走査すれば十分。
189
+ def find_eocd(raw)
190
+ sig = "PK\x05\x06".b
191
+ min = [raw.bytesize - (22 + 0xFFFF), 0].max
192
+ i = raw.bytesize - 22
193
+ while i >= min
194
+ return i if raw[i, 4] == sig
195
+
196
+ i -= 1
197
+ end
198
+ nil
199
+ end
200
+
201
+ # zip のエントリ名を文字列に整える。UTF-8 として妥当ならそれ、駄目なら置換して壊さない。
202
+ def zip_decode_name(name)
203
+ s = name.dup.force_encoding("UTF-8")
204
+ s.valid_encoding? ? s : s.scrub("?")
205
+ end
206
+
207
+ # ---- 単体圧縮ファイル (.gz / .bz2、tar でない) --------------------------
208
+ #
209
+ # tar を含まない単なる圧縮ファイルは、展開後の 1 ファイルとして扱う。
210
+ # 中身の全展開は避け、メタデータ (圧縮/展開サイズ・推定名) だけを返す。
211
+ def inspect_single_compressed(abs, algorithm, strip_ext:)
212
+ compressed_size = File.size(abs)
213
+ inner_name = File.basename(abs)
214
+ inner_name = inner_name[0...-strip_ext.length] if inner_name.downcase.end_with?(strip_ext)
215
+
216
+ uncompressed_size =
217
+ case algorithm
218
+ when "gzip" then gzip_uncompressed_size(abs)
219
+ when "bzip2" then bunzip2(abs).bytesize
220
+ end
221
+
222
+ {
223
+ "archive" => algorithm == "gzip" ? "gz" : "bz2",
224
+ "single" => true,
225
+ "entries" => [{ "name" => inner_name, "dir" => false, "size" => uncompressed_size }],
226
+ "total_entries" => 1,
227
+ "total_size" => uncompressed_size,
228
+ "compressed_size" => compressed_size,
229
+ "capped" => false
230
+ }
231
+ end
232
+
233
+ # ---- 展開ヘルパ ---------------------------------------------------------
234
+
235
+ def gunzip(bytes)
236
+ Zlib::GzipReader.new(StringIO.new(bytes)).read
237
+ rescue Zlib::GzipFile::Error => e
238
+ raise ArchiveError, "gzip を展開できませんでした: #{e.message}"
239
+ end
240
+
241
+ # gzip 末尾 4 バイトの ISIZE (展開後サイズ mod 2^32)。4GB 未満の実用ケース向け。
242
+ def gzip_uncompressed_size(abs)
243
+ tail = File.open(abs, "rb") { |f| f.seek(-4, IO::SEEK_END); f.read(4) }
244
+ tail ? tail.unpack1("V") : nil
245
+ rescue SystemCallError
246
+ nil
247
+ end
248
+
249
+ # bzip2 を純Ruby (rbzip2) で展開する。ネイティブ libbz2 は不要。
250
+ def bunzip2(abs)
251
+ require "rbzip2"
252
+ raw = File.binread(abs)
253
+ if raw.bytesize > MAX_BZIP2_INPUT_BYTES
254
+ raise ArchiveError, "bzip2 ファイルが大きすぎます (上限 #{MAX_BZIP2_INPUT_BYTES / (1024 * 1024)}MB)"
255
+ end
256
+
257
+ RBzip2.default_adapter::Decompressor.new(StringIO.new(raw)).read
258
+ rescue LoadError
259
+ raise ArchiveError, "bzip2 の展開には rbzip2 gem が必要です"
260
+ rescue ArchiveError
261
+ raise
262
+ rescue StandardError => e
263
+ raise ArchiveError, "bzip2 を展開できませんでした: #{e.message}"
264
+ end
265
+
266
+ # ---- 集計の共通処理 -----------------------------------------------------
267
+
268
+ # エントリ名を正規化する ("./" 前置や重複スラッシュを除く。先頭は相対に統一)。
269
+ def normalize_entry_name(name)
270
+ s = name.tr("\\", "/")
271
+ s = s.sub(%r{\A\./}, "")
272
+ s = s.sub(%r{\A/+}, "")
273
+ s.gsub(%r{/+}, "/")
274
+ end
275
+
276
+ # 正規化済みエントリを「第一階層」に畳んで dirs / files に足し込む。
277
+ # "src/main.rb" → トップレベル dir "src" を登録し配下件数 +1
278
+ # "README.md" → トップレベル直下 file "README.md"
279
+ # "src/" → dir "src" を登録するだけ (ディレクトリ自身なので件数は増やさない)
280
+ # "emptydir/" → dir "emptydir" を件数 0 で登録 (空ディレクトリ)
281
+ def accumulate_top_level(rel, is_dir, size, dirs, files)
282
+ # 末尾スラッシュはディレクトリ自身のエントリ。区切りとしての "/" と区別する。
283
+ trailing_dir = rel.end_with?("/")
284
+ trimmed = trailing_dir ? rel.chomp("/") : rel
285
+ slash = trimmed.index("/")
286
+
287
+ if slash.nil?
288
+ # トップレベル直下のエントリ (ファイル or ディレクトリ自身)。
289
+ if is_dir || trailing_dir
290
+ dirs[trimmed] ||= 0 # 件数は増やさない (自身の登録だけ)。
291
+ else
292
+ files << { name: trimmed, size: size }
293
+ end
294
+ else
295
+ # 配下エントリ → トップレベルのディレクトリに畳んで件数を +1。
296
+ top = trimmed[0...slash]
297
+ dirs[top] ||= 0
298
+ dirs[top] += 1
299
+ end
300
+ end
301
+
302
+ # dirs / files から JSON 用の結果ハッシュを組み立てる。
303
+ # entries はディレクトリ→ファイルの順、各名前順 (一覧の並びに揃える)。
304
+ def build_result(dirs, files, total, total_size, capped)
305
+ dir_entries = dirs.keys.sort.map do |name|
306
+ { "name" => name, "dir" => true, "child_count" => dirs[name] }
307
+ end
308
+ # 同名ファイルの重複は稀だが、名前順で安定させる。
309
+ file_entries = files.uniq { |f| f[:name] }
310
+ .sort_by { |f| f[:name] }
311
+ .map { |f| { "name" => f[:name], "dir" => false, "size" => f[:size] } }
312
+
313
+ {
314
+ "archive" => "tar_or_zip",
315
+ "single" => false,
316
+ "entries" => dir_entries + file_entries,
317
+ "total_entries" => total,
318
+ "total_size" => total_size,
319
+ "capped" => capped
320
+ }
321
+ end
322
+ end
323
+ end