rufio 1.1.0 → 1.3.0

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.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/README.md +74 -3
  4. data/README_ja.md +71 -2
  5. data/Rakefile +14 -1
  6. data/bin/rufio +31 -2
  7. data/docs/fast_zip_design_ja.md +167 -0
  8. data/info/help.md +2 -0
  9. data/info/keybindings.md +3 -1
  10. data/info/welcome.md +1 -1
  11. data/lib/rufio/application.rb +12 -1
  12. data/lib/rufio/archive_controller.rb +211 -0
  13. data/lib/rufio/background_command_executor.rb +9 -0
  14. data/lib/rufio/background_status.rb +49 -0
  15. data/lib/rufio/bookmark_controller.rb +38 -121
  16. data/lib/rufio/bookmark_manager.rb +77 -331
  17. data/lib/rufio/command_completion.rb +6 -1
  18. data/lib/rufio/command_history.rb +5 -2
  19. data/lib/rufio/command_mode.rb +40 -134
  20. data/lib/rufio/command_mode_ui.rb +4 -42
  21. data/lib/rufio/config.rb +88 -136
  22. data/lib/rufio/config_loader.rb +3 -11
  23. data/lib/rufio/dialog_renderer.rb +64 -152
  24. data/lib/rufio/dialog_support.rb +69 -0
  25. data/lib/rufio/document_text_extractor.rb +167 -0
  26. data/lib/rufio/file_operation_controller.rb +49 -222
  27. data/lib/rufio/file_preview.rb +46 -2
  28. data/lib/rufio/global_search.rb +121 -9
  29. data/lib/rufio/help_menu.rb +8 -2
  30. data/lib/rufio/interpreter_resolver.rb +6 -11
  31. data/lib/rufio/job_manager.rb +2 -5
  32. data/lib/rufio/key_input.rb +77 -0
  33. data/lib/rufio/keybind_handler.rb +81 -115
  34. data/lib/rufio/local_script_scanner.rb +6 -18
  35. data/lib/rufio/native/aarch64-linux-gnu/librufio_zig.so +0 -0
  36. data/lib/rufio/native/aarch64-linux-musl/librufio_zig.so +0 -0
  37. data/lib/rufio/native/aarch64-macos/librufio_zig.dylib +0 -0
  38. data/lib/rufio/native/aarch64-windows/rufio_zig.dll +0 -0
  39. data/lib/rufio/native/x86_64-linux-gnu/librufio_zig.so +0 -0
  40. data/lib/rufio/native/x86_64-linux-musl/librufio_zig.so +0 -0
  41. data/lib/rufio/native/x86_64-macos/librufio_zig.dylib +0 -0
  42. data/lib/rufio/native/x86_64-windows/rufio_zig.dll +0 -0
  43. data/lib/rufio/native_content_search.rb +153 -0
  44. data/lib/rufio/native_scanner.rb +13 -68
  45. data/lib/rufio/native_scanner_zig.rb +106 -56
  46. data/lib/rufio/native_zip.rb +121 -0
  47. data/lib/rufio/navigation_controller.rb +18 -20
  48. data/lib/rufio/screen.rb +2 -12
  49. data/lib/rufio/script_executor.rb +6 -73
  50. data/lib/rufio/script_file.rb +44 -0
  51. data/lib/rufio/script_path_manager.rb +9 -43
  52. data/lib/rufio/script_runner.rb +13 -50
  53. data/lib/rufio/search_controller.rb +1 -1
  54. data/lib/rufio/shell_command_completion.rb +1 -5
  55. data/lib/rufio/shell_init.rb +90 -0
  56. data/lib/rufio/task_status.rb +5 -1
  57. data/lib/rufio/terminal_ui.rb +55 -294
  58. data/lib/rufio/text_utils.rb +26 -3
  59. data/lib/rufio/ui_renderer.rb +63 -40
  60. data/lib/rufio/version.rb +1 -1
  61. data/lib/rufio/zip_archive.rb +314 -0
  62. data/lib/rufio/zip_extractor.rb +346 -0
  63. data/lib/rufio/zip_writer.rb +268 -0
  64. data/lib/rufio/zoxide_integration.rb +5 -43
  65. data/lib/rufio.rb +5 -0
  66. data/lib_zig/rufio_native/Makefile +54 -17
  67. data/lib_zig/rufio_native/bench.sh +36 -0
  68. data/lib_zig/rufio_native/build.zig +32 -35
  69. data/lib_zig/rufio_native/src/cli.zig +99 -0
  70. data/lib_zig/rufio_native/src/main.zig +187 -83
  71. data/lib_zig/rufio_native/src/os.zig +889 -0
  72. data/lib_zig/rufio_native/src/search.zig +919 -0
  73. data/lib_zig/rufio_native/src/search_test.zig +88 -0
  74. data/lib_zig/rufio_native/src/zip.zig +482 -0
  75. metadata +125 -3
  76. data/lib/rufio/native/rufio_zig.bundle +0 -0
