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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -0
- data/README.md +74 -3
- data/README_ja.md +71 -2
- data/Rakefile +14 -1
- data/bin/rufio +31 -2
- data/docs/fast_zip_design_ja.md +167 -0
- data/info/help.md +2 -0
- data/info/keybindings.md +3 -1
- data/info/welcome.md +1 -1
- data/lib/rufio/application.rb +12 -1
- data/lib/rufio/archive_controller.rb +211 -0
- data/lib/rufio/background_command_executor.rb +9 -0
- data/lib/rufio/background_status.rb +49 -0
- data/lib/rufio/bookmark_controller.rb +38 -121
- data/lib/rufio/bookmark_manager.rb +77 -331
- data/lib/rufio/command_completion.rb +6 -1
- data/lib/rufio/command_history.rb +5 -2
- data/lib/rufio/command_mode.rb +40 -134
- data/lib/rufio/command_mode_ui.rb +4 -42
- data/lib/rufio/config.rb +88 -136
- data/lib/rufio/config_loader.rb +3 -11
- data/lib/rufio/dialog_renderer.rb +64 -152
- data/lib/rufio/dialog_support.rb +69 -0
- data/lib/rufio/document_text_extractor.rb +167 -0
- data/lib/rufio/file_operation_controller.rb +49 -222
- data/lib/rufio/file_preview.rb +46 -2
- data/lib/rufio/global_search.rb +121 -9
- data/lib/rufio/help_menu.rb +8 -2
- data/lib/rufio/interpreter_resolver.rb +6 -11
- data/lib/rufio/job_manager.rb +2 -5
- data/lib/rufio/key_input.rb +77 -0
- data/lib/rufio/keybind_handler.rb +81 -115
- data/lib/rufio/local_script_scanner.rb +6 -18
- data/lib/rufio/native/aarch64-linux-gnu/librufio_zig.so +0 -0
- data/lib/rufio/native/aarch64-linux-musl/librufio_zig.so +0 -0
- data/lib/rufio/native/aarch64-macos/librufio_zig.dylib +0 -0
- data/lib/rufio/native/aarch64-windows/rufio_zig.dll +0 -0
- data/lib/rufio/native/x86_64-linux-gnu/librufio_zig.so +0 -0
- data/lib/rufio/native/x86_64-linux-musl/librufio_zig.so +0 -0
- data/lib/rufio/native/x86_64-macos/librufio_zig.dylib +0 -0
- data/lib/rufio/native/x86_64-windows/rufio_zig.dll +0 -0
- data/lib/rufio/native_content_search.rb +153 -0
- data/lib/rufio/native_scanner.rb +13 -68
- data/lib/rufio/native_scanner_zig.rb +106 -56
- data/lib/rufio/native_zip.rb +121 -0
- data/lib/rufio/navigation_controller.rb +18 -20
- data/lib/rufio/screen.rb +2 -12
- data/lib/rufio/script_executor.rb +6 -73
- data/lib/rufio/script_file.rb +44 -0
- data/lib/rufio/script_path_manager.rb +9 -43
- data/lib/rufio/script_runner.rb +13 -50
- data/lib/rufio/search_controller.rb +1 -1
- data/lib/rufio/shell_command_completion.rb +1 -5
- data/lib/rufio/shell_init.rb +90 -0
- data/lib/rufio/task_status.rb +5 -1
- data/lib/rufio/terminal_ui.rb +55 -294
- data/lib/rufio/text_utils.rb +26 -3
- data/lib/rufio/ui_renderer.rb +63 -40
- data/lib/rufio/version.rb +1 -1
- data/lib/rufio/zip_archive.rb +314 -0
- data/lib/rufio/zip_extractor.rb +346 -0
- data/lib/rufio/zip_writer.rb +268 -0
- data/lib/rufio/zoxide_integration.rb +5 -43
- data/lib/rufio.rb +5 -0
- data/lib_zig/rufio_native/Makefile +54 -17
- data/lib_zig/rufio_native/bench.sh +36 -0
- data/lib_zig/rufio_native/build.zig +32 -35
- data/lib_zig/rufio_native/src/cli.zig +99 -0
- data/lib_zig/rufio_native/src/main.zig +187 -83
- data/lib_zig/rufio_native/src/os.zig +889 -0
- data/lib_zig/rufio_native/src/search.zig +919 -0
- data/lib_zig/rufio_native/src/search_test.zig +88 -0
- data/lib_zig/rufio_native/src/zip.zig +482 -0
- metadata +125 -3
- 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 =
|
|
162
|
+
char = KeyInput.getch
|
|
201
163
|
|
|
202
164
|
case char
|
|
203
165
|
when "\e", "\x03" # ESC, Ctrl+C
|