@@ -0,0 +1,346 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'zlib'
5
+ require_relative 'zip_archive'
6
+ require_relative 'native_zip'
7
+
8
+ module Rufio
9
+ # ZIPアーカイブの安全な展開(Pure Ruby 実装。ネイティブ実装の互換基準でもある)
10
+ #
11
+ # 書き込み前に全エントリを検証し、1件でも危険なエントリがあれば何も書かずに中止する。
12
+ # - Zip Slip: 絶対パス・ドライブレター・".." を含むエントリ、展開先外を指すエントリを拒否
13
+ # - シンボリックリンクのエントリは展開しない(展開先の既存シンボリックリンクも辿らない)
14
+ # - ZIP 爆弾: 展開後合計サイズ・エントリ数・圧縮率の上限、データ範囲が重なるエントリの拒否、
15
+ # 宣言サイズを超えて伸長されたら中止
16
+ # - CRC-32 不一致のファイルは残さない(一時ファイルに書いてから rename)
17
+ # - 既存ファイルは overwrite: true のときだけ上書きする
18
+ class ZipExtractor
19
+ class Error < StandardError; end
20
+ class UnsafeEntryError < Error; end
21
+ class LimitExceededError < Error; end
22
+ class ConflictError < Error; end
23
+ class CorruptEntryError < Error; end
24
+ class CancelledError < Error; end
25
+
26
+ DEFAULT_MAX_TOTAL_SIZE = 16 * 1024 * 1024 * 1024 # 16 GiB
27
+ DEFAULT_MAX_ENTRIES = 100_000
28
+ DEFAULT_MAX_RATIO = 1000
29
+ # 圧縮率の上限はこのサイズを超えるエントリにだけ適用する(小さな高圧縮ファイルは正常)
30
+ RATIO_CHECK_MIN_SIZE = 1024 * 1024
31
+
32
+ LOCAL_HEADER_SIGNATURE = 0x04034b50
33
+ LOCAL_HEADER_SIZE = 30
34
+ CHUNK_SIZE = 64 * 1024
35
+ SUPPORTED_METHODS = [0, 8].freeze
36
+
37
+ Result = Struct.new(:extracted, :skipped, :bytes_written, keyword_init: true)
38
+
39
+ # @param zip_path [String]
40
+ # @param destination [String] 展開先ディレクトリ(なければ作成する)
41
+ # @param progress [#call, nil] (書き込み済みバイト数, 合計バイト数) を受け取る
42
+ # @param cancelled [#call, nil] true を返したら CancelledError で中止する
43
+ # @param native [Boolean] Zig 実装でデータを並列に伸長する(検証は常に Ruby 側で行う)
44
+ def initialize(zip_path, destination, overwrite: false,
45
+ max_total_size: DEFAULT_MAX_TOTAL_SIZE, max_entries: DEFAULT_MAX_ENTRIES,
46
+ max_ratio: DEFAULT_MAX_RATIO, progress: nil, cancelled: nil, native: NativeZip.available?)
47
+ @zip_path = zip_path
48
+ @destination = File.expand_path(destination)
49
+ @overwrite = overwrite
50
+ @max_total_size = max_total_size
51
+ @max_entries = max_entries
52
+ @max_ratio = max_ratio
53
+ @progress = progress
54
+ @cancelled = cancelled
55
+ @native = native
56
+ end
57
+
58
+ def extract
59
+ listing = ZipArchive.list(@zip_path)
60
+ raise LimitExceededError, "Too many entries (#{listing.total_entries} > #{@max_entries})" if listing.total_entries > @max_entries
61
+
62
+ File.open(@zip_path, 'rb') do |io|
63
+ plan, skipped = build_plan(io, listing)
64
+ FileUtils.mkdir_p(@destination)
65
+ root = File.realpath(@destination)
66
+ total = plan.sum { |item| item[:entry].size }
67
+
68
+ # 書き込み先をすべて確定させてからデータを書く
69
+ plan.each do |item|
70
+ check_cancel!
71
+ item[:target] = safe_target(root, item[:relative])
72
+ make_directory(root, item[:entry].directory? ? item[:target] : File.dirname(item[:target]))
73
+ end
74
+
75
+ files = plan.reject { |item| item[:entry].directory? }
76
+ written = if @native && !files.empty?
77
+ write_files_native(files, total)
78
+ else
79
+ files.reduce(0) do |sum, item|
80
+ check_cancel!
81
+ write_file(io, item, sum, total)
82
+ end
83
+ end
84
+ apply_directory_times(root, plan)
85
+
86
+ Result.new(extracted: plan.map { |item| item[:relative] }, skipped: skipped, bytes_written: written)
87
+ end
88
+ end
89
+
90
+ # 展開先からの相対パスに正規化する。危険な名前は UnsafeEntryError
91
+ def self.normalize_entry_name(name)
92
+ raise UnsafeEntryError, 'Entry name is empty' if name.nil? || name.empty?
93
+ raise UnsafeEntryError, "Entry name contains NUL: #{name.inspect}" if name.include?("\0")
94
+
95
+ path = name.tr('\\', '/')
96
+ raise UnsafeEntryError, "Absolute path entry: #{name}" if path.start_with?('/') || path.match?(%r{\A[A-Za-z]:})
97
+
98
+ parts = path.split('/').reject { |part| part.empty? || part == '.' }
99
+ raise UnsafeEntryError, "Parent directory reference: #{name}" if parts.include?('..')
100
+ raise UnsafeEntryError, "Entry name is empty: #{name}" if parts.empty?
101
+ # Windows では ':' が代替データストリームやドライブ指定になる
102
+ raise UnsafeEntryError, "Entry name contains ':': #{name}" if Gem.win_platform? && parts.any? { |part| part.include?(':') }
103
+
104
+ parts.join('/')
105
+ end
106
+
107
+ private
108
+
109
+ # 書き込み前に全エントリを検証する
110
+ def build_plan(io, listing)
111
+ file_size = io.size
112
+ seen = {}
113
+ skipped = []
114
+ plan = []
115
+ total = 0
116
+
117
+ ranges = listing.entries.map do |entry|
118
+ start = data_start(io, entry, file_size)
119
+ finish = start + entry.compressed_size
120
+ raise CorruptEntryError, "Entry data is out of range: #{entry.name}" if finish > listing.central_directory_offset
121
+
122
+ [entry.local_header_offset, finish, entry, start]
123
+ end
124
+ check_overlaps(ranges)
125
+
126
+ ranges.each do |_header, _finish, entry, start|
127
+ relative = self.class.normalize_entry_name(entry.name)
128
+ if (reason = skip_reason(entry))
129
+ skipped << { name: entry.name, reason: reason }
130
+ next
131
+ end
132
+
133
+ raise UnsafeEntryError, "Duplicate entry: #{entry.name}" if seen[relative] && !entry.directory?
134
+
135
+ seen[relative] = true
136
+ check_limits(entry)
137
+ total += entry.size
138
+ raise LimitExceededError, "Total size exceeds limit (#{@max_total_size} bytes)" if total > @max_total_size
139
+
140
+ check_conflict(relative, entry)
141
+ plan << { entry: entry, relative: relative, data_offset: start }
142
+ end
143
+
144
+ [plan, skipped]
145
+ end
146
+
147
+ def skip_reason(entry)
148
+ return 'symlink' if entry.symlink?
149
+ return 'encrypted' if entry.encrypted?
150
+ return "unsupported method (#{ZipArchive.method_name(entry.method)})" unless entry.directory? || SUPPORTED_METHODS.include?(entry.method)
151
+
152
+ nil
153
+ end
154
+
155
+ def data_start(io, entry, file_size)
156
+ offset = entry.local_header_offset
157
+ raise CorruptEntryError, "Local header is out of range: #{entry.name}" if offset + LOCAL_HEADER_SIZE > file_size
158
+
159
+ io.seek(offset)
160
+ sig, *, name_length, extra_length = io.read(LOCAL_HEADER_SIZE).unpack('VvvvvvVVVvv')
161
+ raise CorruptEntryError, "Invalid local header: #{entry.name}" unless sig == LOCAL_HEADER_SIGNATURE
162
+
163
+ offset + LOCAL_HEADER_SIZE + name_length + extra_length
164
+ end
165
+
166
+ # 同じデータ範囲を複数エントリで共有する ZIP 爆弾(重複参照型)を拒否する
167
+ def check_overlaps(ranges)
168
+ ranges.sort_by(&:first).each_cons(2) do |(_, prev_finish, prev, _), (next_start, _, entry, _)|
169
+ raise UnsafeEntryError, "Overlapping entries: #{prev.name}, #{entry.name}" if next_start < prev_finish
170
+ end
171
+ end
172
+
173
+ def check_limits(entry)
174
+ return if entry.directory?
175
+ return if entry.size <= RATIO_CHECK_MIN_SIZE
176
+
177
+ if entry.compressed_size.zero? || entry.size / entry.compressed_size > @max_ratio
178
+ raise LimitExceededError, "Compression ratio exceeds limit (#{@max_ratio}:1): #{entry.name}"
179
+ end
180
+ end
181
+
182
+ def check_conflict(relative, entry)
183
+ target = File.join(@destination, relative)
184
+ return unless File.exist?(target) || File.symlink?(target)
185
+ return if entry.directory? && File.directory?(target) && !File.symlink?(target)
186
+ return if @overwrite && File.file?(target) && !File.symlink?(target)
187
+
188
+ raise ConflictError, "Already exists: #{relative}"
189
+ end
190
+
191
+ # 展開先の既存シンボリックリンクを辿って外に出ないことを確認する
192
+ def safe_target(root, relative)
193
+ target = File.join(root, relative)
194
+ current = root
195
+ relative.split('/')[0...-1].each do |part|
196
+ current = File.join(current, part)
197
+ next if verified_dirs.include?(current)
198
+ raise UnsafeEntryError, "Path goes through a symlink: #{relative}" if File.symlink?(current)
199
+ end
200
+ raise UnsafeEntryError, "Target is a symlink: #{relative}" if File.symlink?(target)
201
+
202
+ target
203
+ end
204
+
205
+ # 作成・検証済みのディレクトリ(同じディレクトリへの多数のファイルで検証を繰り返さない)
206
+ def verified_dirs
207
+ @verified_dirs ||= {}
208
+ end
209
+
210
+ def make_directory(root, path)
211
+ return if verified_dirs.include?(path)
212
+
213
+ FileUtils.mkdir_p(path)
214
+ real = File.realpath(path)
215
+ raise UnsafeEntryError, "Directory escapes destination: #{path}" unless real == root || real.start_with?(root.end_with?('/') ? root : "#{root}/")
216
+
217
+ # safe_target で途中にシンボリックリンクがないことを確認済みなので、祖先も検証済みとして扱う
218
+ current = path
219
+ until current == root || current.length <= root.length || verified_dirs.include?(current)
220
+ verified_dirs[current] = true
221
+ current = File.dirname(current)
222
+ end
223
+ end
224
+
225
+ def write_file(io, item, written, total)
226
+ entry = item[:entry]
227
+ temp = temp_path(item[:target])
228
+ crc = 0
229
+ size = 0
230
+
231
+ File.open(temp, File::WRONLY | File::CREAT | File::EXCL | File::BINARY, 0o600) do |out|
232
+ each_chunk(io, item) do |chunk|
233
+ size += chunk.bytesize
234
+ raise CorruptEntryError, "Entry is larger than declared: #{entry.name}" if size > entry.size
235
+
236
+ crc = Zlib.crc32(chunk, crc)
237
+ out.write(chunk)
238
+ written += chunk.bytesize
239
+ @progress&.call(written, total)
240
+ check_cancel!
241
+ end
242
+ end
243
+
244
+ raise CorruptEntryError, "Entry is smaller than declared: #{entry.name}" if size != entry.size
245
+ raise CorruptEntryError, "CRC-32 mismatch: #{entry.name}" if crc != entry.crc32
246
+
247
+ finish_file(temp, item)
248
+ written
249
+ ensure
250
+ File.unlink(temp) if temp && File.exist?(temp)
251
+ end
252
+
253
+ # Zig 実装で全ファイルを並列に一時ファイルへ伸長し、すべて検証が通ってから rename する
254
+ def write_files_native(files, total)
255
+ temps = files.map { |item| temp_path(item[:target]) }
256
+ tasks = files.each_with_index.map do |item, index|
257
+ entry = item[:entry]
258
+ { data_offset: item[:data_offset], compressed_size: entry.compressed_size, size: entry.size,
259
+ crc32: entry.crc32, method: entry.method, output: temps[index] }
260
+ end
261
+
262
+ job = NativeZip.extract(@zip_path, tasks)
263
+ begin
264
+ state = job.wait(progress: @progress && ->(done, _) { @progress.call(done, total) }, cancelled: @cancelled)
265
+ raise CancelledError, 'Extraction cancelled' if state == NativeZip::STATE_CANCELLED
266
+
267
+ files.each_with_index { |item, index| check_native_status(job.status(index), item[:entry]) }
268
+ files.each_with_index { |item, index| finish_file(temps[index], item) }
269
+ ensure
270
+ job.close
271
+ temps.each { |temp| File.unlink(temp) if File.exist?(temp) }
272
+ end
273
+ total
274
+ end
275
+
276
+ def check_native_status(status, entry)
277
+ case status
278
+ when NativeZip::STATUS_OK then nil
279
+ when NativeZip::STATUS_CRC_MISMATCH then raise CorruptEntryError, "CRC-32 mismatch: #{entry.name}"
280
+ when NativeZip::STATUS_TOO_LARGE then raise CorruptEntryError, "Entry is larger than declared: #{entry.name}"
281
+ when NativeZip::STATUS_SIZE_MISMATCH then raise CorruptEntryError, "Entry is smaller than declared: #{entry.name}"
282
+ when NativeZip::STATUS_CORRUPT then raise CorruptEntryError, "Invalid compressed data: #{entry.name}"
283
+ when NativeZip::STATUS_CANCELLED then raise CancelledError, 'Extraction cancelled'
284
+ else raise Error, "Failed to write #{entry.name}"
285
+ end
286
+ end
287
+
288
+ def temp_path(target)
289
+ "#{target}.rufio-#{Process.pid}-#{rand(1 << 32).to_s(16)}.tmp"
290
+ end
291
+
292
+ def finish_file(temp, item)
293
+ entry = item[:entry]
294
+ apply_mode(temp, entry)
295
+ File.rename(temp, item[:target])
296
+ File.utime(entry.mtime, entry.mtime, item[:target]) if entry.mtime
297
+ end
298
+
299
+ def each_chunk(io, item)
300
+ entry = item[:entry]
301
+ io.seek(item[:data_offset])
302
+ remaining = entry.compressed_size
303
+ inflater = entry.method == 8 ? Zlib::Inflate.new(-Zlib::MAX_WBITS) : nil
304
+
305
+ while remaining.positive?
306
+ data = io.read([remaining, CHUNK_SIZE].min)
307
+ raise CorruptEntryError, "Unexpected end of data: #{entry.name}" if data.nil? || data.empty?
308
+
309
+ remaining -= data.bytesize
310
+ if inflater
311
+ # 伸長結果が宣言サイズを大きく超えないよう、小分けに伸長する
312
+ inflater.inflate(data) { |chunk| yield chunk }
313
+ else
314
+ yield data
315
+ end
316
+ end
317
+ inflater&.finish&.then { |rest| yield rest unless rest.empty? }
318
+ rescue Zlib::Error => e
319
+ raise CorruptEntryError, "Invalid compressed data: #{entry.name} (#{e.message})"
320
+ ensure
321
+ inflater&.close
322
+ end
323
+
324
+ # 実行権限だけを引き継ぐ(setuid / setgid / sticky や他者書き込み権限は付けない)
325
+ def apply_mode(path, entry)
326
+ return if Gem.win_platform?
327
+
328
+ @umask ||= File.umask
329
+ mode = entry.unix_mode
330
+ File.chmod(mode && (mode & 0o111).nonzero? ? 0o777 & ~@umask & ~0o022 : 0o666 & ~@umask, path)
331
+ end
332
+
333
+ def apply_directory_times(root, plan)
334
+ plan.reverse_each do |item|
335
+ entry = item[:entry]
336
+ next unless entry.directory? && entry.mtime
337
+
338
+ File.utime(entry.mtime, entry.mtime, File.join(root, item[:relative]))
339
+ end
340
+ end
341
+
342
+ def check_cancel!
343
+ raise CancelledError, 'Extraction cancelled' if @cancelled&.call
344
+ end
345
+ end
346
+ end
@@ -0,0 +1,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'zlib'
5
+ require_relative 'zip_archive'
6
+ require_relative 'native_zip'
7
+
8
+ module Rufio
9
+ # ZIPアーカイブの作成(Pure Ruby 実装。ネイティブ実装の互換基準でもある)
10
+ #
11
+ # - ファイル名は UTF-8(汎用フラグ bit 11)で格納する
12
+ # - Deflate で縮まないファイルは Stored で格納し直す
13
+ # - 4GiB 超のファイル・オフセット、65535 件超のエントリでは ZIP64 を使う
14
+ # - シンボリックリンクは辿らずにスキップする
15
+ # - 一時ファイルに書いてから rename するので、失敗・中止時に壊れたアーカイブを残さない
16
+ class ZipWriter
17
+ class Error < StandardError; end
18
+ class ConflictError < Error; end
19
+ class CancelledError < Error; end
20
+
21
+ LOCAL_HEADER_SIGNATURE = 0x04034b50
22
+ CHUNK_SIZE = 64 * 1024
23
+ MAX32 = 0xFFFFFFFF
24
+ MAX16 = 0xFFFF
25
+ # Deflate で僅かに膨らんでも 32bit に収まるよう、このサイズ以上は最初から ZIP64 拡張を付ける
26
+ ZIP64_LOCAL_THRESHOLD = 0xFFFF0000
27
+ VERSION_DEFAULT = 20
28
+ VERSION_ZIP64 = 45
29
+ VERSION_MADE_BY = (ZipArchive::HOST_UNIX << 8) | 63
30
+ METHOD_STORED = 0
31
+ METHOD_DEFLATE = 8
32
+ # これ未満のファイルは Zig に渡さずその場で圧縮する(一時ファイルの作成・コピーの方が高くつく)
33
+ NATIVE_MIN_SIZE = 1024 * 1024
34
+
35
+ Result = Struct.new(:added, :skipped, :bytes_read, keyword_init: true)
36
+ Item = Struct.new(:path, :name, :directory, :size, :mtime, :mode, keyword_init: true)
37
+
38
+ # @param zip_path [String] 作成するZIPファイル
39
+ # @param base_dir [String] アーカイブ内パスの基準ディレクトリ
40
+ # @param paths [Array<String>] base_dir からの相対パス(ディレクトリは再帰的に追加)
41
+ # @param native [Boolean] Zig 実装でファイルを並列に圧縮してから順に格納する
42
+ def initialize(zip_path, base_dir, paths, level: Zlib::DEFAULT_COMPRESSION, overwrite: false,
43
+ progress: nil, cancelled: nil, native: NativeZip.available?)
44
+ @zip_path = File.expand_path(zip_path)
45
+ @base_dir = File.expand_path(base_dir)
46
+ @paths = paths
47
+ @level = level
48
+ @overwrite = overwrite
49
+ @progress = progress
50
+ @cancelled = cancelled
51
+ @native = native
52
+ end
53
+
54
+ def write
55
+ raise ConflictError, "Already exists: #{@zip_path}" if File.exist?(@zip_path) && !@overwrite
56
+
57
+ items, skipped = collect_items
58
+ total = items.sum(&:size)
59
+ temp = "#{@zip_path}.rufio-#{Process.pid}-#{rand(1 << 32).to_s(16)}.tmp"
60
+ bytes_read = 0
61
+ prepared = {}
62
+ native_items = @native ? items.select { |item| !item.directory && item.size >= NATIVE_MIN_SIZE } : []
63
+ unless native_items.empty?
64
+ parts_dir = "#{temp}.parts"
65
+ Dir.mkdir(parts_dir, 0o700)
66
+ prepared = deflate_natively(native_items, parts_dir, total)
67
+ bytes_read = native_items.sum(&:size)
68
+ end
69
+
70
+ File.open(temp, File::RDWR | File::CREAT | File::EXCL | File::BINARY, 0o644) do |out|
71
+ centrals = items.map do |item|
72
+ check_cancel!
73
+ record = write_entry(out, item, prepared[item]) do |count|
74
+ bytes_read += count
75
+ @progress&.call(bytes_read, total)
76
+ check_cancel!
77
+ end
78
+ central_header(item, record)
79
+ end
80
+ write_end_records(out, centrals, items.length)
81
+ end
82
+ File.rename(temp, @zip_path)
83
+
84
+ Result.new(added: items.map(&:name), skipped: skipped, bytes_read: bytes_read)
85
+ ensure
86
+ File.unlink(temp) if temp && File.exist?(temp)
87
+ FileUtils.rm_rf(parts_dir) if parts_dir
88
+ end
89
+
90
+ private
91
+
92
+ def collect_items
93
+ items = []
94
+ skipped = []
95
+ @paths.each do |relative|
96
+ path = File.expand_path(relative, @base_dir)
97
+ raise Error, "Path is outside the base directory: #{relative}" unless path.start_with?(base_prefix)
98
+
99
+ collect_path(path, items, skipped)
100
+ end
101
+ [items.uniq(&:name), skipped]
102
+ end
103
+
104
+ def base_prefix
105
+ @base_prefix ||= @base_dir.end_with?('/') ? @base_dir : "#{@base_dir}/"
106
+ end
107
+
108
+ def collect_path(path, items, skipped)
109
+ name = path.delete_prefix(base_prefix)
110
+ stat = File.lstat(path)
111
+
112
+ if stat.symlink?
113
+ skipped << { name: name, reason: 'symlink' }
114
+ elsif File.identical?(path, @zip_path)
115
+ skipped << { name: name, reason: 'output archive' }
116
+ elsif stat.directory?
117
+ items << Item.new(path: path, name: "#{name}/", directory: true, size: 0, mtime: stat.mtime, mode: stat.mode)
118
+ Dir.children(path).sort.each { |child| collect_path(File.join(path, child), items, skipped) }
119
+ elsif stat.file?
120
+ items << Item.new(path: path, name: name, directory: false, size: stat.size, mtime: stat.mtime, mode: stat.mode)
121
+ else
122
+ skipped << { name: name, reason: 'special file' }
123
+ end
124
+ end
125
+
126
+ # Zig 実装で各ファイルを raw DEFLATE で parts_dir に並列に圧縮する
127
+ # @return [Hash{Item => Hash}] crc / compressed / part
128
+ def deflate_natively(files, parts_dir, total)
129
+ tasks = files.each_with_index.map { |item, index| { source: item.path, output: File.join(parts_dir, index.to_s) } }
130
+ job = NativeZip.deflate(tasks, level: @level)
131
+ begin
132
+ state = job.wait(progress: @progress && ->(done, _) { @progress.call(done, total) }, cancelled: @cancelled)
133
+ raise CancelledError, 'Archive creation cancelled' if state == NativeZip::STATE_CANCELLED
134
+
135
+ files.each_with_index.to_h do |item, index|
136
+ status, crc, compressed, size = job.result(index)
137
+ raise CancelledError, 'Archive creation cancelled' if status == NativeZip::STATUS_CANCELLED
138
+ raise Error, "Failed to compress #{item.name}" unless status == NativeZip::STATUS_OK
139
+ raise Error, "File changed while compressing: #{item.name}" unless size == item.size
140
+
141
+ [item, { crc: crc, compressed: compressed, part: tasks[index][:output] }]
142
+ end
143
+ ensure
144
+ job.close
145
+ end
146
+ end
147
+
148
+ def write_entry(out, item, prepared = nil)
149
+ offset = out.pos
150
+ name = item.name.encode(Encoding::UTF_8).b
151
+ zip64_local = item.size >= ZIP64_LOCAL_THRESHOLD
152
+ dos_time, dos_date = dos_datetime(item.mtime)
153
+ flags = ZipArchive::FLAG_UTF8
154
+
155
+ method = item.directory || item.size.zero? ? METHOD_STORED : METHOD_DEFLATE
156
+ out.write(local_header(name, flags, method, dos_time, dos_date, zip64_local))
157
+ data_offset = out.pos
158
+
159
+ crc, compressed = if prepared
160
+ copy_prepared(out, item, prepared)
161
+ elsif item.directory
162
+ [0, 0]
163
+ else
164
+ write_data(out, item, method) { |n| yield n }
165
+ end
166
+ if method == METHOD_DEFLATE && compressed >= item.size
167
+ # 縮まなかったので Stored で書き直す(ヘッダは固定長なので位置は変わらない)
168
+ method = METHOD_STORED
169
+ out.seek(data_offset)
170
+ out.truncate(data_offset)
171
+ crc, compressed = write_data(out, item, method) { |_| nil }
172
+ end
173
+ raise Error, "File too large for this entry: #{item.name}" if !zip64_local && compressed > MAX32
174
+
175
+ # 圧縮後のサイズと CRC を入れたローカルヘッダで書き戻す
176
+ finish = out.pos
177
+ out.seek(offset)
178
+ out.write(local_header(name, flags, method, dos_time, dos_date, zip64_local,
179
+ crc: crc, compressed: compressed, size: item.size))
180
+ out.seek(finish)
181
+
182
+ { offset: offset, crc: crc, compressed: compressed, method: method, flags: flags,
183
+ dos_time: dos_time, dos_date: dos_date, name: name }
184
+ end
185
+
186
+ def copy_prepared(out, item, prepared)
187
+ copied = IO.copy_stream(prepared[:part], out)
188
+ raise Error, "Compressed data is incomplete: #{item.name}" unless copied == prepared[:compressed]
189
+
190
+ [prepared[:crc], prepared[:compressed]]
191
+ end
192
+
193
+ def local_header(name, flags, method, dos_time, dos_date, zip64, crc: 0, compressed: 0, size: 0)
194
+ extra = zip64 ? [ZipArchive::ZIP64_EXTRA_ID, 16, size, compressed].pack('vvQ<Q<') : ''.b
195
+ version = zip64 ? VERSION_ZIP64 : VERSION_DEFAULT
196
+ sizes = zip64 ? [MAX32, MAX32] : [compressed, size]
197
+ [LOCAL_HEADER_SIGNATURE, version, flags, method, dos_time, dos_date, crc, *sizes,
198
+ name.bytesize, extra.bytesize].pack('VvvvvvVVVvv') + name + extra
199
+ end
200
+
201
+ def write_data(out, item, method)
202
+ crc = 0
203
+ compressed = 0
204
+ deflater = method == METHOD_DEFLATE ? Zlib::Deflate.new(@level, -Zlib::MAX_WBITS) : nil
205
+
206
+ File.open(item.path, 'rb') do |file|
207
+ while (chunk = file.read(CHUNK_SIZE))
208
+ crc = Zlib.crc32(chunk, crc)
209
+ data = deflater ? deflater.deflate(chunk) : chunk
210
+ compressed += out.write(data)
211
+ yield chunk.bytesize
212
+ end
213
+ end
214
+ compressed += out.write(deflater.finish) if deflater
215
+ [crc, compressed]
216
+ ensure
217
+ deflater&.close
218
+ end
219
+
220
+ def central_header(item, record)
221
+ zip64_values = []
222
+ size = item.size
223
+ compressed = record[:compressed]
224
+ offset = record[:offset]
225
+ zip64_values << size if size >= MAX32
226
+ zip64_values << compressed if compressed >= MAX32
227
+ zip64_values << offset if offset >= MAX32
228
+ extra = zip64_values.empty? ? ''.b : [ZipArchive::ZIP64_EXTRA_ID, zip64_values.size * 8, *zip64_values].pack("vvQ<#{zip64_values.size}")
229
+ version = zip64_values.empty? ? VERSION_DEFAULT : VERSION_ZIP64
230
+ type = item.directory ? ZipArchive::S_IFDIR : ZipArchive::S_IFREG
231
+ external = ((type | (item.mode & 0o777)) << 16) | (item.directory ? 0x10 : 0)
232
+
233
+ [ZipArchive::CENTRAL_HEADER_SIGNATURE, VERSION_MADE_BY, version, record[:flags], record[:method],
234
+ record[:dos_time], record[:dos_date], record[:crc], [compressed, MAX32].min, [size, MAX32].min,
235
+ record[:name].bytesize, extra.bytesize, 0, 0, 0, external, [offset, MAX32].min]
236
+ .pack('VvvvvvvVVVvvvvvVV') + record[:name] + extra
237
+ end
238
+
239
+ def write_end_records(out, centrals, count)
240
+ cd_offset = out.pos
241
+ centrals.each { |header| out.write(header) }
242
+ cd_size = out.pos - cd_offset
243
+
244
+ if count >= MAX16 || cd_size >= MAX32 || cd_offset >= MAX32
245
+ zip64_offset = out.pos
246
+ out.write([ZipArchive::ZIP64_EOCD_SIGNATURE, 44, VERSION_MADE_BY, VERSION_ZIP64, 0, 0,
247
+ count, count, cd_size, cd_offset].pack('VQ<vvVVQ<Q<Q<Q<'))
248
+ out.write([ZipArchive::ZIP64_LOCATOR_SIGNATURE, 0, zip64_offset, 1].pack('VVQ<V'))
249
+ end
250
+
251
+ out.write([ZipArchive::EOCD_SIGNATURE, 0, 0, [count, MAX16].min, [count, MAX16].min,
252
+ [cd_size, MAX32].min, [cd_offset, MAX32].min, 0].pack('VvvvvVVv'))
253
+ end
254
+
255
+ # DOS 日時は 1980〜2107 年のみ表現できる
256
+ def dos_datetime(time)
257
+ time = time.localtime
258
+ return [0, (1 << 5) | 1] if time.year < 1980
259
+
260
+ time = Time.new(2107, 12, 31, 23, 59, 58) if time.year > 2107
261
+ [(time.hour << 11) | (time.min << 5) | (time.sec / 2), ((time.year - 1980) << 9) | (time.month << 5) | time.day]
262
+ end
263
+
264
+ def check_cancel!
265
+ raise CancelledError, 'Archive creation cancelled' if @cancelled&.call
266
+ end
267
+ end
268
+ end
@@ -1,10 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'dialog_support'
4
+
3
5
  require 'shellwords'
4
6
 
5
7
  module Rufio
6
8
  # Integrates with zoxide for directory history navigation
7
9
  class ZoxideIntegration
10
+ include DialogSupport
11
+
8
12
  # Dialog size constants
9
13
  DIALOG_WIDTH = 45
10
14
  DIALOG_BORDER_HEIGHT = 4
@@ -88,48 +92,6 @@ module Rufio
88
92
 
89
93
  private
90
94
 
91
- # オーバーレイダイアログを表示してキー入力を待つヘルパーメソッド
92
- def show_overlay_dialog(title, content_lines, options = {}, &block)
93
- # terminal_ui が利用可能で、screen と renderer が存在する場合のみオーバーレイを使用
94
- use_overlay = @terminal_ui &&
95
- @terminal_ui.respond_to?(:screen) &&
96
- @terminal_ui.respond_to?(:renderer) &&
97
- @terminal_ui.screen &&
98
- @terminal_ui.renderer
99
-
100
- if use_overlay
101
- # オーバーレイを使用
102
- @terminal_ui.show_overlay_dialog(title, content_lines, options, &block)
103
- else
104
- # フォールバック: 従来の方法
105
- width = options[:width]
106
- height = options[:height]
107
-
108
- unless width && height
109
- width, height = @dialog_renderer.calculate_dimensions(content_lines, {
110
- title: title,
111
- min_width: options[:min_width] || 40,
112
- max_width: options[:max_width] || 80
113
- })
114
- end
115
-
116
- x, y = @dialog_renderer.calculate_center(width, height)
117
-
118
- @dialog_renderer.draw_floating_window(x, y, width, height, title, content_lines, {
119
- border_color: options[:border_color] || "\e[37m",
120
- title_color: options[:title_color] || "\e[1;33m",
121
- content_color: options[:content_color] || "\e[37m"
122
- })
123
-
124
- key = block_given? ? yield : STDIN.getch
125
-
126
- @dialog_renderer.clear_area(x, y, width, height)
127
- @terminal_ui&.refresh_display
128
-
129
- key
130
- end
131
- end
132
-
133
95
  # Show message when no history is available
134
96
  def show_no_history_message
135
97
  return unless @dialog_renderer
@@ -197,7 +159,7 @@ module Rufio
197
159
  input_buffer = ''
198
160
 
199
161
  loop do
200
- char = STDIN.getch
162
+ char = KeyInput.getch
201
163
 
202
164
  case char
203
165
  when "\e", "\x03" # ESC, Ctrl+C